@wireai/activation 0.13.0 → 0.13.3

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 (82) hide show
  1. package/AGENTS.md +7 -4
  2. package/CHANGELOG.md +341 -4
  3. package/README.md +98 -2
  4. package/dist/analytics/index.d.mts +4 -4
  5. package/dist/analytics/index.d.ts +4 -4
  6. package/dist/analytics/index.js +164 -471
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +162 -469
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/coachmarks/index.d.mts +14 -1
  11. package/dist/coachmarks/index.d.ts +14 -1
  12. package/dist/coachmarks/index.js +58 -14
  13. package/dist/coachmarks/index.js.map +1 -1
  14. package/dist/coachmarks/index.mjs +58 -14
  15. package/dist/coachmarks/index.mjs.map +1 -1
  16. package/dist/{currentSession-_GynvhzT.d.mts → currentSession-BoWtr3Jp.d.mts} +299 -14
  17. package/dist/{currentSession-D7zabMXK.d.ts → currentSession-CCOMlaQ2.d.ts} +299 -14
  18. package/dist/{decision-Bl_M2y3r.d.mts → decision-Cjw0jbEj.d.mts} +1 -1
  19. package/dist/{decision-Cau5KmP6.d.ts → decision-GfpXAsk_.d.ts} +1 -1
  20. package/dist/index.d.mts +218 -4
  21. package/dist/index.d.ts +218 -4
  22. package/dist/index.js +1104 -888
  23. package/dist/index.js.map +1 -1
  24. package/dist/index.mjs +887 -692
  25. package/dist/index.mjs.map +1 -1
  26. package/dist/questionnaire/index.d.mts +42 -10
  27. package/dist/questionnaire/index.d.ts +42 -10
  28. package/dist/questionnaire/index.js +45 -13
  29. package/dist/questionnaire/index.js.map +1 -1
  30. package/dist/questionnaire/index.mjs +45 -13
  31. package/dist/questionnaire/index.mjs.map +1 -1
  32. package/dist/reviews/index.d.mts +4 -4
  33. package/dist/reviews/index.d.ts +4 -4
  34. package/dist/reviews/index.js +39 -8
  35. package/dist/reviews/index.js.map +1 -1
  36. package/dist/reviews/index.mjs +39 -8
  37. package/dist/reviews/index.mjs.map +1 -1
  38. package/dist/showcase/index.js +7 -2
  39. package/dist/showcase/index.js.map +1 -1
  40. package/dist/showcase/index.mjs +7 -2
  41. package/dist/showcase/index.mjs.map +1 -1
  42. package/dist/{transport-DsRe4epC.d.ts → transport-CefdERPs.d.mts} +44 -5
  43. package/dist/{transport-CF_eHwzC.d.mts → transport-DFuPyCoJ.d.ts} +44 -5
  44. package/dist/{types-Buj9Lw9t.d.ts → types-UVYt9BJe.d.ts} +41 -1
  45. package/dist/{types-CNUqMK0D.d.mts → types-l2tfg23P.d.mts} +41 -1
  46. package/package.json +4 -1
  47. package/src/OnboardingFlow.tsx +175 -15
  48. package/src/WireOnboarding.tsx +70 -8
  49. package/src/activation/wireActivation.ts +3 -3
  50. package/src/analytics/analyticsEvent.ts +16 -1
  51. package/src/analytics/analyticsFacade.ts +2 -2
  52. package/src/analytics/eventQueue.ts +85 -20
  53. package/src/analytics/reportClientEvent.ts +68 -12
  54. package/src/cards/CardGridSelectCard.tsx +1 -1
  55. package/src/cards/ChipSelectCard.tsx +2 -2
  56. package/src/cards/PermissionCard.tsx +438 -0
  57. package/src/cards/SelectionCard.tsx +1 -1
  58. package/src/cards/index.ts +7 -0
  59. package/src/coachmarks/runtime.ts +62 -2
  60. package/src/coachmarks/useCoachmarkTour.ts +62 -14
  61. package/src/context/deviceId.ts +4 -4
  62. package/src/features/WireFeaturesProvider.tsx +4 -1
  63. package/src/identity/userIdentity.ts +1 -1
  64. package/src/illustrations/defaultIllustrations.tsx +44 -3
  65. package/src/index.ts +38 -0
  66. package/src/permissions/index.ts +64 -0
  67. package/src/permissions/permissionCopy.ts +87 -0
  68. package/src/permissions/permissionEvents.ts +76 -0
  69. package/src/permissions/permissionMemory.ts +88 -0
  70. package/src/permissions/placement.ts +88 -0
  71. package/src/permissions/types.ts +131 -0
  72. package/src/questionnaire/QuestionnaireGate.tsx +90 -10
  73. package/src/questionnaire/index.ts +9 -1
  74. package/src/questionnaire/transport.ts +46 -11
  75. package/src/reviews/ReviewGate.tsx +61 -6
  76. package/src/reviews/index.ts +10 -1
  77. package/src/reviews/transport.ts +58 -12
  78. package/src/session/persistedSession.ts +10 -3
  79. package/src/session-analytics/useLifecycleEvents.ts +1 -1
  80. package/src/theme/ThemeContext.tsx +42 -1
  81. package/src/types.ts +66 -4
  82. package/src/utils/submitResult.ts +39 -0
@@ -74,7 +74,7 @@ const AUTO_DEVICE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:autoD
74
74
  /** What a hydration settled on: the id, and whether persistence actually CONFIRMED it.
75
75
  *
76
76
  * `durable: false` means the id lives only in this process's memory — the adapter rejected, threw,
77
- * or there was no adapter at all. That distinction is the whole of K1: a string is a string, so
77
+ * or there was no adapter at all. That distinction is the whole point here: a string is a string, so
78
78
  * before 0.13.0 a caller could not tell a persisted id from a per-launch mint, and the auto-join
79
79
  * gate (`Boolean(storage)`) was reading the PRESENCE of the prop rather than the SUCCESS of the
80
80
  * write. See {@link hydrateDeviceIdentity}. */
@@ -131,7 +131,7 @@ export interface ResolveAutoDeviceKeyOptions {
131
131
  * the write. The id is real but PROCESS-scoped, so anything that
132
132
  * counts a device across launches must refuse it.
133
133
  *
134
- * A DEGRADED outcome also drops the registry latches so the NEXT caller starts a fresh read (K8). A
134
+ * A DEGRADED outcome also drops the registry latches so the NEXT caller starts a fresh read. A
135
135
  * cold-boot storage lock is transient; caching it as a verdict for the process lifetime turned a
136
136
  * one-second problem into a whole-launch one, and nothing ever retried.
137
137
  */
@@ -170,7 +170,7 @@ const startHydration = (
170
170
  run = Promise.resolve(degraded());
171
171
  }
172
172
  registry.pending.set(appId, run);
173
- // Retry-on-failure (K8): release the latches once a degraded outcome settles, so a later caller
173
+ // Retry-on-failure: release the latches once a degraded outcome settles, so a later caller
174
174
  // is not permanently bound to one bad read. Registered AFTER the `set` above, so the clean-up can
175
175
  // never race ahead of the entry it is clearing.
176
176
  void run.then((outcome) => {
@@ -238,7 +238,7 @@ export const hydrateAutoDeviceKey = async (
238
238
  * The PROVENANCE-CARRYING sibling of {@link hydrateAutoDeviceKey}: the same awaited read, but it
239
239
  * answers "is this id one this install will KEEP?" instead of only "what is the id?".
240
240
  *
241
- * WHY IT EXISTS (K1). `<WireOnboarding>` gated auto-injection on `Boolean(storage)` — the presence of
241
+ * WHY IT EXISTS. `<WireOnboarding>` gated auto-injection on `Boolean(storage)` — the presence of
242
242
  * the prop — because a string carries no provenance and there was nothing better to gate on. A
243
243
  * REJECTING adapter therefore injected a fresh `wdev_*` on every launch: strictly worse than
244
244
  * injecting nothing, since the server counts `min_sessions` by distinct opens grouped on `device_key`,
@@ -19,7 +19,10 @@ import { useWireFeatures } from "./useWireFeatures";
19
19
  import type { WireFeatures, WireFeaturesConfig } from "./types";
20
20
 
21
21
  const CONTEXT_SYMBOL = Symbol.for("wireai.features.context");
22
- const globalObj = global as any;
22
+ // `globalThis`, not `global`: the other nine `Symbol.for` slots in this kit all use it, and `global`
23
+ // simply does not exist in a plain browser (RN-web / SSR-in-the-browser), where this line would
24
+ // throw a ReferenceError before the provider could render anything.
25
+ const globalObj = globalThis as any;
23
26
 
24
27
  if (!globalObj[CONTEXT_SYMBOL]) {
25
28
  globalObj[CONTEXT_SYMBOL] = createContext<WireFeatures | null>(null);
@@ -131,7 +131,7 @@ export const identifyOnboarding = async (
131
131
  }
132
132
  }
133
133
  let space: Exclude<IdentifyOnboardingBinding, false> = "onboarding";
134
- // The OPT-IN last resort (K3). Until 0.13.0 this ran unconditionally: with no captured contextId
134
+ // The OPT-IN last resort. Until 0.13.0 this ran unconditionally: with no captured contextId
135
135
  // it posted the LIVE PER-OPEN session id in the `session_id` field — which on this endpoint means
136
136
  // the ONBOARDING session — and then returned `true`. Two disjoint id spaces share that field, so
137
137
  // the row it wrote could never join the onboarding funnel, and the host got a success signal for a
@@ -16,8 +16,8 @@ import React from "react";
16
16
  import { StyleSheet, Text, View } from "react-native";
17
17
  import { useOnboardingTheme } from "../theme/ThemeContext";
18
18
 
19
- /** A soft tinted disc with a centered glyph the shared frame for every default. */
20
- const Glyph: React.FC<{ children: React.ReactNode }> = ({ children }) => {
19
+ /** The soft tinted disc every default sits in, whether its content is a glyph or a shape. */
20
+ const Disc: React.FC<{ children: React.ReactNode }> = ({ children }) => {
21
21
  const t = useOnboardingTheme();
22
22
  return (
23
23
  <View
@@ -26,16 +26,51 @@ const Glyph: React.FC<{ children: React.ReactNode }> = ({ children }) => {
26
26
  { backgroundColor: t.colors.primarySoft, borderRadius: t.radius.full },
27
27
  ]}
28
28
  >
29
+ {children}
30
+ </View>
31
+ );
32
+ };
33
+
34
+ /** A soft tinted disc with a centered glyph: the shared frame for every text-glyph default. */
35
+ const Glyph: React.FC<{ children: React.ReactNode }> = ({ children }) => {
36
+ const t = useOnboardingTheme();
37
+ return (
38
+ <Disc>
29
39
  <Text style={[styles.glyphText, { color: t.colors.primary }]} allowFontScaling={false}>
30
40
  {children}
31
41
  </Text>
32
- </View>
42
+ </Disc>
33
43
  );
34
44
  };
35
45
 
36
46
  /** Forward-motion / momentum: an upward arrow. */
37
47
  const MomentumGlyph: React.FC = () => <Glyph>{"↗"}</Glyph>;
38
48
 
49
+ /**
50
+ * Notification priming: a bell, DRAWN FROM VIEWS rather than written as an emoji.
51
+ *
52
+ * A color emoji carries its own palette and ignores the `color` its container sets, so a "🔔" here
53
+ * would be the one default illustration that refuses to repaint with the host's theme, sitting
54
+ * next to siblings that do. Three plain Views (a domed body, a rim, a clapper) take
55
+ * `t.colors.primary` directly, and they depend on no glyph being present in the platform font and
56
+ * on no guess about whether it renders as text or as emoji.
57
+ *
58
+ * Keyed by the PERMISSION name, which is what PermissionCard looks up by default, so a
59
+ * notification screen is never a blank box with no host wiring.
60
+ */
61
+ const NotificationsGlyph: React.FC = () => {
62
+ const t = useOnboardingTheme();
63
+ return (
64
+ <Disc>
65
+ <View style={styles.bell}>
66
+ <View style={[styles.bellBody, { backgroundColor: t.colors.primary }]} />
67
+ <View style={[styles.bellRim, { backgroundColor: t.colors.primary }]} />
68
+ <View style={[styles.bellClapper, { backgroundColor: t.colors.primary }]} />
69
+ </View>
70
+ </Disc>
71
+ );
72
+ };
73
+
39
74
  /** Before → after: two states with an arrow between. */
40
75
  const BeforeAfterGlyph: React.FC = () => {
41
76
  const t = useOnboardingTheme();
@@ -67,6 +102,7 @@ const BeforeAfterGlyph: React.FC = () => {
67
102
  export const defaultIllustrations: Record<string, React.ReactNode> = {
68
103
  momentum: <MomentumGlyph />,
69
104
  "before-after": <BeforeAfterGlyph />,
105
+ notifications: <NotificationsGlyph />,
70
106
  };
71
107
 
72
108
  const styles = StyleSheet.create({
@@ -96,4 +132,9 @@ const styles = StyleSheet.create({
96
132
  fontSize: 28,
97
133
  fontWeight: "700",
98
134
  },
135
+ // The bell: a domed body over a wider rim, with the clapper hanging below it.
136
+ bell: { alignItems: "center", justifyContent: "center" },
137
+ bellBody: { width: 34, height: 30, borderTopLeftRadius: 17, borderTopRightRadius: 17 },
138
+ bellRim: { width: 44, height: 5, borderRadius: 3, marginTop: 2 },
139
+ bellClapper: { width: 9, height: 9, borderRadius: 5, marginTop: 3 },
99
140
  });
package/src/index.ts CHANGED
@@ -57,6 +57,10 @@ export {
57
57
  onboardingComponents,
58
58
  } from "./cards";
59
59
  export type { CardOption } from "./cards";
60
+ // The priming screen. NOT in `onboardingComponents` (see the note there): it is host-declared via
61
+ // the `permissionScreens` prop, and registered so a server-emitted placement can adopt it later.
62
+ export { PermissionCard, PermissionCardView, PERMISSION_CARD_NAME } from "./cards";
63
+ export type { PermissionCardProps } from "./cards";
60
64
 
61
65
  // ─── Icons (semantic vocabulary → optional @expo/vector-icons → nothing) ──────
62
66
  // WIRE_ICON_NAMES is the list to paste into the server/AI prompt as the allowed `icon` values.
@@ -243,6 +247,40 @@ export type {
243
247
  RevenueCatSink,
244
248
  } from "./revenuecat";
245
249
 
250
+ // ─── Permission screens (mid-flow priming; the OS dialog only ever on the primary tap) ──
251
+ export {
252
+ WIRE_PERMISSION_EVENTS,
253
+ permissionEventName,
254
+ permissionEventProps,
255
+ normalizePermissionStatus,
256
+ DEFAULT_PERMISSION_PLACEMENT,
257
+ isPermissionDue,
258
+ normalizeAfterCard,
259
+ permissionScreenId,
260
+ selectDuePermissionScreen,
261
+ DEFAULT_PERMISSION_COPY,
262
+ GENERIC_PERMISSION_COPY,
263
+ NOTIFICATIONS_PERMISSION_COPY,
264
+ resolvePermissionCopy,
265
+ clearSettledPermissions,
266
+ loadSettledPermissions,
267
+ permissionStorageKey,
268
+ readSettledPermissions,
269
+ saveSettledPermissions,
270
+ } from "./permissions";
271
+ export type {
272
+ PermissionPlacement,
273
+ PermissionScreenConfig,
274
+ PermissionScreenCopy,
275
+ PermissionStage,
276
+ WirePermissionKind,
277
+ WirePermissionOutcome,
278
+ WirePermissionStatus,
279
+ WirePermissionEventName,
280
+ FlowPosition,
281
+ DuePermissionScreen,
282
+ } from "./permissions";
283
+
246
284
  // ─── Session mapping (one `app.session_started` per app-open → /v1/events) ─────
247
285
  export {
248
286
  reportSessionStart,
@@ -0,0 +1,64 @@
1
+ /**
2
+ * permissions - injectable mid-flow permission screens.
3
+ *
4
+ * Re-exported from the main `@wireai/activation` barrel (no separate subpath), the same way
5
+ * `revenuecat` is: everything here is pure, dependency-free and UI-free, so it costs a consumer
6
+ * that never configures a screen nothing. The screen itself lives in `cards/PermissionCard.tsx`.
7
+ *
8
+ * Adopting it is one prop:
9
+ *
10
+ * <WireOnboarding
11
+ * permissionScreens={[{ permission: "notifications", request: askForNotifications }]}
12
+ * ...
13
+ * />
14
+ *
15
+ * See `types.ts` for why the kit imports no native permission module, and `PermissionCard.tsx` for
16
+ * the priming rule the whole feature exists to enforce.
17
+ */
18
+
19
+ // ─── Canonical permission-funnel names + the pure mappers behind them ─────────
20
+ export {
21
+ WIRE_PERMISSION_EVENTS,
22
+ permissionEventName,
23
+ permissionEventProps,
24
+ normalizePermissionStatus,
25
+ } from "./permissionEvents";
26
+ export type { WirePermissionEventName } from "./permissionEvents";
27
+
28
+ // ─── Placement math (server-driven stream, so a target past the end clamps) ───
29
+ export {
30
+ DEFAULT_PERMISSION_PLACEMENT,
31
+ isPermissionDue,
32
+ normalizeAfterCard,
33
+ permissionScreenId,
34
+ selectDuePermissionScreen,
35
+ } from "./placement";
36
+ export type { FlowPosition, DuePermissionScreen } from "./placement";
37
+
38
+ // ─── Rationale copy (kit defaults + host overrides) ───────────────────────────
39
+ export {
40
+ DEFAULT_PERMISSION_COPY,
41
+ GENERIC_PERMISSION_COPY,
42
+ NOTIFICATIONS_PERMISSION_COPY,
43
+ resolvePermissionCopy,
44
+ } from "./permissionCopy";
45
+
46
+ // ─── Once-only across an app kill (the same host-injected `storage`) ──────────
47
+ export {
48
+ clearSettledPermissions,
49
+ loadSettledPermissions,
50
+ permissionStorageKey,
51
+ readSettledPermissions,
52
+ saveSettledPermissions,
53
+ } from "./permissionMemory";
54
+
55
+ // ─── Types ───────────────────────────────────────────────────────────────────
56
+ export type {
57
+ PermissionPlacement,
58
+ PermissionScreenConfig,
59
+ PermissionScreenCopy,
60
+ PermissionStage,
61
+ WirePermissionKind,
62
+ WirePermissionOutcome,
63
+ WirePermissionStatus,
64
+ } from "./types";
@@ -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
+ };