@wireai/activation 0.14.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +144 -0
- package/README.md +23 -9
- package/dist/analytics/index.d.mts +5 -5
- package/dist/analytics/index.d.ts +5 -5
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +13 -5
- package/dist/coachmarks/index.d.ts +13 -5
- package/dist/coachmarks/index.js +252 -58
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +204 -9
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-CW_5Mq4O.d.ts → currentSession-CFSRZ2wg.d.ts} +10 -4
- package/dist/{currentSession-CUvTOchb.d.mts → currentSession-orZy5p1e.d.mts} +10 -4
- package/dist/{decision-Bgo17oH7.d.mts → decision-3vWLuBlO.d.ts} +11 -2
- package/dist/{decision-Bkh_LigV.d.ts → decision-yBj2AyPW.d.mts} +11 -2
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +3 -3
- package/dist/questionnaire/index.d.ts +3 -3
- package/dist/questionnaire/index.js +48 -3
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +48 -3
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +10 -6
- package/dist/reviews/index.d.ts +10 -6
- package/dist/reviews/index.js +167 -18
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +167 -18
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.d.mts +1 -1
- package/dist/showcase/index.d.ts +1 -1
- package/dist/showcase/index.js +282 -48
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +248 -12
- package/dist/showcase/index.mjs.map +1 -1
- package/dist/{transport-j5gFfJhK.d.mts → transport-s5QxA-ci.d.mts} +18 -13
- package/dist/{transport-B_0SgCBe.d.ts → transport-xqqSFqxs.d.ts} +18 -13
- package/dist/{types-Cju-1_jT.d.mts → types-Byx306Kv.d.mts} +25 -10
- package/dist/{types-BcmagF6K.d.mts → types-D_0B0yay.d.mts} +7 -2
- package/dist/{types-BcmagF6K.d.ts → types-D_0B0yay.d.ts} +7 -2
- package/dist/{types-h2BZvl1t.d.ts → types-tdATL5z0.d.ts} +25 -10
- package/metro/index.js +16 -5
- package/package.json +1 -1
- package/src/WireOnboarding.tsx +4 -1
- package/src/coachmarks/GestureHint.tsx +16 -7
- package/src/coachmarks/SpotlightOverlay.tsx +19 -13
- package/src/coachmarks/index.ts +7 -2
- package/src/coachmarks/reanimated.ts +342 -0
- package/src/coachmarks/runtime.ts +103 -2
- package/src/coachmarks/types.ts +7 -2
- package/src/context/deviceId.ts +32 -0
- package/src/reviews/ReviewGate.tsx +86 -37
- package/src/reviews/decision.ts +11 -1
- package/src/reviews/idempotency.ts +71 -0
- package/src/reviews/index.ts +4 -2
- package/src/reviews/runtime.ts +5 -3
- package/src/reviews/transport.ts +17 -12
- package/src/reviews/types.ts +7 -0
- package/src/showcase/FeatureShowcase.tsx +25 -9
- package/src/showcase/blazejOnboarding.ts +136 -0
- package/src/showcase/index.ts +8 -4
- package/src/types.ts +10 -4
- package/src/utils/submitResult.ts +17 -9
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
*
|
|
18
18
|
* The form NEVER asks for identity (no name / email / contact field): the host already
|
|
19
19
|
* identifies the user via `sessionId` + `meta` (e.g. user_id, device_key), which is all
|
|
20
|
-
* the POST body carries alongside the rating and the free text.
|
|
20
|
+
* the POST body carries alongside the rating and the free text. Since 2026-08-19 the gate also
|
|
21
|
+
* fills in `meta.device_key` from the kit's own per-install id when the host passed none — not for
|
|
22
|
+
* identification, but because it is the unit the server scopes the idempotency key to, and without
|
|
23
|
+
* it the key is discarded. Any non-blank string the host passes still wins verbatim. See
|
|
24
|
+
* `idempotency.ts`.
|
|
21
25
|
*
|
|
22
26
|
* Motion (2026-07-16): the three phases used to swap IN PLACE with no transition. They now
|
|
23
27
|
* ride `ModalPaneHost`, the same pane-slide primitive the questionnaire steps use, and the
|
|
@@ -45,6 +49,7 @@ import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
|
45
49
|
import { bodyStyle, captionStyle, headingStyle } from "../theme/typography";
|
|
46
50
|
import { buildReviewSubmission, routeRating } from "./decision";
|
|
47
51
|
import { feedbackInputHeights } from "./feedbackForm";
|
|
52
|
+
import { createImpression, hasDeviceKey, type ReviewImpression } from "./idempotency";
|
|
48
53
|
import { resolvePresentation } from "./presentation";
|
|
49
54
|
import { ReviewModal, type ReviewModalHandle } from "./ReviewModal";
|
|
50
55
|
import { requestStoreReview } from "./storeReview";
|
|
@@ -107,18 +112,63 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
107
112
|
onResolved?.();
|
|
108
113
|
}, [onResolved]);
|
|
109
114
|
|
|
115
|
+
/**
|
|
116
|
+
* ONE identity + ONE idempotency key for this whole gate impression, opened lazily at the first
|
|
117
|
+
* post so a prompt the user never acts on mints nothing.
|
|
118
|
+
*
|
|
119
|
+
* Both values live for the IMPRESSION, not the render and not the post: the server derives the
|
|
120
|
+
* row id from `(app_id, unit, key)`, so a key that moves between two posts writes two rows and a
|
|
121
|
+
* unit that moves does the same. `idempotency.ts` carries the full reasoning.
|
|
122
|
+
*/
|
|
123
|
+
const impressionRef = React.useRef<ReviewImpression | null>(null);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build a submission for THIS impression: the host's `meta` plus whatever the call site adds,
|
|
127
|
+
* stamped with the impression's key and — only when the host supplied none — its device key.
|
|
128
|
+
*
|
|
129
|
+
* A host-supplied `meta.device_key` ALWAYS wins and is never overwritten — "supplied" meaning a
|
|
130
|
+
* string with at least one non-whitespace character, the bar {@link hasDeviceKey} sets; a blank
|
|
131
|
+
* or non-string value is not a usable unit and is replaced. This mirrors the `userContext` /
|
|
132
|
+
* `autoJoinKey` rule one directory over, which sets the same bar through `resolveIdentity`. The
|
|
133
|
+
* injection exists because without a resolvable unit the server discards the key outright, and a
|
|
134
|
+
* home-feed gate has no `session_id` to fall back to.
|
|
135
|
+
*/
|
|
136
|
+
const buildBody = useCallback(
|
|
137
|
+
(input: {
|
|
138
|
+
stars: number;
|
|
139
|
+
feedbackText?: string;
|
|
140
|
+
suggestion?: string;
|
|
141
|
+
extraMeta?: Record<string, unknown>;
|
|
142
|
+
}): ReviewSubmission => {
|
|
143
|
+
let impression = impressionRef.current;
|
|
144
|
+
if (!impression) {
|
|
145
|
+
impression = createImpression();
|
|
146
|
+
impressionRef.current = impression;
|
|
147
|
+
}
|
|
148
|
+
const merged: Record<string, unknown> = { ...meta, ...input.extraMeta };
|
|
149
|
+
if (!hasDeviceKey(merged) && impression.deviceKey) merged.device_key = impression.deviceKey;
|
|
150
|
+
return buildReviewSubmission({
|
|
151
|
+
stars: input.stars,
|
|
152
|
+
feedbackText: input.feedbackText,
|
|
153
|
+
suggestion: input.suggestion,
|
|
154
|
+
sessionId,
|
|
155
|
+
meta: merged,
|
|
156
|
+
idempotencyKey: impression.idempotencyKey,
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
[meta, sessionId],
|
|
160
|
+
);
|
|
161
|
+
|
|
110
162
|
/**
|
|
111
163
|
* EXACTLY ONE review row per gate, ever — the latch that makes "force feedback" safe.
|
|
112
164
|
*
|
|
113
|
-
* WHY ONE POST AND NOT TWO: the tempting design is to post the rating at star-tap and then
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* the failure is invisible to the caller and permanent. Post once, at the last responsible
|
|
121
|
-
* moment, and never depend on an unmerged/undeployed server capability.
|
|
165
|
+
* WHY ONE POST AND NOT TWO: the tempting design is to post the rating at star-tap and then post
|
|
166
|
+
* again with the text, enriching the first row. The server now supports exactly that — a post
|
|
167
|
+
* carrying `idempotency_key` upserts on a derived row id instead of inserting — but the latch
|
|
168
|
+
* stays, because two posts buy nothing the one-at-the-last-responsible-moment post does not
|
|
169
|
+
* already have, and every additional post is another chance to hand the server a unit it cannot
|
|
170
|
+
* resolve. What the key genuinely buys is the RECOVERY paths below: the `unsent` re-post and the
|
|
171
|
+
* abandonment net now merge into the row they may have already written instead of duplicating it.
|
|
122
172
|
*
|
|
123
173
|
* ── THE LATCH IS A CLAIM, NOT A RECEIPT ────────────────────────────────────────────────────
|
|
124
174
|
*
|
|
@@ -134,17 +184,23 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
134
184
|
*
|
|
135
185
|
* "Did it land" and "did the server answer" are DIFFERENT questions, which is why `submitReview`
|
|
136
186
|
* resolves `accepted` / `rejected` / `unsent` instead of a boolean. Only `unsent` un-latches.
|
|
137
|
-
* A non-2xx means the server was REACHED, and
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* cleared: the row is the server's problem now.
|
|
187
|
+
* A non-2xx means the server was REACHED, and we still cannot know whether it stored the row
|
|
188
|
+
* before failing to say so — a gateway 502 after the insert commits looks identical to a refusal.
|
|
189
|
+
* That verdict is left alone DELIBERATELY (2026-08-19): re-posting on it is now safe wherever the
|
|
190
|
+
* key is honoured, but it would also turn a genuine 4xx refusal into a retry needing its own
|
|
191
|
+
* budget, so changing it is its own decision and not this one. On any answer the latch stays
|
|
192
|
+
* closed and `unackedRef` is cleared: the row is the server's problem now.
|
|
143
193
|
*
|
|
144
|
-
*
|
|
145
|
-
* after the row committed throws here exactly like an offline device, so the
|
|
146
|
-
*
|
|
147
|
-
* the server
|
|
194
|
+
* WHAT THE KEY CHANGED, STATED AS A CAPABILITY: `unsent` was never proof the server got nothing —
|
|
195
|
+
* a connection dropped after the row committed throws here exactly like an offline device, so the
|
|
196
|
+
* one re-post below could duplicate. It now carries this impression's `idempotency_key` and the
|
|
197
|
+
* unit the server scopes it to, so the server upserts that row rather than adding a second one.
|
|
198
|
+
* That holds while the unit is resolvable, and a host CANNOT make it unresolvable by editing
|
|
199
|
+
* `meta`: `buildBody` tests the MERGED bucket, so stripping `device_key` there only gets the auto
|
|
200
|
+
* id re-injected. The one post that carries no unit is the one where the kit cannot name an id
|
|
201
|
+
* honestly — two tenants registered in one process leave `impression.deviceKey` undefined, and
|
|
202
|
+
* nothing is stamped. On a gate with no `session_id` that post reaches the server with no unit to
|
|
203
|
+
* scope the key to, so the server ignores the key and it is back in the old, duplicating world.
|
|
148
204
|
*
|
|
149
205
|
* NOTHING HERE IS AWAITED. `postOnce` returns synchronously and every caller advances the phase
|
|
150
206
|
* on the next line, so the user is never waiting on the network to dismiss the prompt.
|
|
@@ -169,8 +225,9 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
169
225
|
postedRef.current = false;
|
|
170
226
|
return;
|
|
171
227
|
}
|
|
172
|
-
// `accepted` OR `rejected`: the server answered, so it owns this row.
|
|
173
|
-
// a non-2xx can follow a committed insert, and
|
|
228
|
+
// `accepted` OR `rejected`: the server answered, so it owns this row. Not re-posted here —
|
|
229
|
+
// a non-2xx can follow a committed insert, and re-posting a refusal is a retry decision
|
|
230
|
+
// with its own budget, deliberately left out of the change that added the key.
|
|
174
231
|
unackedRef.current = null;
|
|
175
232
|
});
|
|
176
233
|
},
|
|
@@ -202,12 +259,11 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
202
259
|
}
|
|
203
260
|
if (postedRef.current || stars < 1) return;
|
|
204
261
|
postOnce(
|
|
205
|
-
|
|
262
|
+
buildBody({
|
|
206
263
|
stars,
|
|
207
264
|
feedbackText: feedback,
|
|
208
265
|
suggestion,
|
|
209
|
-
|
|
210
|
-
meta: { ...meta, abandoned: true },
|
|
266
|
+
extraMeta: { abandoned: true },
|
|
211
267
|
}),
|
|
212
268
|
);
|
|
213
269
|
};
|
|
@@ -249,10 +305,9 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
249
305
|
// the user actually left a review — this only ever means we ASKED. Never read it as
|
|
250
306
|
// a review count. We await first so the row carries the route it actually took.
|
|
251
307
|
postOnce(
|
|
252
|
-
|
|
308
|
+
buildBody({
|
|
253
309
|
stars: value,
|
|
254
|
-
|
|
255
|
-
meta: { ...meta, store_route: route },
|
|
310
|
+
extraMeta: { store_route: route },
|
|
256
311
|
}),
|
|
257
312
|
);
|
|
258
313
|
setPhase("thanks");
|
|
@@ -263,7 +318,7 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
263
318
|
setPhase("feedback");
|
|
264
319
|
}
|
|
265
320
|
},
|
|
266
|
-
[onEvent, id, store, postOnce,
|
|
321
|
+
[onEvent, id, store, postOnce, buildBody, resolveWithDelay],
|
|
267
322
|
);
|
|
268
323
|
|
|
269
324
|
/**
|
|
@@ -284,19 +339,13 @@ const _ReviewGate: React.FC<ReviewGateProps> = ({
|
|
|
284
339
|
if (!feedbackReady) return;
|
|
285
340
|
// Identity is NEVER collected here — the host already sends user_id / device /
|
|
286
341
|
// session via `sessionId` + `meta`. We POST only the rating + the free text.
|
|
287
|
-
const body =
|
|
288
|
-
stars,
|
|
289
|
-
feedbackText: feedback,
|
|
290
|
-
suggestion,
|
|
291
|
-
sessionId,
|
|
292
|
-
meta,
|
|
293
|
-
});
|
|
342
|
+
const body = buildBody({ stars, feedbackText: feedback, suggestion });
|
|
294
343
|
postOnce(body);
|
|
295
344
|
onEvent?.({ name: "review_feedback_submitted", id, stars });
|
|
296
345
|
playHaptic("success");
|
|
297
346
|
setPhase("thanks");
|
|
298
347
|
resolveWithDelay();
|
|
299
|
-
}, [feedbackReady, stars, feedback, suggestion,
|
|
348
|
+
}, [feedbackReady, stars, feedback, suggestion, buildBody, postOnce, onEvent, id, resolveWithDelay]);
|
|
300
349
|
|
|
301
350
|
const dismiss = useCallback(() => {
|
|
302
351
|
setPhase("thanks");
|
package/src/reviews/decision.ts
CHANGED
|
@@ -103,7 +103,14 @@ export const evaluateGate = (rules: GateRules, s: GateSignals): ReviewDecision =
|
|
|
103
103
|
export const routeRating = (stars: number): RatingRoute =>
|
|
104
104
|
stars >= 5 ? "store" : "feedback";
|
|
105
105
|
|
|
106
|
-
/**
|
|
106
|
+
/**
|
|
107
|
+
* Build the `POST /v1/reviews` body. Feedback text/contact belong ONLY here, never in events.
|
|
108
|
+
*
|
|
109
|
+
* STILL PURE, deliberately: `idempotencyKey` and the `meta.device_key` the server scopes it to are
|
|
110
|
+
* both passed IN rather than resolved here. Reaching into the device-key registry from this function
|
|
111
|
+
* would make the gate logic untestable in isolation, and both values are scoped to an impression
|
|
112
|
+
* this function cannot see — see `reviews/idempotency.ts`.
|
|
113
|
+
*/
|
|
107
114
|
export const buildReviewSubmission = (input: {
|
|
108
115
|
stars: number;
|
|
109
116
|
feedbackText?: string;
|
|
@@ -111,6 +118,8 @@ export const buildReviewSubmission = (input: {
|
|
|
111
118
|
contact?: string;
|
|
112
119
|
sessionId?: string;
|
|
113
120
|
source?: string;
|
|
121
|
+
/** The impression's idempotency key. Omitted → the server mints a row id and inserts, as before. */
|
|
122
|
+
idempotencyKey?: string;
|
|
114
123
|
meta?: Record<string, unknown>;
|
|
115
124
|
}): ReviewSubmission => {
|
|
116
125
|
const body: ReviewSubmission = { stars: input.stars };
|
|
@@ -122,6 +131,7 @@ export const buildReviewSubmission = (input: {
|
|
|
122
131
|
if (contact) body.contact = contact;
|
|
123
132
|
if (input.sessionId) body.session_id = input.sessionId;
|
|
124
133
|
if (input.source) body.source = input.source;
|
|
134
|
+
if (input.idempotencyKey) body.idempotency_key = input.idempotencyKey;
|
|
125
135
|
if (input.meta && Object.keys(input.meta).length > 0) body.meta = input.meta;
|
|
126
136
|
return body;
|
|
127
137
|
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* idempotency.ts — the ONE idempotency key and the ONE identity a single gate impression posts under.
|
|
3
|
+
*
|
|
4
|
+
* ── WHAT THE SERVER DOES WITH THESE, AND WHY BOTH ARE REQUIRED ───────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* `POST /v1/reviews` accepts an optional `idempotency_key`: two posts carrying the same key upsert
|
|
7
|
+
* ONE row instead of creating two, which is what stops a re-post double-counting `count` and
|
|
8
|
+
* corrupting `avg`. The row id is derived, never the key verbatim —
|
|
9
|
+
* `rev_ + sha256(app_id : unit : key)[:24]` — where `app_id` comes from the resolved `wai_` Bearer
|
|
10
|
+
* key (never the body) and `unit` is the USER the key is scoped to.
|
|
11
|
+
*
|
|
12
|
+
* THE PART THAT MAKES A KEY-ONLY CHANGE INERT: `unit` is `meta.device_key`, else `session_id`, and
|
|
13
|
+
* when the post carries NEITHER the server drops the key on the floor and inserts a normal row —
|
|
14
|
+
* deliberately, because an unbounded key could merge two strangers' reviews. The gate lives on the
|
|
15
|
+
* home feed, where a user legitimately has no onboarding session, so `session_id` is frequently
|
|
16
|
+
* absent; and nothing in the kit ever put a `device_key` into a review `meta` — that was documented
|
|
17
|
+
* as a host responsibility. Sending a key without guaranteeing the identity would therefore have
|
|
18
|
+
* shipped a field the server discards.
|
|
19
|
+
*
|
|
20
|
+
* ── THE SCOPE IS THE WHOLE DESIGN ────────────────────────────────────────────────────────────
|
|
21
|
+
*
|
|
22
|
+
* One key per gate IMPRESSION, held across every post of that impression:
|
|
23
|
+
* • minted per POST instead → the re-post carries a different key, writes the second row, and
|
|
24
|
+
* defeats the feature on the exact path it exists to protect.
|
|
25
|
+
* • minted once per INSTALL instead → a later, genuine second review merges into the first and
|
|
26
|
+
* destroys a rating.
|
|
27
|
+
* The identity is pinned for the same reason and in the same object: the auto device key is adopted
|
|
28
|
+
* from persistence ASYNCHRONOUSLY, so a value re-read between two posts of one impression can move
|
|
29
|
+
* under us, and two posts under two different units hash to two different rows.
|
|
30
|
+
*/
|
|
31
|
+
import { ambientAutoDeviceKey } from "../context/deviceId";
|
|
32
|
+
|
|
33
|
+
/** Prefix so a kit-minted review key is visibly ours in a stored `meta` dump. */
|
|
34
|
+
export const REVIEW_IDEMPOTENCY_PREFIX = "rvw_";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Mint a fresh idempotency key. Dependency-free timestamp + randomness, the same scheme
|
|
38
|
+
* `makeSessionId` and `mintDeviceId` already use — the key only has to be unique among the
|
|
39
|
+
* impressions of ONE install, because the server hashes it together with `app_id` and the unit
|
|
40
|
+
* before it ever becomes a row id.
|
|
41
|
+
*/
|
|
42
|
+
export const mintIdempotencyKey = (): string =>
|
|
43
|
+
`${REVIEW_IDEMPOTENCY_PREFIX}${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
44
|
+
|
|
45
|
+
/** Whether a `meta` bucket already carries a usable, host-supplied `device_key`. */
|
|
46
|
+
export const hasDeviceKey = (meta: Record<string, unknown> | undefined): boolean => {
|
|
47
|
+
const value = meta?.device_key;
|
|
48
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** The key + identity every post of ONE gate impression carries. Both resolved once, at creation. */
|
|
52
|
+
export interface ReviewImpression {
|
|
53
|
+
/** The `idempotency_key` on the wire. Stable for the impression, distinct across impressions. */
|
|
54
|
+
readonly idempotencyKey: string;
|
|
55
|
+
/**
|
|
56
|
+
* The auto `device_key` to stamp when the host supplied none, or `undefined` when the kit cannot
|
|
57
|
+
* name one honestly (two tenants in one process). A host-supplied key that {@link hasDeviceKey}
|
|
58
|
+
* accepts — a string with at least one non-whitespace character — always wins over this and is
|
|
59
|
+
* never touched; the caller checks that first.
|
|
60
|
+
*/
|
|
61
|
+
readonly deviceKey: string | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Open one impression. Both fields are resolved EAGERLY here rather than read per post, which is the
|
|
66
|
+
* entire point: a lazily re-read key or identity is a per-post value wearing an impression's name.
|
|
67
|
+
*/
|
|
68
|
+
export const createImpression = (): ReviewImpression => ({
|
|
69
|
+
idempotencyKey: mintIdempotencyKey(),
|
|
70
|
+
deviceKey: ambientAutoDeviceKey(),
|
|
71
|
+
});
|
package/src/reviews/index.ts
CHANGED
|
@@ -55,8 +55,10 @@ export { requestStoreReview, openStoreListing, storeUrl } from "./storeReview";
|
|
|
55
55
|
// hand-rolling a decision fetch: a host-rolled one that collapses `{fire:false}` into undefined
|
|
56
56
|
// makes the server able to turn prompts ON but never OFF (the 2026-07-16 first-session incident).
|
|
57
57
|
// `submitReview` resolves with a THREE-state `ReviewSubmitResult` (accepted / rejected / unsent),
|
|
58
|
-
// not a boolean: only `unsent`
|
|
59
|
-
//
|
|
58
|
+
// not a boolean: only `unsent` is retried today. A body built by `ReviewGate` now carries an
|
|
59
|
+
// `idempotency_key` plus the unit the server scopes it to, so that re-post upserts one row instead
|
|
60
|
+
// of writing a second; whether a `rejected` verdict should ALSO retry is a separate recovery-budget
|
|
61
|
+
// decision and is deliberately unchanged.
|
|
60
62
|
export { submitReview, fetchReviewDecision, reportAppEvent } from "./transport";
|
|
61
63
|
export type {
|
|
62
64
|
FetchReviewDecisionOptions,
|
package/src/reviews/runtime.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* mounted CoachmarkProvider gets review gating for free — no second storage to wire.
|
|
6
6
|
*/
|
|
7
7
|
import type { CoachmarkStorage } from "../coachmarks/types";
|
|
8
|
-
import { getCoachmarkStorage } from "../coachmarks/runtime";
|
|
8
|
+
import { getCoachmarkStorage, validateGateStorage } from "../coachmarks/runtime";
|
|
9
9
|
import { getCurrentSessionId } from "../analytics/currentSession";
|
|
10
10
|
import { makeSessionId } from "../analytics/reportClientEvent";
|
|
11
11
|
|
|
@@ -22,10 +22,12 @@ export const reviewSessionsKey = (id: string): string => `wire_review_${id}_sess
|
|
|
22
22
|
/** Companion key holding the open id the counter was LAST incremented for (see {@link bumpSessionCount}). */
|
|
23
23
|
export const reviewSessionOpenKey = (id: string): string => `wire_review_${id}_open`;
|
|
24
24
|
|
|
25
|
-
/** Resolve the storage to use: an explicit override, else the provider-injected singleton
|
|
25
|
+
/** Resolve the storage to use: an explicit override, else the provider-injected singleton — both
|
|
26
|
+
* routed through the thenable probe, so an ASYNC adapter resolves to the fail-closed substitute
|
|
27
|
+
* (see `validateGateStorage` in coachmarks/runtime) instead of feeding Promises to `readInt`. */
|
|
26
28
|
export const resolveStorage = (
|
|
27
29
|
override?: CoachmarkStorage | null,
|
|
28
|
-
): CoachmarkStorage | null => override ?? getCoachmarkStorage();
|
|
30
|
+
): CoachmarkStorage | null => validateGateStorage(override ?? getCoachmarkStorage());
|
|
29
31
|
|
|
30
32
|
/** RN sets this global; absent under node/SSR. Read defensively via {@link warnMissingGateStorage}. */
|
|
31
33
|
declare const __DEV__: boolean | undefined;
|
package/src/reviews/transport.ts
CHANGED
|
@@ -45,23 +45,28 @@ export type ReviewSubmitResult = SubmitResult;
|
|
|
45
45
|
*
|
|
46
46
|
* It was, for exactly one unpublished release, and the boolean was the bug. `false` meant both
|
|
47
47
|
* "nothing reached the server" and "the server answered non-2xx", and the one caller that reads
|
|
48
|
-
* this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
48
|
+
* this (`ReviewGate.postOnce`) treats `false` as "still owed" and re-posts. At the time there was
|
|
49
|
+
* no idempotency key on the wire at all, so a 502 returned AFTER the insert commits meant the
|
|
50
|
+
* re-post wrote a SECOND row, double-counting `count` and corrupting `avg` — the precise corruption
|
|
51
|
+
* the one-row latch exists to prevent. A response of any status proves the server was reached, and
|
|
52
|
+
* that is a different question from whether it liked the row. So the two are different values, and
|
|
53
|
+
* they stay different: the split is what lets the caller decide, and the retry-on-`rejected`
|
|
54
|
+
* question is a recovery-budget decision the key does not answer on its own.
|
|
54
55
|
*
|
|
55
56
|
* ── THE RESIDUAL, STATED HONESTLY ────────────────────────────────────────────────────────────
|
|
56
57
|
*
|
|
57
58
|
* `unsent` is not proof the server never got the row. A connection dropped after the request was
|
|
58
59
|
* written — or after the row committed — surfaces as a thrown/rejected `fetch` here, exactly like
|
|
59
|
-
* an offline device.
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
60
|
+
* an offline device. That used to make the `unsent` re-post SAFER rather than SAFE, and it is what
|
|
61
|
+
* the client-minted idempotency key now covers: `ReviewSubmission.idempotency_key` reaches
|
|
62
|
+
* `CreateReviewRequest`, and the server upserts the derived row rather than inserting a second one.
|
|
63
|
+
*
|
|
64
|
+
* IT IS A CAPABILITY, NOT A GUARANTEE, and the condition is not ours to enforce from in here. The
|
|
65
|
+
* server honours the key only when the post also carries a unit it can scope it to —
|
|
66
|
+
* `meta.device_key`, else `session_id` — and ignores it outright otherwise. `ReviewGate` fills the
|
|
67
|
+
* device key in for exactly this reason, but this function posts whatever body it is handed, so a
|
|
68
|
+
* caller that assembles its own body without a unit is back in the old world where a lost detractor
|
|
69
|
+
* is a gap in the data and a duplicated one is a lie in the data.
|
|
65
70
|
*/
|
|
66
71
|
export const submitReview = async (
|
|
67
72
|
target: ReviewTarget | undefined,
|
package/src/reviews/types.ts
CHANGED
|
@@ -97,6 +97,13 @@ export interface ReviewSubmission {
|
|
|
97
97
|
contact?: string;
|
|
98
98
|
session_id?: string;
|
|
99
99
|
source?: string;
|
|
100
|
+
/**
|
|
101
|
+
* OPTIONAL client-minted key for the two-phase submit: two posts carrying the same key upsert ONE
|
|
102
|
+
* row instead of creating two. The server honours it ONLY when the post also carries a resolvable
|
|
103
|
+
* user — `meta.device_key`, else `session_id` — and silently ignores it otherwise, so it always
|
|
104
|
+
* travels with one of those. One key per gate impression, stable across that impression's posts.
|
|
105
|
+
*/
|
|
106
|
+
idempotency_key?: string;
|
|
100
107
|
meta?: Record<string, unknown>;
|
|
101
108
|
}
|
|
102
109
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import type {
|
|
2
|
+
OnboardingColors,
|
|
3
|
+
OnboardingFonts,
|
|
4
|
+
OnboardingProps,
|
|
5
|
+
OnboardingStep,
|
|
6
6
|
} from "@blazejkustra/react-native-onboarding";
|
|
7
7
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
8
8
|
import { type ImageSourcePropType, StyleSheet, View } from "react-native";
|
|
@@ -12,6 +12,7 @@ import { hasSeenGate, markSeenGate, showcaseGateKey } from "../coachmarks/runtim
|
|
|
12
12
|
import { useResolvedFeatures } from "../features/WireFeaturesProvider";
|
|
13
13
|
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
14
14
|
import type { OnboardingTheme } from "../theme/types";
|
|
15
|
+
import { resolveShowcaseOnboarding } from "./blazejOnboarding";
|
|
15
16
|
import { showcaseColorsFromTheme, showcasePanelBackground } from "./showcaseColors";
|
|
16
17
|
import type { FeatureShowcaseProps } from "./types";
|
|
17
18
|
|
|
@@ -47,6 +48,13 @@ const mergeThemeOver = (
|
|
|
47
48
|
*
|
|
48
49
|
* When the gate says "seen", it renders nothing and calls `onDone` from an
|
|
49
50
|
* effect (never during render).
|
|
51
|
+
*
|
|
52
|
+
* The underlying package is an OPTIONAL peer, resolved through the guarded lazy
|
|
53
|
+
* require in `blazejOnboarding.ts` rather than a static import, so the showcase
|
|
54
|
+
* subpath builds on a host that never installed it. Absent, this takes the SAME
|
|
55
|
+
* path as the kill switch — render null, call `onDone`, write no gate — so the
|
|
56
|
+
* host's flow always advances and the showcase still plays once if the peer is
|
|
57
|
+
* added later.
|
|
50
58
|
*/
|
|
51
59
|
const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
|
|
52
60
|
config,
|
|
@@ -71,14 +79,20 @@ const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
|
|
|
71
79
|
const flags = useResolvedFeatures({ flags: features, config: featuresConfig });
|
|
72
80
|
const disabled = !flags.showcase.enabled;
|
|
73
81
|
|
|
82
|
+
// The optional pager peer. Resolved once, on first render, instead of at module scope — so
|
|
83
|
+
// merely importing this file (and thus the showcase subpath) never needs it. Absent → treated
|
|
84
|
+
// exactly like the kill switch below: no render, no gate write, `onDone` from the effect.
|
|
85
|
+
const Onboarding = useMemo(() => resolveShowcaseOnboarding(), []);
|
|
86
|
+
|
|
74
87
|
const gateKey = showcaseGateKey(config.id);
|
|
75
88
|
const seen = useMemo(
|
|
76
89
|
() => hasSeenGate(gateKey, storage, isTesting),
|
|
77
90
|
[gateKey, storage, isTesting],
|
|
78
91
|
);
|
|
79
|
-
//
|
|
80
|
-
// gate write (in `finish`); the
|
|
81
|
-
|
|
92
|
+
// Already-seen, feature-disabled OR the peer missing short-circuits the showcase. Only the seen
|
|
93
|
+
// path is a gate write (in `finish`); the other two never persist, so the showcase replays once
|
|
94
|
+
// the feature is re-enabled or the peer is installed.
|
|
95
|
+
const skip = seen || disabled || !Onboarding;
|
|
82
96
|
|
|
83
97
|
const doneRef = useRef(false);
|
|
84
98
|
const finish = useCallback(() => {
|
|
@@ -177,7 +191,9 @@ const _FeatureShowcase: React.FC<FeatureShowcaseProps> = ({
|
|
|
177
191
|
[t],
|
|
178
192
|
);
|
|
179
193
|
|
|
180
|
-
|
|
194
|
+
// `!Onboarding` is already folded into `skip`; it is repeated here so the narrowing is explicit
|
|
195
|
+
// to the reader and to the compiler at the JSX below.
|
|
196
|
+
if (skip || !Onboarding) return null;
|
|
181
197
|
|
|
182
198
|
const activeGesture = config.slides[activeIndex]?.gesture;
|
|
183
199
|
// Same rule as the coachmark path: tap / double-tap slides show no glyph.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* blazejOnboarding — resolve `@blazejkustra/react-native-onboarding`, lazily and optionally.
|
|
3
|
+
*
|
|
4
|
+
* ── WHY THIS DOES NOT BREAK THE NO-NATIVE-DEPENDENCY POLICY ──────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* `@blazejkustra/react-native-onboarding` is an OPTIONAL peer of the showcase subpath — it is the
|
|
7
|
+
* slide pager `FeatureShowcase` wraps. Before this file, `FeatureShowcase.tsx` imported its default
|
|
8
|
+
* export at module scope, and `showcase/index.ts` re-exports the component with no wildcard escape,
|
|
9
|
+
* so importing ANYTHING from `@wireai/activation/showcase` (even `selectShowcaseSlides`) dragged
|
|
10
|
+
* the peer in, and a host that skipped the "optional" peer hit a Metro resolution failure. This
|
|
11
|
+
* resolver removes that static edge. Same class and same shape as the 0.14.2 `expo-blur` fix
|
|
12
|
+
* (`coachmarks/expoBlur.ts`), whose header carries the reasoning in full.
|
|
13
|
+
*
|
|
14
|
+
* The TYPES stay statically imported on purpose: a type-only import is erased by every toolchain
|
|
15
|
+
* (babel, esbuild and sucrase all drop it), so it creates no runtime edge and no bundle dependency,
|
|
16
|
+
* and `showcaseColors.ts` already relies on exactly that.
|
|
17
|
+
*
|
|
18
|
+
* ── THE SPECIFIER MUST BE A STRING LITERAL, INSIDE A TRY/CATCH ───────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* Metro collects dependencies statically and matches only a call whose callee is literally the
|
|
21
|
+
* identifier `require` and whose argument is literally a string; the try/catch around it is what
|
|
22
|
+
* marks the dependency `isOptional`. Both halves are load-bearing — see `icons/expoIcons.ts` for
|
|
23
|
+
* the full autopsy of the 0.8.0 shape that aliased the callee and was collected nowhere.
|
|
24
|
+
*
|
|
25
|
+
* ── THE DEGRADE ─────────────────────────────────────────────────────────────────────────
|
|
26
|
+
*
|
|
27
|
+
* Absent → `FeatureShowcase` renders null and calls `onDone` from an effect, which is the SAME path
|
|
28
|
+
* the feature kill switch already takes. The host's flow always advances (a showcase that cannot
|
|
29
|
+
* render must never strand the user on a blank screen), and the seen-gate is deliberately NOT
|
|
30
|
+
* written, so installing the peer later still plays the showcase once.
|
|
31
|
+
*/
|
|
32
|
+
import type { ComponentType } from "react";
|
|
33
|
+
|
|
34
|
+
import type { OnboardingProps } from "@blazejkustra/react-native-onboarding";
|
|
35
|
+
|
|
36
|
+
// Metro injects a module-scoped `require`; it is ABSENT in a pure-ESM runtime (the kit's own tests
|
|
37
|
+
// run under `node --test` as ESM). Declared locally so this type-checks without ambient Node types;
|
|
38
|
+
// the `typeof` guard keeps the reference ESM-safe.
|
|
39
|
+
declare const require: ((id: string) => unknown) | undefined;
|
|
40
|
+
|
|
41
|
+
/** A `require`-like resolver. Injectable in tests; production uses the guarded literal require. */
|
|
42
|
+
export type OptionalRequire = (moduleName: string) => unknown;
|
|
43
|
+
|
|
44
|
+
/** The pager component `FeatureShowcase` wraps. */
|
|
45
|
+
export type OnboardingComponent = ComponentType<OnboardingProps>;
|
|
46
|
+
|
|
47
|
+
const MODULE_NAME = "@blazejkustra/react-native-onboarding";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The production resolver. The specifier is a LITERAL so Metro collects it (see the header); the
|
|
51
|
+
* `moduleName` parameter exists only to keep the `OptionalRequire` seam shape, so anything other
|
|
52
|
+
* than the one module this file owns resolves to undefined.
|
|
53
|
+
*/
|
|
54
|
+
const runtimeRequire: OptionalRequire = (moduleName) => {
|
|
55
|
+
if (moduleName !== MODULE_NAME) return undefined;
|
|
56
|
+
if (typeof require !== "function") return undefined;
|
|
57
|
+
try {
|
|
58
|
+
return require("@blazejkustra/react-native-onboarding");
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* TEST-ONLY seam. `FeatureShowcase`'s public props are frozen, so it cannot take a `requireModule`
|
|
66
|
+
* the way `WireIcon` does — this lets a component render exercise the PRESENT path without the peer
|
|
67
|
+
* installed. Production never sets it; `runtimeRequire` is the only resolver.
|
|
68
|
+
*/
|
|
69
|
+
let testRequire: OptionalRequire | undefined;
|
|
70
|
+
export const __setShowcaseOnboardingRequireForTests = (fn: OptionalRequire | undefined): void => {
|
|
71
|
+
testRequire = fn;
|
|
72
|
+
cached = undefined;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read the component off the module. The package ships it as a DEFAULT export; a CJS build may put
|
|
77
|
+
* the component directly on `module.exports` instead, so both shapes are accepted.
|
|
78
|
+
*/
|
|
79
|
+
const interop = (mod: unknown): unknown => {
|
|
80
|
+
if (typeof mod === "function") return mod;
|
|
81
|
+
if (!mod || typeof mod !== "object") return undefined;
|
|
82
|
+
const ns = mod as Record<string, unknown>;
|
|
83
|
+
if (ns.default) return ns.default;
|
|
84
|
+
// `module.exports = forwardRef(...)`: the component IS the namespace, and it is an object rather
|
|
85
|
+
// than a function, so it would slip past the check above.
|
|
86
|
+
if ("$$typeof" in ns) return ns;
|
|
87
|
+
return undefined;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Narrow an unknown export to something mountable: a real component (function or class) or a
|
|
92
|
+
* React.memo / forwardRef wrapper (an object carrying `$$typeof`). Reject anything else rather than
|
|
93
|
+
* handing the reconciler a non-component.
|
|
94
|
+
*/
|
|
95
|
+
const asComponent = (value: unknown): OnboardingComponent | undefined => {
|
|
96
|
+
if (typeof value === "function") return value as OnboardingComponent;
|
|
97
|
+
if (value && typeof value === "object" && "$$typeof" in (value as object)) {
|
|
98
|
+
return value as OnboardingComponent;
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Module-level memo. `null` = "we looked and it is not there" (distinct from "not looked yet"), so
|
|
105
|
+
* an absent peer costs exactly one failed require per process, not one per showcase mount.
|
|
106
|
+
*/
|
|
107
|
+
let cached: OnboardingComponent | null | undefined;
|
|
108
|
+
|
|
109
|
+
/** Reset the memo. TEST-ONLY seam — production never calls it. */
|
|
110
|
+
export const resetShowcaseOnboardingCache = (): void => {
|
|
111
|
+
cached = undefined;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve the pager component, or undefined when the peer is absent/unresolvable. Never throws: an
|
|
116
|
+
* absent showcase must advance the host's flow, never break it.
|
|
117
|
+
*
|
|
118
|
+
* `requireModule` is injectable so tests can exercise BOTH the found and absent paths without
|
|
119
|
+
* installing the peer (same convention as `resolveBlurView` / `resolveIconFamily`).
|
|
120
|
+
*/
|
|
121
|
+
export const resolveShowcaseOnboarding = (
|
|
122
|
+
requireModule: OptionalRequire = testRequire ?? runtimeRequire,
|
|
123
|
+
): OnboardingComponent | undefined => {
|
|
124
|
+
try {
|
|
125
|
+
if (cached === undefined || requireModule !== runtimeRequire) {
|
|
126
|
+
const component = asComponent(interop(requireModule(MODULE_NAME)));
|
|
127
|
+
// Don't poison the module memo from an injected test require.
|
|
128
|
+
if (requireModule === runtimeRequire) cached = component ?? null;
|
|
129
|
+
return component;
|
|
130
|
+
}
|
|
131
|
+
if (cached === null) return undefined;
|
|
132
|
+
return cached;
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
};
|
package/src/showcase/index.ts
CHANGED
|
@@ -2,10 +2,14 @@
|
|
|
2
2
|
* @wireai/activation/showcase — the pre-onboarding feature showcase ("app intro").
|
|
3
3
|
*
|
|
4
4
|
* Subpath entry, kept OUT of the main barrel so the core kit stays dependency-
|
|
5
|
-
* free
|
|
6
|
-
* `@blazejkustra/react-native-onboarding`
|
|
7
|
-
* `
|
|
8
|
-
*
|
|
5
|
+
* free. Importing this pulls in NO native peer (0.14.3): the pager
|
|
6
|
+
* `@blazejkustra/react-native-onboarding` is reached through the guarded lazy
|
|
7
|
+
* require in `blazejOnboarding.ts`, and the transitive edge through GestureHint
|
|
8
|
+
* to `react-native-reanimated` is guarded the same way in `coachmarks/reanimated.ts`.
|
|
9
|
+
* Without the pager the showcase renders null and calls `onDone` so the host's
|
|
10
|
+
* flow advances; without reanimated the gesture glyph renders static. The app
|
|
11
|
+
* supplies a declarative ShowcaseConfig; the kit bakes in the Wire theme +
|
|
12
|
+
* optional per-slide gesture hand and gates once.
|
|
9
13
|
*
|
|
10
14
|
* import { FeatureShowcase, selectShowcaseSlides } from "@wireai/activation/showcase";
|
|
11
15
|
*/
|