@wireai/activation 0.13.0 → 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.
Files changed (41) hide show
  1. package/AGENTS.md +3 -1
  2. package/CHANGELOG.md +116 -3
  3. package/README.md +82 -0
  4. package/dist/analytics/index.d.mts +2 -2
  5. package/dist/analytics/index.d.ts +2 -2
  6. package/dist/analytics/index.js +61 -2
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +61 -2
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/{currentSession-_GynvhzT.d.mts → currentSession-ClkLjcJ0.d.mts} +298 -13
  11. package/dist/{currentSession-D7zabMXK.d.ts → currentSession-DOVZEWJl.d.ts} +298 -13
  12. package/dist/index.d.mts +195 -4
  13. package/dist/index.d.ts +195 -4
  14. package/dist/index.js +909 -340
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +693 -145
  17. package/dist/index.mjs.map +1 -1
  18. package/dist/questionnaire/index.js.map +1 -1
  19. package/dist/questionnaire/index.mjs.map +1 -1
  20. package/dist/reviews/index.js +12 -1
  21. package/dist/reviews/index.js.map +1 -1
  22. package/dist/reviews/index.mjs +12 -1
  23. package/dist/reviews/index.mjs.map +1 -1
  24. package/package.json +1 -1
  25. package/src/OnboardingFlow.tsx +131 -0
  26. package/src/WireOnboarding.tsx +63 -2
  27. package/src/analytics/analyticsEvent.ts +16 -1
  28. package/src/analytics/eventQueue.ts +2 -0
  29. package/src/analytics/reportClientEvent.ts +68 -12
  30. package/src/cards/PermissionCard.tsx +438 -0
  31. package/src/cards/index.ts +7 -0
  32. package/src/illustrations/defaultIllustrations.tsx +44 -3
  33. package/src/index.ts +38 -0
  34. package/src/permissions/index.ts +64 -0
  35. package/src/permissions/permissionCopy.ts +87 -0
  36. package/src/permissions/permissionEvents.ts +76 -0
  37. package/src/permissions/permissionMemory.ts +88 -0
  38. package/src/permissions/placement.ts +88 -0
  39. package/src/permissions/types.ts +131 -0
  40. package/src/session/persistedSession.ts +10 -3
  41. package/src/types.ts +66 -4
@@ -0,0 +1,87 @@
1
+ /**
2
+ * permissionCopy - the rationale a priming screen shows, and the kit-quality defaults behind it.
3
+ *
4
+ * Same job the `copy` prop does for the loaders: the kit ships English that is good enough to
5
+ * ship as-is, and a host overrides any single line without having to restate the rest.
6
+ *
7
+ * PURE, no React, no React Native - so the resolution is unit-testable and the same function can
8
+ * later resolve copy a server sent.
9
+ */
10
+ import type { WirePermissionKind } from "./types";
11
+
12
+ /** Every string a permission screen can render. Both states (the ask and the blocked route). */
13
+ export type PermissionScreenCopy = {
14
+ /** Headline for the ask. */
15
+ title: string;
16
+ /** The rationale. The one thing that decides whether the primary gets tapped. */
17
+ message: string;
18
+ /** Primary button. Tapping it is the ONLY thing that can open the OS dialog. */
19
+ primaryLabel: string;
20
+ /** Secondary button. Advances the flow and does NOT spend the one native prompt. */
21
+ secondaryLabel: string;
22
+ /** Headline once the OS says the permission is permanently refused. */
23
+ blockedTitle: string;
24
+ /** Rationale for the blocked state, where the only remaining route is the settings app. */
25
+ blockedMessage: string;
26
+ /** Primary button on the blocked route. */
27
+ settingsLabel: string;
28
+ /** Primary button when there is nothing left to ask for (already granted, or no `request`). */
29
+ continueLabel: string;
30
+ };
31
+
32
+ /**
33
+ * The notification default. Written to the same bar as the kit's other shipped copy: it names the
34
+ * benefit in the user's terms and promises a limit, because "we would like to send you
35
+ * notifications" is exactly the sentence that spends the one iOS prompt on a no.
36
+ */
37
+ export const NOTIFICATIONS_PERMISSION_COPY: PermissionScreenCopy = {
38
+ title: "Want a nudge at the right moment?",
39
+ message:
40
+ "Turn notifications on and we will remind you when it actually helps. No daily noise, and you can turn them off any time.",
41
+ primaryLabel: "Enable notifications",
42
+ secondaryLabel: "Maybe later",
43
+ blockedTitle: "Notifications are switched off",
44
+ blockedMessage:
45
+ "Notifications are turned off for this app, so we cannot ask from here. You can switch them back on in Settings.",
46
+ settingsLabel: "Open settings",
47
+ continueLabel: "Continue",
48
+ };
49
+
50
+ /** The fallback for any permission the kit ships no copy for. A host overriding `copy` replaces it. */
51
+ export const GENERIC_PERMISSION_COPY: PermissionScreenCopy = {
52
+ title: "One quick permission",
53
+ message: "We need your permission for this part to work. You can change it any time.",
54
+ primaryLabel: "Allow",
55
+ secondaryLabel: "Maybe later",
56
+ blockedTitle: "Permission is switched off",
57
+ blockedMessage:
58
+ "This permission is turned off for this app, so we cannot ask from here. You can switch it back on in Settings.",
59
+ settingsLabel: "Open settings",
60
+ continueLabel: "Continue",
61
+ };
62
+
63
+ /** The kit's shipped defaults, by permission. Anything not listed falls back to the generic set. */
64
+ export const DEFAULT_PERMISSION_COPY: Record<string, PermissionScreenCopy> = {
65
+ notifications: NOTIFICATIONS_PERMISSION_COPY,
66
+ };
67
+
68
+ /**
69
+ * The kit default for `permission`, with the host's overrides applied on top.
70
+ *
71
+ * An override key whose value is `undefined` is IGNORED rather than allowed to blank the default:
72
+ * hosts build this object from their i18n layer, and a missing translation resolving to `undefined`
73
+ * would otherwise render an empty button.
74
+ */
75
+ export const resolvePermissionCopy = (
76
+ permission: WirePermissionKind,
77
+ overrides?: Partial<PermissionScreenCopy>,
78
+ ): PermissionScreenCopy => {
79
+ const base = DEFAULT_PERMISSION_COPY[permission] ?? GENERIC_PERMISSION_COPY;
80
+ if (!overrides) return base;
81
+ const merged: PermissionScreenCopy = { ...base };
82
+ for (const key of Object.keys(overrides) as Array<keyof PermissionScreenCopy>) {
83
+ const value = overrides[key];
84
+ if (typeof value === "string" && value.length > 0) merged[key] = value;
85
+ }
86
+ return merged;
87
+ };
@@ -0,0 +1,76 @@
1
+ /**
2
+ * permissionEvents - the canonical Wire names for a permission-priming funnel.
3
+ *
4
+ * Same job `purchaseEvents.ts` does for the subscription funnel and `analyticsEvent.ts` does for
5
+ * the onboarding funnel: every app was naming these itself (`push_permission`, `NOTIF_PROMPT`,
6
+ * `notifications_allowed`), so the same funnel read differently per tenant and no cross-app report
7
+ * was possible. These are the ONE set of names.
8
+ *
9
+ * They are `app_event` `question_key` values on the wire, exactly like `WIRE_PURCHASE_EVENTS`, so
10
+ * they are ALSO the exact strings a review / questionnaire firing trigger matches on. Never rename
11
+ * one: a rename silently unfires every trigger configured against the old string.
12
+ *
13
+ * PURE + dependency-free: no React, no React Native, no transport, no imports outside the type
14
+ * declarations - so an analytics-only bundle can carry the names without carrying a screen.
15
+ */
16
+ import type { PermissionStage, WirePermissionKind, WirePermissionStatus } from "./types";
17
+
18
+ export const WIRE_PERMISSION_EVENTS = {
19
+ /** The priming screen became visible. The denominator for every rate below. */
20
+ screenShown: "wire_permission_screen_shown",
21
+ /** The user tapped the primary, so the OS dialog is about to open. The rationale worked. */
22
+ primerAccepted: "wire_permission_primer_accepted",
23
+ /** The OS granted it. */
24
+ granted: "wire_permission_granted",
25
+ /** The OS refused it (a permanently blocked answer reports here too, with `status: "blocked"`). */
26
+ denied: "wire_permission_denied",
27
+ /** The user took the secondary. The one native prompt was NOT spent. */
28
+ skipped: "wire_permission_skipped",
29
+ /** A blocked user was redirected to the OS settings page. */
30
+ settingsOpened: "wire_permission_settings_opened",
31
+ } as const;
32
+
33
+ export type WirePermissionEventName =
34
+ (typeof WIRE_PERMISSION_EVENTS)[keyof typeof WIRE_PERMISSION_EVENTS];
35
+
36
+ /** The canonical event name for a stage. Exhaustive over the union (a new stage is a compile error). */
37
+ export const permissionEventName = (stage: PermissionStage): WirePermissionEventName => {
38
+ switch (stage) {
39
+ case "shown":
40
+ return WIRE_PERMISSION_EVENTS.screenShown;
41
+ case "accepted":
42
+ return WIRE_PERMISSION_EVENTS.primerAccepted;
43
+ case "granted":
44
+ return WIRE_PERMISSION_EVENTS.granted;
45
+ case "denied":
46
+ return WIRE_PERMISSION_EVENTS.denied;
47
+ case "skipped":
48
+ return WIRE_PERMISSION_EVENTS.skipped;
49
+ case "settings":
50
+ return WIRE_PERMISSION_EVENTS.settingsOpened;
51
+ default: {
52
+ const _exhaustive: never = stage;
53
+ return _exhaustive;
54
+ }
55
+ }
56
+ };
57
+
58
+ /**
59
+ * The small, non-PII props that ride a permission event. `permission` is always present so one
60
+ * funnel can be sliced per permission; `status` only appears when the OS actually answered, which
61
+ * is what keeps a `blocked` refusal distinguishable from a plain `denied` without a second event.
62
+ */
63
+ export const permissionEventProps = (
64
+ permission: WirePermissionKind,
65
+ status?: WirePermissionStatus,
66
+ ): Record<string, string> => (status ? { permission, status } : { permission });
67
+
68
+ /**
69
+ * Normalize whatever the host's `request` / `getStatus` actually returned.
70
+ *
71
+ * A native permission bridge is the host's code, and hosts return `"undetermined"`, `true`, or a
72
+ * whole Expo response object. Anything the kit does not recognise is treated as `denied`: it is the
73
+ * only reading that cannot invent a grant, and every outcome continues the flow anyway.
74
+ */
75
+ export const normalizePermissionStatus = (value: unknown): WirePermissionStatus =>
76
+ value === "granted" || value === "denied" || value === "blocked" ? value : "denied";
@@ -0,0 +1,88 @@
1
+ /**
2
+ * permissionMemory - "shown exactly once per session", kept true across an app kill.
3
+ *
4
+ * The in-memory guard alone is not enough. The kit already resumes a killed onboarding into the
5
+ * SAME backend session (see `persistedSession.ts`), so without a persisted record the resumed mount
6
+ * would re-show a permission screen the user had already answered - and on the ask path that means
7
+ * a second dialog attempt against an OS that grants exactly one.
8
+ *
9
+ * The record is SCOPED TO THE SESSION ID, one entry, never a growing map: a record whose
10
+ * `sessionId` is not the session being resumed is stale by definition and reads as empty. That is
11
+ * also what makes a genuinely new onboarding start clean without anything having to expire it.
12
+ *
13
+ * Same host-injected `WireOnboardingStorage` and the same best-effort discipline as the session
14
+ * seed: reads race the shared ceiling and degrade to "nothing remembered", writes swallow every
15
+ * error. A broken storage adapter must never gate or break onboarding.
16
+ */
17
+ import {
18
+ READ_TIMEOUT_MS,
19
+ withTimeout,
20
+ type WireOnboardingStorage,
21
+ } from "../session/persistedSession";
22
+
23
+ /** Storage key for an app's settled permission screens, e.g. `wireai:permissions:acme`. */
24
+ export const permissionStorageKey = (appId: string): string => `wireai:permissions:${appId}`;
25
+
26
+ type PermissionRecord = { sessionId: string; ids: string[] };
27
+
28
+ /**
29
+ * PURE seam: which screen ids a stored blob contributes to THIS session. A corrupt entry, a
30
+ * malformed shape, or a record belonging to a different session all read as "nothing remembered",
31
+ * because re-showing a screen is a far cheaper failure than trusting another session's answers.
32
+ */
33
+ export const readSettledPermissions = (
34
+ raw: string | null | undefined,
35
+ sessionId: string,
36
+ ): string[] => {
37
+ if (!raw || !sessionId) return [];
38
+ try {
39
+ const parsed: unknown = JSON.parse(raw);
40
+ if (typeof parsed !== "object" || parsed === null) return [];
41
+ const record = parsed as Partial<PermissionRecord>;
42
+ if (record.sessionId !== sessionId || !Array.isArray(record.ids)) return [];
43
+ return record.ids.filter((id): id is string => typeof id === "string" && id.length > 0);
44
+ } catch {
45
+ // Corrupt entry: treat as absent. The next write overwrites it.
46
+ return [];
47
+ }
48
+ };
49
+
50
+ /** Read the settled ids for `sessionId`. Never throws, never hangs past the shared read ceiling. */
51
+ export const loadSettledPermissions = async (
52
+ storage: WireOnboardingStorage,
53
+ key: string,
54
+ sessionId: string,
55
+ ): Promise<string[]> => {
56
+ try {
57
+ return readSettledPermissions(await withTimeout(storage.getItem(key), READ_TIMEOUT_MS), sessionId);
58
+ } catch {
59
+ return [];
60
+ }
61
+ };
62
+
63
+ /** Persist the settled ids for `sessionId` - fire-and-forget, all errors swallowed. */
64
+ export const saveSettledPermissions = (
65
+ storage: WireOnboardingStorage,
66
+ key: string,
67
+ sessionId: string,
68
+ ids: readonly string[],
69
+ ): void => {
70
+ try {
71
+ const record: PermissionRecord = { sessionId, ids: [...ids] };
72
+ void storage.setItem(key, JSON.stringify(record)).catch(() => {});
73
+ } catch {
74
+ // Best-effort: a failed write only means a resumed session may re-show the screen.
75
+ }
76
+ };
77
+
78
+ /** Drop the record (fired alongside the session seed on completion) - fire-and-forget. */
79
+ export const clearSettledPermissions = (
80
+ storage: WireOnboardingStorage,
81
+ key: string,
82
+ ): void => {
83
+ try {
84
+ void storage.removeItem(key).catch(() => {});
85
+ } catch {
86
+ // Best-effort.
87
+ }
88
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * placement - WHERE an injected permission screen lands in a stream nobody knows the length of.
3
+ *
4
+ * The card stream is server-driven: the backend decides how many questions this user gets, and the
5
+ * count differs per user and per run. So a placement is never an index into a known list; it is a
6
+ * predicate evaluated against the card the flow is ABOUT to render, on every turn.
7
+ *
8
+ * THE CLAMP is the whole reason this is a function and not a comparison. `{ afterCard: 4 }` against
9
+ * a stream that ended after 3 would simply never fire, and "the screen silently never showed" is
10
+ * the exact failure a host cannot see. So an unreached target degrades to `"beforeEnd"`: the
11
+ * terminal card is the last position where the screen can still exist, so that is where it goes.
12
+ *
13
+ * PURE, no React, no React Native - the placement math is the part worth unit-testing.
14
+ */
15
+ import type { PermissionPlacement, PermissionScreenConfig } from "./types";
16
+
17
+ /** Where a screen sits when the host does not say. High intent, and it can never be clamped away. */
18
+ export const DEFAULT_PERMISSION_PLACEMENT: PermissionPlacement = "beforeEnd";
19
+
20
+ /** The flow position a placement is resolved against. */
21
+ export type FlowPosition = {
22
+ /** 0-based index of the card about to render. `-1` when no card has arrived yet. */
23
+ cardIndex: number;
24
+ /** Whether that card is the terminal recap, i.e. the flow's last screen. */
25
+ isTerminal: boolean;
26
+ };
27
+
28
+ /** `afterCard` from a host is arbitrary input: floor it, floor it at 0, and never let NaN through. */
29
+ export const normalizeAfterCard = (value: number): number =>
30
+ Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
31
+
32
+ /** The 0-based card index a placement targets. `"beforeEnd"` has none, it rides `isTerminal`. */
33
+ const targetIndex = (placement: PermissionPlacement): number | undefined => {
34
+ if (placement === "beforeEnd") return undefined;
35
+ if (placement === "start") return 0;
36
+ return normalizeAfterCard(placement.afterCard);
37
+ };
38
+
39
+ /**
40
+ * Is a screen with this placement due at this position?
41
+ *
42
+ * `>=` rather than `===` on purpose: due-ness is re-evaluated every render and a screen is settled
43
+ * the moment it is answered, so a target the flow has already passed (because a screen ahead of it
44
+ * in the array was on screen for those turns) still fires instead of being skipped forever.
45
+ */
46
+ export const isPermissionDue = (
47
+ placement: PermissionPlacement,
48
+ position: FlowPosition,
49
+ ): boolean => {
50
+ if (position.cardIndex < 0) return false;
51
+ const target = targetIndex(placement);
52
+ // "beforeEnd", or an `afterCard` the stream ended before reaching: the terminal card is the
53
+ // last chance, so this is where the clamp lands.
54
+ if (target === undefined) return position.isTerminal;
55
+ return position.cardIndex >= target || position.isTerminal;
56
+ };
57
+
58
+ /**
59
+ * The stable id a screen is remembered by across a resume. Defaults to `<permission>:<index>`,
60
+ * which is right until the array is reordered - hence the documented `id` escape hatch.
61
+ */
62
+ export const permissionScreenId = (screen: PermissionScreenConfig, index: number): string =>
63
+ screen.id ?? `${screen.permission}:${index}`;
64
+
65
+ /** A due screen and the id it is remembered by. */
66
+ export type DuePermissionScreen = { screen: PermissionScreenConfig; id: string };
67
+
68
+ /**
69
+ * The FIRST configured screen that is due here and has not already been settled in this session.
70
+ * One at a time, in array order, so two screens due at the same position queue rather than collide.
71
+ */
72
+ export const selectDuePermissionScreen = (
73
+ screens: readonly PermissionScreenConfig[] | undefined,
74
+ settled: readonly string[],
75
+ position: FlowPosition,
76
+ ): DuePermissionScreen | undefined => {
77
+ if (!screens || screens.length === 0) return undefined;
78
+ for (let index = 0; index < screens.length; index++) {
79
+ const screen = screens[index];
80
+ if (!screen) continue;
81
+ const id = permissionScreenId(screen, index);
82
+ if (settled.includes(id)) continue;
83
+ if (isPermissionDue(screen.placement ?? DEFAULT_PERMISSION_PLACEMENT, position)) {
84
+ return { screen, id };
85
+ }
86
+ }
87
+ return undefined;
88
+ };
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Public types for the injectable mid-flow permission screens.
3
+ *
4
+ * THE ONE RULE THIS WHOLE MODULE EXISTS TO ENFORCE: the OS permission dialog is reached from the
5
+ * user's explicit primary tap and from nowhere else. iOS grants an app exactly ONE native
6
+ * notification prompt for its entire lifetime, so firing it on mount (the shape almost every app
7
+ * ships) spends the single ask on a user who has not been told why. A priming screen spends a
8
+ * cheap in-app screen first and only forwards the ones who said yes.
9
+ *
10
+ * DEPENDENCY-FREE, the RevenueCat-bridge idiom: the kit imports NO native permission module. The
11
+ * host passes `request` (and optionally `getStatus` / `openSettings`) in, exactly the way it hands
12
+ * the RevenueCat bridge real `CustomerInfo` objects. Wiring `expo-notifications` is five lines in
13
+ * the host and zero dependencies here.
14
+ *
15
+ * A PERMISSION SCREEN IS NOT A QUESTION. It mints no `key` and no `slot_id`, sends nothing to the
16
+ * backend, and never enters the thread, so `deriveAnswers` / `readProgress` and every completion
17
+ * semantic are byte-identical whether or not one is configured. Completion NEVER blocks on a grant.
18
+ */
19
+ import type { PermissionScreenCopy } from "./permissionCopy";
20
+
21
+ export type { PermissionScreenCopy } from "./permissionCopy";
22
+
23
+ /**
24
+ * Which OS permission a screen primes. `notifications` is the one the kit ships copy for; any other
25
+ * string is accepted (the host supplies the copy) so a host can prime tracking, health or location
26
+ * without waiting on a kit release.
27
+ */
28
+ export type WirePermissionKind = "notifications" | (string & {});
29
+
30
+ /**
31
+ * What the host's `request` / `getStatus` answers.
32
+ * - `granted`: the user allowed it.
33
+ * - `denied`: not allowed right now, but the OS would still show a dialog if asked again
34
+ * (Android, or an iOS provisional state).
35
+ * - `blocked`: permanently refused. Asking again shows NOTHING, so the only route left is Settings.
36
+ */
37
+ export type WirePermissionStatus = "granted" | "denied" | "blocked";
38
+
39
+ /** How a permission screen ENDED. `skipped` is the secondary tap, which never burns the OS prompt. */
40
+ export type WirePermissionOutcome = WirePermissionStatus | "skipped";
41
+
42
+ /**
43
+ * The moments a permission screen reports. One stage, one canonical event name (see
44
+ * `permissionEventName`), so the funnel reads the same across every tenant.
45
+ * - `shown`: the primer screen became visible. The denominator.
46
+ * - `accepted`: the user tapped the primary, so the OS dialog is ABOUT to open. The gap between
47
+ * `shown` and `accepted` is the only number that tells a host whether its rationale copy works.
48
+ * - `granted` / `denied`: what the OS answered (a `blocked` answer reports as `denied` and carries
49
+ * `status: "blocked"`, so the two are one funnel step and still distinguishable).
50
+ * - `skipped`: the secondary tap. The prompt was NOT spent.
51
+ * - `settings`: the user was redirected to the OS settings page (the `blocked` route).
52
+ */
53
+ export type PermissionStage =
54
+ | "shown"
55
+ | "accepted"
56
+ | "granted"
57
+ | "denied"
58
+ | "skipped"
59
+ | "settings";
60
+
61
+ /**
62
+ * WHERE a screen sits in the server-driven stream. The flow length is decided by the backend and
63
+ * varies per user, so every position is resolved against the card the flow is ABOUT to render:
64
+ * - `"start"`: before the first card.
65
+ * - `{ afterCard: n }`: after `n` cards have been shown (`{ afterCard: 2 }` sits between card 2
66
+ * and card 3). An `n` past the end of a shorter-than-expected stream degrades to `"beforeEnd"`
67
+ * rather than silently never showing.
68
+ * - `"beforeEnd"`: immediately before the terminal recap. The default.
69
+ */
70
+ export type PermissionPlacement = "start" | "beforeEnd" | { afterCard: number };
71
+
72
+ /**
73
+ * One injectable permission screen.
74
+ *
75
+ * ```tsx
76
+ * import * as Notifications from "expo-notifications";
77
+ *
78
+ * <WireOnboarding
79
+ * permissionScreens={[
80
+ * {
81
+ * permission: "notifications",
82
+ * placement: "beforeEnd",
83
+ * request: async () => {
84
+ * const { status, canAskAgain } = await Notifications.requestPermissionsAsync();
85
+ * return status === "granted" ? "granted" : canAskAgain ? "denied" : "blocked";
86
+ * },
87
+ * },
88
+ * ]}
89
+ * />
90
+ * ```
91
+ */
92
+ export type PermissionScreenConfig = {
93
+ /** Which permission this screen primes. Drives the default copy and every event's `permission`. */
94
+ permission: WirePermissionKind;
95
+ /**
96
+ * Stable id for the once-only / resume record. Defaults to `<permission>:<index in the array>`,
97
+ * which is right until you reorder the array; set it explicitly if you configure two screens for
98
+ * the same permission or intend to reorder them between releases.
99
+ */
100
+ id?: string;
101
+ /** Where the screen sits in the stream. Default `"beforeEnd"`. */
102
+ placement?: PermissionPlacement;
103
+ /**
104
+ * THE ONLY FUNCTION THE KIT CALLS THAT CAN OPEN AN OS DIALOG, and it is called from the primary
105
+ * press handler alone. Never from mount, never from an effect, never from a status probe.
106
+ */
107
+ request: () => Promise<WirePermissionStatus>;
108
+ /**
109
+ * Optional NON-PROMPTING status read (`Notifications.getPermissionsAsync()`), used only to pick
110
+ * which primary action the screen offers: a `blocked` user gets "Open settings" instead of an
111
+ * "Enable" button that would open nothing, and an already-granted user gets a plain Continue.
112
+ * Leave it out and the screen simply always offers the ask.
113
+ */
114
+ getStatus?: () => Promise<WirePermissionStatus>;
115
+ /** Open the OS settings page (`Linking.openSettings()`). Only reachable on the `blocked` route. */
116
+ openSettings?: () => void | Promise<void>;
117
+ /** Rationale copy overrides. Anything left out keeps the kit default for this permission. */
118
+ copy?: Partial<PermissionScreenCopy>;
119
+ /**
120
+ * Name of a host illustration (the existing `illustrations` registry). Defaults to the
121
+ * permission name, so registering `illustrations={{ notifications: <MyBell/> }}` is enough. The
122
+ * kit ships a dependency-free default so the screen is never a blank box.
123
+ */
124
+ illustration?: string;
125
+ /**
126
+ * Fired once, with the outcome this screen produced. This is the seam for scheduling a local
127
+ * notification the moment a grant lands; the kit deliberately schedules nothing itself. It can
128
+ * never break the flow: a throw here is caught and the flow continues.
129
+ */
130
+ onResult?: (permission: WirePermissionKind, outcome: WirePermissionOutcome) => void;
131
+ };
@@ -37,7 +37,7 @@ export type WireOnboardingStorage = {
37
37
  export const DEFAULT_SESSION_TTL_MS = 3_600_000;
38
38
 
39
39
  /** Ceiling on the storage read — a hung adapter degrades to a fresh mint, never a stuck gate. */
40
- const READ_TIMEOUT_MS = 1_500;
40
+ export const READ_TIMEOUT_MS = 1_500;
41
41
 
42
42
  /** Storage key for an app's cached session, e.g. `wireai:session:acme`. */
43
43
  export const sessionStorageKey = (appId: string): string => `wireai:session:${appId}`;
@@ -51,7 +51,14 @@ export type LoadedSession = {
51
51
  resumed: boolean;
52
52
  };
53
53
 
54
- const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | undefined> => {
54
+ /**
55
+ * Race a storage read against a ceiling, resolving `undefined` when the adapter did not answer.
56
+ * Exported so the permission memory reads through the same ceiling as the session seed rather than
57
+ * carrying a second copy of it. NOT the kit's only such helper: `features/cache.ts`,
58
+ * `session-analytics/lifecycle.ts` and `analytics/eventQueue.ts` each keep their own local timeout
59
+ * for their own transports. Consolidating those is a separate change, not this one.
60
+ */
61
+ export const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | undefined> => {
55
62
  let timer: ReturnType<typeof setTimeout>;
56
63
  const timeout = new Promise<undefined>((resolve) => {
57
64
  timer = setTimeout(() => resolve(undefined), ms);
@@ -150,7 +157,7 @@ export const clearPersistedSession = (storage: WireOnboardingStorage, key: strin
150
157
  * not opt in.)
151
158
  *
152
159
  * - undefined / false (the default) → `true`: clear on completion, so the NEXT onboarding on this
153
- * device mints a fresh session. This is the legacy single-stage behavior (e.g. Myelino).
160
+ * device mints a fresh session. This is the legacy single-stage behavior.
154
161
  * - true → `false`: LEAVE the seed in place so a same-signup re-entry/remount within the TTL
155
162
  * resumes the SAME session (one funnel start) instead of minting a phantom second `started`.
156
163
  * Freshness for a genuinely new run is then governed by the TTL (past `sessionTtlMs` → fresh
package/src/types.ts CHANGED
@@ -4,6 +4,12 @@
4
4
  import type { Message } from "wireai-rn";
5
5
  import type { OnboardingTheme } from "./theme/types";
6
6
  import type { WireOnboardingStorage } from "./session/persistedSession";
7
+ import type {
8
+ PermissionScreenConfig,
9
+ PermissionStage,
10
+ WirePermissionKind,
11
+ WirePermissionStatus,
12
+ } from "./permissions/types";
7
13
 
8
14
  export type { OnboardingTheme } from "./theme/types";
9
15
  export type { WireOnboardingStorage } from "./session/persistedSession";
@@ -65,6 +71,10 @@ export type OnboardingResult = {
65
71
  * - `fallback`: retries are exhausted; the kit degraded to the static `fallbackFlow`
66
72
  * (or handed off to `onError`). This is the client-side mirror of the
67
73
  * backend's `llm_fallback` reliability event.
74
+ * - `permission`: an injected permission screen moved (`shown` / `accepted` / `granted` /
75
+ * `denied` / `skipped` / `settings`). Carries NO funnel weight: a permission screen
76
+ * is not a question, so it never appears in `answers` and never gates completion.
77
+ * `toAnalyticsEvent` maps it to the canonical `wire_permission_*` name.
68
78
  */
69
79
  export type OnboardingEvent =
70
80
  | { type: "started"; contextId: string }
@@ -72,7 +82,14 @@ export type OnboardingEvent =
72
82
  | { type: "turn"; step: number; component?: string }
73
83
  | { type: "error"; reason: "backend" | "timeout" }
74
84
  | { type: "retry"; reason: "backend" | "timeout"; attempt: number }
75
- | { type: "fallback"; reason: "backend" | "timeout" };
85
+ | { type: "fallback"; reason: "backend" | "timeout" }
86
+ | {
87
+ type: "permission";
88
+ permission: WirePermissionKind;
89
+ stage: PermissionStage;
90
+ /** What the OS actually answered, when it answered. Absent on `shown` / `accepted`. */
91
+ status?: WirePermissionStatus;
92
+ };
76
93
 
77
94
  /**
78
95
  * Copy overrides for the kit's built-in (English) strings, so a host can localize
@@ -118,6 +135,48 @@ export type WireOnboardingProps = {
118
135
  icons?: Record<string, import("react").ReactNode>;
119
136
  /** Per-step validators keyed by base-question key, e.g. `{ username: checkUsername }`. */
120
137
  validators?: Record<string, StepValidator>;
138
+ /**
139
+ * PERMISSION SCREENS injected into the server-driven flow at a position you choose.
140
+ *
141
+ * The screen explains why the app wants the permission and asks the OS **only** on the primary
142
+ * tap. That priming pattern is not decoration: iOS grants an app exactly ONE native notification
143
+ * prompt for its whole lifetime, and firing it on mount spends it on a user who was told nothing.
144
+ * "Maybe later" advances the flow with the prompt still unspent.
145
+ *
146
+ * ```tsx
147
+ * import * as Notifications from "expo-notifications";
148
+ *
149
+ * <WireOnboarding
150
+ * permissionScreens={[
151
+ * {
152
+ * permission: "notifications",
153
+ * placement: "beforeEnd",
154
+ * request: async () => {
155
+ * const { status, canAskAgain } = await Notifications.requestPermissionsAsync();
156
+ * return status === "granted" ? "granted" : canAskAgain ? "denied" : "blocked";
157
+ * },
158
+ * getStatus: async () => {
159
+ * const { status, canAskAgain } = await Notifications.getPermissionsAsync();
160
+ * return status === "granted" ? "granted" : canAskAgain ? "denied" : "blocked";
161
+ * },
162
+ * onResult: (_p, outcome) => { if (outcome === "granted") scheduleFirstReminder(); },
163
+ * },
164
+ * ]}
165
+ * />
166
+ * ```
167
+ *
168
+ * The kit adds NO dependency for this: it imports no `expo-notifications`, no
169
+ * `react-native-permissions`, nothing native. You inject `request`, exactly the way you hand the
170
+ * RevenueCat bridge real RevenueCat objects.
171
+ *
172
+ * A permission screen is NOT a question. It sends nothing to the backend, never enters the
173
+ * thread, mints no `key` / `slot_id`, and never appears in `onComplete`'s `answers`. Completion
174
+ * never blocks on a grant: grant, deny, skip and blocked all continue the flow. Each screen is
175
+ * shown at most once per session, and with `storage` that survives an app kill (a resumed session
176
+ * does not re-ask). Analytics ride `onEvent` (`type: "permission"`) and the canonical
177
+ * `wire_permission_*` events, stamped with the same `device_key` as the rest of the funnel.
178
+ */
179
+ permissionScreens?: PermissionScreenConfig[];
121
180
  /** Fired once the flow reaches its terminal StatusCard. */
122
181
  onComplete: (result: OnboardingResult) => void;
123
182
  /**
@@ -281,9 +340,12 @@ export type OnboardingProgress = {
281
340
  * `answers.interests` silently becomes `answers.what_are_you_into_v2`, with no error anywhere. A
282
341
  * slot is the question's identity independent of its wording.
283
342
  *
284
- * FULLY ADDITIVE AND CURRENTLY INERT: no server emits it yet. Every fallback is PER-CARD, so a
285
- * thread that mixes slotted and unslotted cards (the real shape during a rollout) keys each one
286
- * correctly, and a backend that never sends it produces byte-identical behaviour to 0.12.2.
343
+ * FULLY ADDITIVE, AND LIVE SINCE 2026-07-28 (server `47dae92`). The deployed server sends it on
344
+ * `progress` for every AI-GENERATED question, as `adaptive_<n>` 1-based over adaptive answers, and
345
+ * for a CONFIGURED question only when the tenant set one. Where the tenant set none the field is
346
+ * simply absent. Every fallback is PER-CARD, so a thread that mixes slotted and unslotted cards
347
+ * (the real shape during a rollout) keys each one correctly, and a backend or tenant that never
348
+ * sends it produces byte-identical behaviour to 0.12.2.
287
349
  */
288
350
  slot_id?: string;
289
351
  /** Whether the CURRENT screen may be skipped (backend-marked; default false → no Skip shown). */