@wireai/activation 0.12.2 → 0.13.2
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 +3 -1
- package/CHANGELOG.md +259 -1
- package/README.md +87 -3
- package/dist/analytics/index.d.mts +2 -2
- package/dist/analytics/index.d.ts +2 -2
- package/dist/analytics/index.js +174 -36
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +174 -37
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{currentSession-BlCeDP0f.d.mts → currentSession-ClkLjcJ0.d.mts} +456 -19
- package/dist/{currentSession-BxEB37xt.d.ts → currentSession-DOVZEWJl.d.ts} +456 -19
- package/dist/index.d.mts +197 -16
- package/dist/index.d.ts +197 -16
- package/dist/index.js +1056 -390
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +836 -193
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.js +20 -7
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +20 -7
- package/dist/reviews/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/OnboardingFlow.tsx +141 -3
- package/src/WireOnboarding.tsx +178 -34
- package/src/activation/wireActivation.ts +13 -7
- package/src/analytics/analyticsEvent.ts +16 -1
- package/src/analytics/analyticsFacade.ts +11 -10
- package/src/analytics/currentSession.ts +6 -20
- package/src/analytics/eventQueue.ts +71 -1
- package/src/analytics/index.ts +1 -1
- package/src/analytics/reportClientEvent.ts +157 -38
- package/src/cards/PermissionCard.tsx +438 -0
- package/src/cards/index.ts +7 -0
- package/src/config/wireConfigFromEnv.ts +1 -10
- package/src/context/deviceId.ts +77 -16
- package/src/context/userContext.ts +4 -15
- package/src/identity/identityRecord.ts +123 -0
- package/src/identity/userIdentity.ts +45 -9
- package/src/illustrations/defaultIllustrations.tsx +44 -3
- package/src/index.ts +44 -4
- package/src/permissions/index.ts +64 -0
- package/src/permissions/permissionCopy.ts +87 -0
- package/src/permissions/permissionEvents.ts +76 -0
- package/src/permissions/permissionMemory.ts +88 -0
- package/src/permissions/placement.ts +88 -0
- package/src/permissions/types.ts +131 -0
- package/src/session/persistedSession.ts +10 -3
- package/src/session-analytics/useLifecycleEvents.ts +10 -1
- package/src/types.ts +77 -1
- package/src/utils/deriveAnswers.ts +6 -2
- package/src/utils/readProgress.ts +4 -0
- package/src/utils/warnInDev.ts +33 -0
- package/src/components/DoneBlock.tsx +0 -37
|
@@ -153,15 +153,24 @@ type ClientEvent = {
|
|
|
153
153
|
*/
|
|
154
154
|
user_id?: string;
|
|
155
155
|
/**
|
|
156
|
-
* Client-stamped
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
|
|
164
|
-
|
|
156
|
+
* Client-stamped timestamp of when the event was ENQUEUED on the device. Optional and ADDITIVE.
|
|
157
|
+
*
|
|
158
|
+
* TWO REPRESENTATIONS, on purpose:
|
|
159
|
+
* - INTERNAL (epoch-ms `number`): what the offline queue stamps at enqueue time (see
|
|
160
|
+
* `createEventQueue`), so two otherwise byte-identical events fired seconds apart (a genuine
|
|
161
|
+
* repeat, e.g. the user taps "share" twice) are NOT collapsed by the queue's identical-JSON
|
|
162
|
+
* de-dup — while two truly simultaneous re-enqueues of the same instant (a redundant
|
|
163
|
+
* re-render) still share a `ts` and collapse. The de-dup signature depends on this number.
|
|
164
|
+
* - WIRE (ISO8601 UTC `string`): what actually leaves the device. {@link buildEventsRequest}
|
|
165
|
+
* converts the number on its way out, because the server declares `ts: str | None` and
|
|
166
|
+
* pydantic v2 does NOT coerce a number into it — a numeric `ts` made the server answer HTTP
|
|
167
|
+
* 200 with `{written: 0, skipped: N, errors: [{field: "ts", reason: "validation_error"}]}`,
|
|
168
|
+
* silently discarding EVERY `app_event` through 0.13.0.
|
|
169
|
+
*
|
|
170
|
+
* A caller-set ISO string is passed through as-is. Never a wall-clock the server trusts (it
|
|
171
|
+
* derives its own receive time); an old/strict server that does not model it ignores the field.
|
|
172
|
+
*/
|
|
173
|
+
ts?: number | string;
|
|
165
174
|
};
|
|
166
175
|
/** Where to POST. Derived from `WireOnboardingConfig` (`serverUrl` + `apiKey`). */
|
|
167
176
|
type ClientEventTarget = {
|
|
@@ -190,13 +199,238 @@ declare const reportClientEvent: (target: ClientEventTarget | undefined, event:
|
|
|
190
199
|
* `/v1/events` path, but resolve only once the server has RESPONDED — so a decision re-fetch fired
|
|
191
200
|
* immediately after is guaranteed to see the event in the session stream (this is the guarantee
|
|
192
201
|
* `wire.track` needs before it triggers decision revalidation). Never throws: a missing/invalid
|
|
193
|
-
* target, a missing `fetch`, a network error, or a non-2xx status all resolve to `false`.
|
|
194
|
-
*
|
|
202
|
+
* target, a missing `fetch`, a network error, or a non-2xx status all resolve to `false`.
|
|
203
|
+
*
|
|
204
|
+
* ⚠️ 0.13.0: a 2xx is NO LONGER SUFFICIENT. The endpoint answers HTTP 200 with `{ ok, written,
|
|
205
|
+
* skipped }` and counts an event it refuses in `skipped`, so this used to resolve `true` for an event
|
|
206
|
+
* the server had thrown away — and `wire.track` then bumped decision revalidation, making every
|
|
207
|
+
* subscribed gate re-fetch against a stream the action never entered. It now reads the ack and
|
|
208
|
+
* resolves `false` when the server reports a positive `skipped`.
|
|
209
|
+
*
|
|
210
|
+
* BACKWARD COMPATIBLE BY CONSTRUCTION: only an explicit positive `skipped` demotes a 200. An old
|
|
211
|
+
* server that sends no such field, a body that cannot be parsed, or a response with no `.json` at all
|
|
212
|
+
* resolves `true` exactly as before — the change can produce no false negatives.
|
|
195
213
|
*/
|
|
196
214
|
declare const reportClientEventsAwait: (target: ClientEventTarget | undefined, events: ClientEvent[]) => Promise<boolean>;
|
|
197
215
|
/** Convenience single-event wrapper around {@link reportClientEventsAwait}. */
|
|
198
216
|
declare const reportClientEventAwait: (target: ClientEventTarget | undefined, event: ClientEvent) => Promise<boolean>;
|
|
199
217
|
|
|
218
|
+
/**
|
|
219
|
+
* permissionCopy - the rationale a priming screen shows, and the kit-quality defaults behind it.
|
|
220
|
+
*
|
|
221
|
+
* Same job the `copy` prop does for the loaders: the kit ships English that is good enough to
|
|
222
|
+
* ship as-is, and a host overrides any single line without having to restate the rest.
|
|
223
|
+
*
|
|
224
|
+
* PURE, no React, no React Native - so the resolution is unit-testable and the same function can
|
|
225
|
+
* later resolve copy a server sent.
|
|
226
|
+
*/
|
|
227
|
+
|
|
228
|
+
/** Every string a permission screen can render. Both states (the ask and the blocked route). */
|
|
229
|
+
type PermissionScreenCopy = {
|
|
230
|
+
/** Headline for the ask. */
|
|
231
|
+
title: string;
|
|
232
|
+
/** The rationale. The one thing that decides whether the primary gets tapped. */
|
|
233
|
+
message: string;
|
|
234
|
+
/** Primary button. Tapping it is the ONLY thing that can open the OS dialog. */
|
|
235
|
+
primaryLabel: string;
|
|
236
|
+
/** Secondary button. Advances the flow and does NOT spend the one native prompt. */
|
|
237
|
+
secondaryLabel: string;
|
|
238
|
+
/** Headline once the OS says the permission is permanently refused. */
|
|
239
|
+
blockedTitle: string;
|
|
240
|
+
/** Rationale for the blocked state, where the only remaining route is the settings app. */
|
|
241
|
+
blockedMessage: string;
|
|
242
|
+
/** Primary button on the blocked route. */
|
|
243
|
+
settingsLabel: string;
|
|
244
|
+
/** Primary button when there is nothing left to ask for (already granted, or no `request`). */
|
|
245
|
+
continueLabel: string;
|
|
246
|
+
};
|
|
247
|
+
/**
|
|
248
|
+
* The notification default. Written to the same bar as the kit's other shipped copy: it names the
|
|
249
|
+
* benefit in the user's terms and promises a limit, because "we would like to send you
|
|
250
|
+
* notifications" is exactly the sentence that spends the one iOS prompt on a no.
|
|
251
|
+
*/
|
|
252
|
+
declare const NOTIFICATIONS_PERMISSION_COPY: PermissionScreenCopy;
|
|
253
|
+
/** The fallback for any permission the kit ships no copy for. A host overriding `copy` replaces it. */
|
|
254
|
+
declare const GENERIC_PERMISSION_COPY: PermissionScreenCopy;
|
|
255
|
+
/** The kit's shipped defaults, by permission. Anything not listed falls back to the generic set. */
|
|
256
|
+
declare const DEFAULT_PERMISSION_COPY: Record<string, PermissionScreenCopy>;
|
|
257
|
+
/**
|
|
258
|
+
* The kit default for `permission`, with the host's overrides applied on top.
|
|
259
|
+
*
|
|
260
|
+
* An override key whose value is `undefined` is IGNORED rather than allowed to blank the default:
|
|
261
|
+
* hosts build this object from their i18n layer, and a missing translation resolving to `undefined`
|
|
262
|
+
* would otherwise render an empty button.
|
|
263
|
+
*/
|
|
264
|
+
declare const resolvePermissionCopy: (permission: WirePermissionKind, overrides?: Partial<PermissionScreenCopy>) => PermissionScreenCopy;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Public types for the injectable mid-flow permission screens.
|
|
268
|
+
*
|
|
269
|
+
* THE ONE RULE THIS WHOLE MODULE EXISTS TO ENFORCE: the OS permission dialog is reached from the
|
|
270
|
+
* user's explicit primary tap and from nowhere else. iOS grants an app exactly ONE native
|
|
271
|
+
* notification prompt for its entire lifetime, so firing it on mount (the shape almost every app
|
|
272
|
+
* ships) spends the single ask on a user who has not been told why. A priming screen spends a
|
|
273
|
+
* cheap in-app screen first and only forwards the ones who said yes.
|
|
274
|
+
*
|
|
275
|
+
* DEPENDENCY-FREE, the RevenueCat-bridge idiom: the kit imports NO native permission module. The
|
|
276
|
+
* host passes `request` (and optionally `getStatus` / `openSettings`) in, exactly the way it hands
|
|
277
|
+
* the RevenueCat bridge real `CustomerInfo` objects. Wiring `expo-notifications` is five lines in
|
|
278
|
+
* the host and zero dependencies here.
|
|
279
|
+
*
|
|
280
|
+
* A PERMISSION SCREEN IS NOT A QUESTION. It mints no `key` and no `slot_id`, sends nothing to the
|
|
281
|
+
* backend, and never enters the thread, so `deriveAnswers` / `readProgress` and every completion
|
|
282
|
+
* semantic are byte-identical whether or not one is configured. Completion NEVER blocks on a grant.
|
|
283
|
+
*/
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Which OS permission a screen primes. `notifications` is the one the kit ships copy for; any other
|
|
287
|
+
* string is accepted (the host supplies the copy) so a host can prime tracking, health or location
|
|
288
|
+
* without waiting on a kit release.
|
|
289
|
+
*/
|
|
290
|
+
type WirePermissionKind = "notifications" | (string & {});
|
|
291
|
+
/**
|
|
292
|
+
* What the host's `request` / `getStatus` answers.
|
|
293
|
+
* - `granted`: the user allowed it.
|
|
294
|
+
* - `denied`: not allowed right now, but the OS would still show a dialog if asked again
|
|
295
|
+
* (Android, or an iOS provisional state).
|
|
296
|
+
* - `blocked`: permanently refused. Asking again shows NOTHING, so the only route left is Settings.
|
|
297
|
+
*/
|
|
298
|
+
type WirePermissionStatus = "granted" | "denied" | "blocked";
|
|
299
|
+
/** How a permission screen ENDED. `skipped` is the secondary tap, which never burns the OS prompt. */
|
|
300
|
+
type WirePermissionOutcome = WirePermissionStatus | "skipped";
|
|
301
|
+
/**
|
|
302
|
+
* The moments a permission screen reports. One stage, one canonical event name (see
|
|
303
|
+
* `permissionEventName`), so the funnel reads the same across every tenant.
|
|
304
|
+
* - `shown`: the primer screen became visible. The denominator.
|
|
305
|
+
* - `accepted`: the user tapped the primary, so the OS dialog is ABOUT to open. The gap between
|
|
306
|
+
* `shown` and `accepted` is the only number that tells a host whether its rationale copy works.
|
|
307
|
+
* - `granted` / `denied`: what the OS answered (a `blocked` answer reports as `denied` and carries
|
|
308
|
+
* `status: "blocked"`, so the two are one funnel step and still distinguishable).
|
|
309
|
+
* - `skipped`: the secondary tap. The prompt was NOT spent.
|
|
310
|
+
* - `settings`: the user was redirected to the OS settings page (the `blocked` route).
|
|
311
|
+
*/
|
|
312
|
+
type PermissionStage = "shown" | "accepted" | "granted" | "denied" | "skipped" | "settings";
|
|
313
|
+
/**
|
|
314
|
+
* WHERE a screen sits in the server-driven stream. The flow length is decided by the backend and
|
|
315
|
+
* varies per user, so every position is resolved against the card the flow is ABOUT to render:
|
|
316
|
+
* - `"start"`: before the first card.
|
|
317
|
+
* - `{ afterCard: n }`: after `n` cards have been shown (`{ afterCard: 2 }` sits between card 2
|
|
318
|
+
* and card 3). An `n` past the end of a shorter-than-expected stream degrades to `"beforeEnd"`
|
|
319
|
+
* rather than silently never showing.
|
|
320
|
+
* - `"beforeEnd"`: immediately before the terminal recap. The default.
|
|
321
|
+
*/
|
|
322
|
+
type PermissionPlacement = "start" | "beforeEnd" | {
|
|
323
|
+
afterCard: number;
|
|
324
|
+
};
|
|
325
|
+
/**
|
|
326
|
+
* One injectable permission screen.
|
|
327
|
+
*
|
|
328
|
+
* ```tsx
|
|
329
|
+
* import * as Notifications from "expo-notifications";
|
|
330
|
+
*
|
|
331
|
+
* <WireOnboarding
|
|
332
|
+
* permissionScreens={[
|
|
333
|
+
* {
|
|
334
|
+
* permission: "notifications",
|
|
335
|
+
* placement: "beforeEnd",
|
|
336
|
+
* request: async () => {
|
|
337
|
+
* const { status, canAskAgain } = await Notifications.requestPermissionsAsync();
|
|
338
|
+
* return status === "granted" ? "granted" : canAskAgain ? "denied" : "blocked";
|
|
339
|
+
* },
|
|
340
|
+
* },
|
|
341
|
+
* ]}
|
|
342
|
+
* />
|
|
343
|
+
* ```
|
|
344
|
+
*/
|
|
345
|
+
type PermissionScreenConfig = {
|
|
346
|
+
/** Which permission this screen primes. Drives the default copy and every event's `permission`. */
|
|
347
|
+
permission: WirePermissionKind;
|
|
348
|
+
/**
|
|
349
|
+
* Stable id for the once-only / resume record. Defaults to `<permission>:<index in the array>`,
|
|
350
|
+
* which is right until you reorder the array; set it explicitly if you configure two screens for
|
|
351
|
+
* the same permission or intend to reorder them between releases.
|
|
352
|
+
*/
|
|
353
|
+
id?: string;
|
|
354
|
+
/** Where the screen sits in the stream. Default `"beforeEnd"`. */
|
|
355
|
+
placement?: PermissionPlacement;
|
|
356
|
+
/**
|
|
357
|
+
* THE ONLY FUNCTION THE KIT CALLS THAT CAN OPEN AN OS DIALOG, and it is called from the primary
|
|
358
|
+
* press handler alone. Never from mount, never from an effect, never from a status probe.
|
|
359
|
+
*/
|
|
360
|
+
request: () => Promise<WirePermissionStatus>;
|
|
361
|
+
/**
|
|
362
|
+
* Optional NON-PROMPTING status read (`Notifications.getPermissionsAsync()`), used only to pick
|
|
363
|
+
* which primary action the screen offers: a `blocked` user gets "Open settings" instead of an
|
|
364
|
+
* "Enable" button that would open nothing, and an already-granted user gets a plain Continue.
|
|
365
|
+
* Leave it out and the screen simply always offers the ask.
|
|
366
|
+
*/
|
|
367
|
+
getStatus?: () => Promise<WirePermissionStatus>;
|
|
368
|
+
/** Open the OS settings page (`Linking.openSettings()`). Only reachable on the `blocked` route. */
|
|
369
|
+
openSettings?: () => void | Promise<void>;
|
|
370
|
+
/** Rationale copy overrides. Anything left out keeps the kit default for this permission. */
|
|
371
|
+
copy?: Partial<PermissionScreenCopy>;
|
|
372
|
+
/**
|
|
373
|
+
* Name of a host illustration (the existing `illustrations` registry). Defaults to the
|
|
374
|
+
* permission name, so registering `illustrations={{ notifications: <MyBell/> }}` is enough. The
|
|
375
|
+
* kit ships a dependency-free default so the screen is never a blank box.
|
|
376
|
+
*/
|
|
377
|
+
illustration?: string;
|
|
378
|
+
/**
|
|
379
|
+
* Fired once, with the outcome this screen produced. This is the seam for scheduling a local
|
|
380
|
+
* notification the moment a grant lands; the kit deliberately schedules nothing itself. It can
|
|
381
|
+
* never break the flow: a throw here is caught and the flow continues.
|
|
382
|
+
*/
|
|
383
|
+
onResult?: (permission: WirePermissionKind, outcome: WirePermissionOutcome) => void;
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* permissionEvents - the canonical Wire names for a permission-priming funnel.
|
|
388
|
+
*
|
|
389
|
+
* Same job `purchaseEvents.ts` does for the subscription funnel and `analyticsEvent.ts` does for
|
|
390
|
+
* the onboarding funnel: every app was naming these itself (`push_permission`, `NOTIF_PROMPT`,
|
|
391
|
+
* `notifications_allowed`), so the same funnel read differently per tenant and no cross-app report
|
|
392
|
+
* was possible. These are the ONE set of names.
|
|
393
|
+
*
|
|
394
|
+
* They are `app_event` `question_key` values on the wire, exactly like `WIRE_PURCHASE_EVENTS`, so
|
|
395
|
+
* they are ALSO the exact strings a review / questionnaire firing trigger matches on. Never rename
|
|
396
|
+
* one: a rename silently unfires every trigger configured against the old string.
|
|
397
|
+
*
|
|
398
|
+
* PURE + dependency-free: no React, no React Native, no transport, no imports outside the type
|
|
399
|
+
* declarations - so an analytics-only bundle can carry the names without carrying a screen.
|
|
400
|
+
*/
|
|
401
|
+
|
|
402
|
+
declare const WIRE_PERMISSION_EVENTS: {
|
|
403
|
+
/** The priming screen became visible. The denominator for every rate below. */
|
|
404
|
+
readonly screenShown: "wire_permission_screen_shown";
|
|
405
|
+
/** The user tapped the primary, so the OS dialog is about to open. The rationale worked. */
|
|
406
|
+
readonly primerAccepted: "wire_permission_primer_accepted";
|
|
407
|
+
/** The OS granted it. */
|
|
408
|
+
readonly granted: "wire_permission_granted";
|
|
409
|
+
/** The OS refused it (a permanently blocked answer reports here too, with `status: "blocked"`). */
|
|
410
|
+
readonly denied: "wire_permission_denied";
|
|
411
|
+
/** The user took the secondary. The one native prompt was NOT spent. */
|
|
412
|
+
readonly skipped: "wire_permission_skipped";
|
|
413
|
+
/** A blocked user was redirected to the OS settings page. */
|
|
414
|
+
readonly settingsOpened: "wire_permission_settings_opened";
|
|
415
|
+
};
|
|
416
|
+
type WirePermissionEventName = (typeof WIRE_PERMISSION_EVENTS)[keyof typeof WIRE_PERMISSION_EVENTS];
|
|
417
|
+
/** The canonical event name for a stage. Exhaustive over the union (a new stage is a compile error). */
|
|
418
|
+
declare const permissionEventName: (stage: PermissionStage) => WirePermissionEventName;
|
|
419
|
+
/**
|
|
420
|
+
* The small, non-PII props that ride a permission event. `permission` is always present so one
|
|
421
|
+
* funnel can be sliced per permission; `status` only appears when the OS actually answered, which
|
|
422
|
+
* is what keeps a `blocked` refusal distinguishable from a plain `denied` without a second event.
|
|
423
|
+
*/
|
|
424
|
+
declare const permissionEventProps: (permission: WirePermissionKind, status?: WirePermissionStatus) => Record<string, string>;
|
|
425
|
+
/**
|
|
426
|
+
* Normalize whatever the host's `request` / `getStatus` actually returned.
|
|
427
|
+
*
|
|
428
|
+
* A native permission bridge is the host's code, and hosts return `"undetermined"`, `true`, or a
|
|
429
|
+
* whole Expo response object. Anything the kit does not recognise is treated as `denied`: it is the
|
|
430
|
+
* only reading that cannot invent a grant, and every outcome continues the flow anyway.
|
|
431
|
+
*/
|
|
432
|
+
declare const normalizePermissionStatus: (value: unknown) => WirePermissionStatus;
|
|
433
|
+
|
|
200
434
|
/** Transport + tenant config for the managed Wire AI onboarding backend (A2A). */
|
|
201
435
|
type WireOnboardingConfig = {
|
|
202
436
|
/** Tenant API key (resolves the app server-side). */
|
|
@@ -254,6 +488,10 @@ type OnboardingResult = {
|
|
|
254
488
|
* - `fallback`: retries are exhausted; the kit degraded to the static `fallbackFlow`
|
|
255
489
|
* (or handed off to `onError`). This is the client-side mirror of the
|
|
256
490
|
* backend's `llm_fallback` reliability event.
|
|
491
|
+
* - `permission`: an injected permission screen moved (`shown` / `accepted` / `granted` /
|
|
492
|
+
* `denied` / `skipped` / `settings`). Carries NO funnel weight: a permission screen
|
|
493
|
+
* is not a question, so it never appears in `answers` and never gates completion.
|
|
494
|
+
* `toAnalyticsEvent` maps it to the canonical `wire_permission_*` name.
|
|
257
495
|
*/
|
|
258
496
|
type OnboardingEvent = {
|
|
259
497
|
type: "started";
|
|
@@ -275,6 +513,12 @@ type OnboardingEvent = {
|
|
|
275
513
|
} | {
|
|
276
514
|
type: "fallback";
|
|
277
515
|
reason: "backend" | "timeout";
|
|
516
|
+
} | {
|
|
517
|
+
type: "permission";
|
|
518
|
+
permission: WirePermissionKind;
|
|
519
|
+
stage: PermissionStage;
|
|
520
|
+
/** What the OS actually answered, when it answered. Absent on `shown` / `accepted`. */
|
|
521
|
+
status?: WirePermissionStatus;
|
|
278
522
|
};
|
|
279
523
|
/**
|
|
280
524
|
* Copy overrides for the kit's built-in (English) strings, so a host can localize
|
|
@@ -319,6 +563,48 @@ type WireOnboardingProps = {
|
|
|
319
563
|
icons?: Record<string, React.ReactNode>;
|
|
320
564
|
/** Per-step validators keyed by base-question key, e.g. `{ username: checkUsername }`. */
|
|
321
565
|
validators?: Record<string, StepValidator>;
|
|
566
|
+
/**
|
|
567
|
+
* PERMISSION SCREENS injected into the server-driven flow at a position you choose.
|
|
568
|
+
*
|
|
569
|
+
* The screen explains why the app wants the permission and asks the OS **only** on the primary
|
|
570
|
+
* tap. That priming pattern is not decoration: iOS grants an app exactly ONE native notification
|
|
571
|
+
* prompt for its whole lifetime, and firing it on mount spends it on a user who was told nothing.
|
|
572
|
+
* "Maybe later" advances the flow with the prompt still unspent.
|
|
573
|
+
*
|
|
574
|
+
* ```tsx
|
|
575
|
+
* import * as Notifications from "expo-notifications";
|
|
576
|
+
*
|
|
577
|
+
* <WireOnboarding
|
|
578
|
+
* permissionScreens={[
|
|
579
|
+
* {
|
|
580
|
+
* permission: "notifications",
|
|
581
|
+
* placement: "beforeEnd",
|
|
582
|
+
* request: async () => {
|
|
583
|
+
* const { status, canAskAgain } = await Notifications.requestPermissionsAsync();
|
|
584
|
+
* return status === "granted" ? "granted" : canAskAgain ? "denied" : "blocked";
|
|
585
|
+
* },
|
|
586
|
+
* getStatus: async () => {
|
|
587
|
+
* const { status, canAskAgain } = await Notifications.getPermissionsAsync();
|
|
588
|
+
* return status === "granted" ? "granted" : canAskAgain ? "denied" : "blocked";
|
|
589
|
+
* },
|
|
590
|
+
* onResult: (_p, outcome) => { if (outcome === "granted") scheduleFirstReminder(); },
|
|
591
|
+
* },
|
|
592
|
+
* ]}
|
|
593
|
+
* />
|
|
594
|
+
* ```
|
|
595
|
+
*
|
|
596
|
+
* The kit adds NO dependency for this: it imports no `expo-notifications`, no
|
|
597
|
+
* `react-native-permissions`, nothing native. You inject `request`, exactly the way you hand the
|
|
598
|
+
* RevenueCat bridge real RevenueCat objects.
|
|
599
|
+
*
|
|
600
|
+
* A permission screen is NOT a question. It sends nothing to the backend, never enters the
|
|
601
|
+
* thread, mints no `key` / `slot_id`, and never appears in `onComplete`'s `answers`. Completion
|
|
602
|
+
* never blocks on a grant: grant, deny, skip and blocked all continue the flow. Each screen is
|
|
603
|
+
* shown at most once per session, and with `storage` that survives an app kill (a resumed session
|
|
604
|
+
* does not re-ask). Analytics ride `onEvent` (`type: "permission"`) and the canonical
|
|
605
|
+
* `wire_permission_*` events, stamped with the same `device_key` as the rest of the funnel.
|
|
606
|
+
*/
|
|
607
|
+
permissionScreens?: PermissionScreenConfig[];
|
|
322
608
|
/** Fired once the flow reaches its terminal StatusCard. */
|
|
323
609
|
onComplete: (result: OnboardingResult) => void;
|
|
324
610
|
/**
|
|
@@ -472,6 +758,23 @@ type OnboardingProgress = {
|
|
|
472
758
|
total: number;
|
|
473
759
|
/** Base-question key for the CURRENT screen, when known (used to pick a validator). */
|
|
474
760
|
key?: string;
|
|
761
|
+
/**
|
|
762
|
+
* STABLE per-slot identity for the CURRENT screen, when the backend sends one. Preferred over
|
|
763
|
+
* {@link key} for both the answer key and the validator lookup.
|
|
764
|
+
*
|
|
765
|
+
* WHY IT EXISTS: `key` is authored from the question's prompt text (the tenant flows slugify it and
|
|
766
|
+
* cut at 32 chars), so re-wording a question mints a NEW key — the answer a host reads as
|
|
767
|
+
* `answers.interests` silently becomes `answers.what_are_you_into_v2`, with no error anywhere. A
|
|
768
|
+
* slot is the question's identity independent of its wording.
|
|
769
|
+
*
|
|
770
|
+
* FULLY ADDITIVE, AND LIVE SINCE 2026-07-28 (server `47dae92`). The deployed server sends it on
|
|
771
|
+
* `progress` for every AI-GENERATED question, as `adaptive_<n>` 1-based over adaptive answers, and
|
|
772
|
+
* for a CONFIGURED question only when the tenant set one. Where the tenant set none the field is
|
|
773
|
+
* simply absent. Every fallback is PER-CARD, so a thread that mixes slotted and unslotted cards
|
|
774
|
+
* (the real shape during a rollout) keys each one correctly, and a backend or tenant that never
|
|
775
|
+
* sends it produces byte-identical behaviour to 0.12.2.
|
|
776
|
+
*/
|
|
777
|
+
slot_id?: string;
|
|
475
778
|
/** Whether the CURRENT screen may be skipped (backend-marked; default false → no Skip shown). */
|
|
476
779
|
skippable?: boolean;
|
|
477
780
|
};
|
|
@@ -507,7 +810,12 @@ declare const WIRE_ONBOARDING_EVENTS: {
|
|
|
507
810
|
};
|
|
508
811
|
type WireOnboardingEventName = (typeof WIRE_ONBOARDING_EVENTS)[keyof typeof WIRE_ONBOARDING_EVENTS];
|
|
509
812
|
type AnalyticsEvent = {
|
|
510
|
-
|
|
813
|
+
/**
|
|
814
|
+
* A permission screen maps to its own canonical `wire_permission_*` name rather than to an
|
|
815
|
+
* onboarding one: it is a distinct funnel (see `permissions/permissionEvents.ts`), and folding it
|
|
816
|
+
* into `wire_onboarding_turn` would make every permission rate unreadable.
|
|
817
|
+
*/
|
|
818
|
+
name: WireOnboardingEventName | WirePermissionEventName;
|
|
511
819
|
params?: Record<string, unknown>;
|
|
512
820
|
};
|
|
513
821
|
/**
|
|
@@ -638,6 +946,10 @@ type EventQueue = {
|
|
|
638
946
|
/** Current pending (in-memory) count. */
|
|
639
947
|
size(): number;
|
|
640
948
|
};
|
|
949
|
+
/** Test-only: forget every claimed queue key. A real RELAUNCH is a new process, so a test that
|
|
950
|
+
* simulates one in-process must call this or its second queue reads as a concurrent sibling.
|
|
951
|
+
* Exported from `@wireai/activation/analytics`, matching `resetAutoDeviceKeys` / `resetCurrentSessionId`. */
|
|
952
|
+
declare const resetEventQueueKeys: () => void;
|
|
641
953
|
/**
|
|
642
954
|
* Create an offline-first event queue. Loads any persisted backlog on creation so a
|
|
643
955
|
* killed-and-relaunched app resumes where it left off. Returns the {@link EventQueue} surface.
|
|
@@ -705,8 +1017,6 @@ interface ResolvedUserContext {
|
|
|
705
1017
|
/** The `user_context` bucket (device_key, app_version, user_email[+ _hashed], custom.*). */
|
|
706
1018
|
userContext?: Record<string, string | number | boolean>;
|
|
707
1019
|
}
|
|
708
|
-
/** Reserved `user_context` keys the kit itself writes; host `extra` is namespaced away from these. */
|
|
709
|
-
declare const RESERVED_USER_CONTEXT_KEYS: readonly ["device_key", "app_version", "app_build", "network_type", "session_count", "returning", "platform", "user_email", "user_email_hashed"];
|
|
710
1020
|
/** The prefix applied to every host `extra` key so it can never collide with a reserved key. */
|
|
711
1021
|
declare const EXTRA_KEY_PREFIX: "custom.";
|
|
712
1022
|
/** A finite scalar the wire accepts. Non-finite numbers (NaN/Infinity) are NOT scalars here. */
|
|
@@ -831,17 +1141,127 @@ type IdentifyOnboardingOptions = {
|
|
|
831
1141
|
appId?: string;
|
|
832
1142
|
/** Storage key override — pass the same `persistKey` you gave `<WireOnboarding>`, if any. */
|
|
833
1143
|
persistKey?: string;
|
|
1144
|
+
/**
|
|
1145
|
+
* OPT-IN LAST RESORT, default `false`. When no ONBOARDING session can be resolved (no `contextId`,
|
|
1146
|
+
* nothing in `storage`), bind the user to the LIVE PER-OPEN app session instead and return
|
|
1147
|
+
* `"app_session"`.
|
|
1148
|
+
*
|
|
1149
|
+
* ⚠️ These are two different id spaces sharing one wire field. An onboarding session id is the A2A
|
|
1150
|
+
* `contextId`; a per-open id is what `app.session_started` registers. The server's onboarding funnel
|
|
1151
|
+
* groups by `session_id`, so a per-open id posted here does not attach the user to their onboarding
|
|
1152
|
+
* — it writes a row nothing in that funnel can join. Until 0.13.0 this happened SILENTLY and
|
|
1153
|
+
* returned `true`, in exactly the documented post-completion case (completion clears the persisted
|
|
1154
|
+
* session), so the funnel stayed unattributed while the host was told it had worked.
|
|
1155
|
+
*
|
|
1156
|
+
* Turn it on only if binding the id to *some* session the server saw is genuinely worth more to you
|
|
1157
|
+
* than knowing the onboarding bind failed — and read the return value, which now says which it was.
|
|
1158
|
+
*/
|
|
1159
|
+
allowAppSessionFallback?: boolean;
|
|
834
1160
|
};
|
|
1161
|
+
/**
|
|
1162
|
+
* What {@link identifyOnboarding} bound, and to WHICH id space — because `true` could not say.
|
|
1163
|
+
*
|
|
1164
|
+
* • `"onboarding"` — bound to the A2A `contextId`. This is the one that attributes the funnel.
|
|
1165
|
+
* • `"app_session"` — bound to the live per-open app session, via `allowAppSessionFallback`. The
|
|
1166
|
+
* server saw that session, but it is not this user's onboarding.
|
|
1167
|
+
* • `false` — nothing was dispatched (no user id, no server url, no resolvable session).
|
|
1168
|
+
*
|
|
1169
|
+
* ⚠️ 0.13.0 widened this from `boolean`. `"onboarding"` is truthy, so an `if (await identify…)` still
|
|
1170
|
+
* behaves identically; only an explicit `: boolean` annotation needs updating.
|
|
1171
|
+
*/
|
|
1172
|
+
type IdentifyOnboardingBinding = "onboarding" | "app_session" | false;
|
|
835
1173
|
/**
|
|
836
1174
|
* Attach a host user id to an onboarding session AFTER the fact (post-registration), by sending
|
|
837
1175
|
* an `identify` client event to `/v1/events`. Resolves the contextId from an explicit
|
|
838
1176
|
* `contextId` or, failing that, from the persisted session in the host `storage`.
|
|
839
1177
|
*
|
|
840
|
-
* Fire-and-forget under the hood (never throws, never blocks onboarding). Resolves to
|
|
841
|
-
*
|
|
842
|
-
* or no resolvable
|
|
1178
|
+
* Fire-and-forget under the hood (never throws, never blocks onboarding). Resolves to the
|
|
1179
|
+
* {@link IdentifyOnboardingBinding} that says WHICH id space was bound, or `false` when nothing could
|
|
1180
|
+
* be (no user id, no server url, or no resolvable session).
|
|
843
1181
|
*/
|
|
844
|
-
declare const identifyOnboarding: (opts: IdentifyOnboardingOptions) => Promise<
|
|
1182
|
+
declare const identifyOnboarding: (opts: IdentifyOnboardingOptions) => Promise<IdentifyOnboardingBinding>;
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* identityRecord — the ONE provenance-carrying shape for an id the kit puts on the wire.
|
|
1186
|
+
*
|
|
1187
|
+
* WHY IT EXISTS. `session_id` and `device_key` are bare `string`s minted independently by four
|
|
1188
|
+
* subsystems, and nothing anywhere recorded WHERE a given id came from. Every id-layer defect this
|
|
1189
|
+
* release fixes is a direct consequence of that one omission:
|
|
1190
|
+
*
|
|
1191
|
+
* • a rejecting storage adapter's in-memory id was indistinguishable from a persisted one, so the
|
|
1192
|
+
* kit injected a fresh per-launch join key on every launch — nothing carried `durable`.
|
|
1193
|
+
* • an app-OPEN session id could be posted into a field that means the ONBOARDING session, and the
|
|
1194
|
+
* caller was told `true` — nothing carried `space`.
|
|
1195
|
+
* • an auto-minted `wdev_*` could be injected beside a device id the host demonstrably owns on
|
|
1196
|
+
* another surface, silently — nothing carried `source`.
|
|
1197
|
+
*
|
|
1198
|
+
* WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT. It is a small record plus a process-wide registry of
|
|
1199
|
+
* the ids a HOST supplied. It is NOT a branded-type refactor (`OnboardingSessionId` / `AppSessionId` /
|
|
1200
|
+
* `DeviceKey` across every signature) — that is real value and it is deferred, because it touches
|
|
1201
|
+
* every file and is not what makes a number correct this week. Nothing here changes the wire.
|
|
1202
|
+
*
|
|
1203
|
+
* WHY A `Symbol.for` REGISTRY. Same reason as `analytics/currentSession` and `context/deviceId`: tsup
|
|
1204
|
+
* inlines a separate copy of a module into each bundle (`.` and `./analytics`), so a plain module
|
|
1205
|
+
* `let` would give every bundle its own registry and the cross-surface question this exists to answer
|
|
1206
|
+
* ("did ANY surface in this process get a host-supplied device key?") would read `no` from the wrong
|
|
1207
|
+
* copy. `Symbol.for` resolves to one slot on `globalThis` no matter how many copies exist.
|
|
1208
|
+
*/
|
|
1209
|
+
/**
|
|
1210
|
+
* Which id space a value belongs to. These are NOT interchangeable, and the whole point of naming
|
|
1211
|
+
* them is that a value from one space must never be posted into a field that means another:
|
|
1212
|
+
* • `onboarding-session` — the A2A `contextId` for ONE onboarding run.
|
|
1213
|
+
* • `app-session` — the per-app-open session id (`app.session_started`).
|
|
1214
|
+
* • `device` — the per-install `device_key`; the only cross-family join key.
|
|
1215
|
+
*/
|
|
1216
|
+
type IdentitySpace = "onboarding-session" | "app-session" | "device";
|
|
1217
|
+
/** Where the value came from: the host handed it over, or the kit minted it. */
|
|
1218
|
+
type IdentitySource = "host" | "auto";
|
|
1219
|
+
/** An id plus everything a consumer needs to decide whether it may use it. */
|
|
1220
|
+
type IdentityRecord = {
|
|
1221
|
+
/** The id itself, trimmed. Never empty (a blank input yields no record at all). */
|
|
1222
|
+
value: string;
|
|
1223
|
+
/** Which id space {@link value} belongs to. */
|
|
1224
|
+
space: IdentitySpace;
|
|
1225
|
+
/** `host` = the integrator supplied it; `auto` = the kit minted it. */
|
|
1226
|
+
source: IdentitySource;
|
|
1227
|
+
/**
|
|
1228
|
+
* Whether the value was actually PERSISTED (or adopted from persistence), as opposed to living
|
|
1229
|
+
* only in this process's memory. A non-durable auto id is a DIFFERENT id on the next launch, which
|
|
1230
|
+
* for a `device` value is worse than no value at all: the server counts `min_sessions` by distinct
|
|
1231
|
+
* opens grouped on `device_key`, so a per-launch key corrupts the counter rather than leaving it
|
|
1232
|
+
* empty. A host-supplied value is durable by definition — the host owns its lifetime.
|
|
1233
|
+
*/
|
|
1234
|
+
durable: boolean;
|
|
1235
|
+
};
|
|
1236
|
+
/** Input to {@link resolveIdentity}. `value` is `unknown` so callers can pass a raw prop through. */
|
|
1237
|
+
type ResolveIdentityInput = {
|
|
1238
|
+
value: unknown;
|
|
1239
|
+
space: IdentitySpace;
|
|
1240
|
+
source: IdentitySource;
|
|
1241
|
+
/** Defaults to `true` for a host value (the host owns its lifetime) and `false` otherwise. */
|
|
1242
|
+
durable?: boolean;
|
|
1243
|
+
/** Tenant/app id — two tenants in one process never share a provenance entry. */
|
|
1244
|
+
scope?: string;
|
|
1245
|
+
};
|
|
1246
|
+
/**
|
|
1247
|
+
* Build an {@link IdentityRecord} from a candidate value, or `undefined` when there is nothing usable
|
|
1248
|
+
* (a non-string, or blank after trimming) — so a caller can `if (record)`-gate instead of guessing
|
|
1249
|
+
* whether an empty string means "none" or "not yet".
|
|
1250
|
+
*
|
|
1251
|
+
* SIDE EFFECT, deliberate and the reason this is a function and not an object literal: a `host`-sourced
|
|
1252
|
+
* record is RECORDED on the process registry, so a later surface can ask {@link hostIdentity} whether
|
|
1253
|
+
* this process demonstrably owns a host id in that space. That is what turns "the kit injected its own
|
|
1254
|
+
* key" from a silent third id space into a warnable condition. Never throws.
|
|
1255
|
+
*/
|
|
1256
|
+
declare const resolveIdentity: (input: ResolveIdentityInput) => IdentityRecord | undefined;
|
|
1257
|
+
/**
|
|
1258
|
+
* The HOST-supplied id this process has seen for a space, or `undefined` when every surface so far
|
|
1259
|
+
* let the kit mint its own. Answers the cross-surface question no single mount can answer alone:
|
|
1260
|
+
* "does this app own a device id that this particular mount was not given?"
|
|
1261
|
+
*/
|
|
1262
|
+
declare const hostIdentity: (space: IdentitySpace, scope?: string) => string | undefined;
|
|
1263
|
+
/** Test-only: forget every recorded host identity so a unit test starts from a clean registry. */
|
|
1264
|
+
declare const resetIdentityProvenance: () => void;
|
|
845
1265
|
|
|
846
1266
|
/**
|
|
847
1267
|
* deviceId — mint a stable, NON-PII, per-install device id the kit owns when the host supplies
|
|
@@ -862,6 +1282,7 @@ declare const identifyOnboarding: (opts: IdentifyOnboardingOptions) => Promise<b
|
|
|
862
1282
|
* A host that wants its OWN device id still wins: pass `WireUserContext.deviceKey` and the kit uses
|
|
863
1283
|
* that verbatim and never mints/persists an auto id.
|
|
864
1284
|
*/
|
|
1285
|
+
|
|
865
1286
|
/** Prefix so an auto-minted id is visibly the kit's (distinguishable from a host-supplied `deviceKey`). */
|
|
866
1287
|
declare const AUTO_DEVICE_ID_PREFIX = "wdev_";
|
|
867
1288
|
/** The storage key the façade persists the auto-minted id under (namespaced per `appId`). */
|
|
@@ -917,6 +1338,22 @@ declare const resolveAutoDeviceKey: (opts?: ResolveAutoDeviceKeyOptions) => stri
|
|
|
917
1338
|
* with no `storage` it resolves immediately (there is nothing to hydrate from).
|
|
918
1339
|
*/
|
|
919
1340
|
declare const hydrateAutoDeviceKey: (opts?: ResolveAutoDeviceKeyOptions) => Promise<string>;
|
|
1341
|
+
/**
|
|
1342
|
+
* The PROVENANCE-CARRYING sibling of {@link hydrateAutoDeviceKey}: the same awaited read, but it
|
|
1343
|
+
* answers "is this id one this install will KEEP?" instead of only "what is the id?".
|
|
1344
|
+
*
|
|
1345
|
+
* WHY IT EXISTS (K1). `<WireOnboarding>` gated auto-injection on `Boolean(storage)` — the presence of
|
|
1346
|
+
* the prop — because a string carries no provenance and there was nothing better to gate on. A
|
|
1347
|
+
* REJECTING adapter therefore injected a fresh `wdev_*` on every launch: strictly worse than
|
|
1348
|
+
* injecting nothing, since the server counts `min_sessions` by distinct opens grouped on `device_key`,
|
|
1349
|
+
* so a per-launch key corrupts that counter AND inflates distinct-device counts. Callers that write a
|
|
1350
|
+
* key onto the wire as a cross-launch join must read `durable` and refuse a `false`.
|
|
1351
|
+
*
|
|
1352
|
+
* Resolves `undefined` only when there is no usable id at all. With no `storage` it resolves
|
|
1353
|
+
* immediately with `durable: false` — a process-scoped id is exactly what "no persistence" means.
|
|
1354
|
+
* Never throws or rejects.
|
|
1355
|
+
*/
|
|
1356
|
+
declare const hydrateDeviceIdentity: (opts?: ResolveAutoDeviceKeyOptions) => Promise<IdentityRecord | undefined>;
|
|
920
1357
|
/** Test-only: forget every auto id + hydration flag so a unit test starts from a clean registry. */
|
|
921
1358
|
declare const resetAutoDeviceKeys: () => void;
|
|
922
1359
|
|
|
@@ -948,4 +1385,4 @@ declare const resetCurrentSessionId: () => void;
|
|
|
948
1385
|
*/
|
|
949
1386
|
declare const ensureCurrentSessionId: () => string;
|
|
950
1387
|
|
|
951
|
-
export {
|
|
1388
|
+
export { type IdentifyOnboardingOptions as $, AUTO_DEVICE_ID_PREFIX as A, resetEventQueueKeys as B, type ClearUserContextOptions as C, type DeviceKeyStorage as D, type EventQueueOptions as E, resolveAutoDeviceKey as F, setCurrentSessionId as G, toAnalyticsEvent as H, type WireOnboardingProps as I, type WireOnboardingConfig as J, type WirePermissionStatus as K, type WirePermissionOutcome as L, type OnboardingEvent as M, type OnboardingCopy as N, type OnboardingResult as O, type PermissionStage as P, type DeviceContext as Q, type ResolveAutoDeviceKeyOptions as R, type StepValidator as S, type PermissionScreenConfig as T, type PermissionPlacement as U, DEFAULT_PERMISSION_COPY as V, type WireUserContext as W, type DeviceFormFactor as X, EXTRA_KEY_PREFIX as Y, GENERIC_PERMISSION_COPY as Z, type IdentifyOnboardingBinding as _, type AnalyticsEvent as a, type IdentityRecord as a0, type IdentitySource as a1, type IdentitySpace as a2, NOTIFICATIONS_PERMISSION_COPY as a3, type OnboardingProgress as a4, type PermissionScreenCopy as a5, type ResolveUserContextOptions as a6, type ResolvedUserContext as a7, USER_ID_MAX_LENGTH as a8, WIRE_PERMISSION_EVENTS as a9, type WirePermissionEventName as aa, type WirePermissionKind as ab, activationJoinContext as ac, collectDeviceContext as ad, hashEmailFnv1a as ae, hostIdentity as af, hydrateAutoDeviceKey as ag, hydrateDeviceIdentity as ah, identifyOnboarding as ai, isWireScalar as aj, mintDeviceId as ak, namespaceExtra as al, normalizePermissionStatus as am, permissionEventName as an, permissionEventProps as ao, resetIdentityProvenance as ap, resolveIdentity as aq, resolvePermissionCopy as ar, resolveUserContext as as, sanitizeUserId as at, type ClientEvent as b, type ClientEventTarget as c, type ClientEventType as d, type ContextEnvelope as e, type ContextEnvelopeInput as f, type EnvelopeSource as g, type EventQueue as h, WIRE_ONBOARDING_EVENTS as i, type WireOnboardingEventName as j, analyticsUserIdStorageKey as k, buildContextEnvelope as l, clearPiiFromContext as m, clearUserContext as n, createEventQueue as o, deviceIdStorageKey as p, ensureCurrentSessionId as q, getCurrentSessionId as r, looksLikeEmail as s, makeSessionId as t, reportClientEvent as u, reportClientEventAwait as v, reportClientEvents as w, reportClientEventsAwait as x, resetAutoDeviceKeys as y, resetCurrentSessionId as z };
|