@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.
Files changed (55) hide show
  1. package/AGENTS.md +3 -1
  2. package/CHANGELOG.md +259 -1
  3. package/README.md +87 -3
  4. package/dist/analytics/index.d.mts +2 -2
  5. package/dist/analytics/index.d.ts +2 -2
  6. package/dist/analytics/index.js +174 -36
  7. package/dist/analytics/index.js.map +1 -1
  8. package/dist/analytics/index.mjs +174 -37
  9. package/dist/analytics/index.mjs.map +1 -1
  10. package/dist/{currentSession-BlCeDP0f.d.mts → currentSession-ClkLjcJ0.d.mts} +456 -19
  11. package/dist/{currentSession-BxEB37xt.d.ts → currentSession-DOVZEWJl.d.ts} +456 -19
  12. package/dist/index.d.mts +197 -16
  13. package/dist/index.d.ts +197 -16
  14. package/dist/index.js +1056 -390
  15. package/dist/index.js.map +1 -1
  16. package/dist/index.mjs +836 -193
  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 +20 -7
  21. package/dist/reviews/index.js.map +1 -1
  22. package/dist/reviews/index.mjs +20 -7
  23. package/dist/reviews/index.mjs.map +1 -1
  24. package/package.json +1 -1
  25. package/src/OnboardingFlow.tsx +141 -3
  26. package/src/WireOnboarding.tsx +178 -34
  27. package/src/activation/wireActivation.ts +13 -7
  28. package/src/analytics/analyticsEvent.ts +16 -1
  29. package/src/analytics/analyticsFacade.ts +11 -10
  30. package/src/analytics/currentSession.ts +6 -20
  31. package/src/analytics/eventQueue.ts +71 -1
  32. package/src/analytics/index.ts +1 -1
  33. package/src/analytics/reportClientEvent.ts +157 -38
  34. package/src/cards/PermissionCard.tsx +438 -0
  35. package/src/cards/index.ts +7 -0
  36. package/src/config/wireConfigFromEnv.ts +1 -10
  37. package/src/context/deviceId.ts +77 -16
  38. package/src/context/userContext.ts +4 -15
  39. package/src/identity/identityRecord.ts +123 -0
  40. package/src/identity/userIdentity.ts +45 -9
  41. package/src/illustrations/defaultIllustrations.tsx +44 -3
  42. package/src/index.ts +44 -4
  43. package/src/permissions/index.ts +64 -0
  44. package/src/permissions/permissionCopy.ts +87 -0
  45. package/src/permissions/permissionEvents.ts +76 -0
  46. package/src/permissions/permissionMemory.ts +88 -0
  47. package/src/permissions/placement.ts +88 -0
  48. package/src/permissions/types.ts +131 -0
  49. package/src/session/persistedSession.ts +10 -3
  50. package/src/session-analytics/useLifecycleEvents.ts +10 -1
  51. package/src/types.ts +77 -1
  52. package/src/utils/deriveAnswers.ts +6 -2
  53. package/src/utils/readProgress.ts +4 -0
  54. package/src/utils/warnInDev.ts +33 -0
  55. package/src/components/DoneBlock.tsx +0 -37
@@ -0,0 +1,123 @@
1
+ /**
2
+ * identityRecord — the ONE provenance-carrying shape for an id the kit puts on the wire.
3
+ *
4
+ * WHY IT EXISTS. `session_id` and `device_key` are bare `string`s minted independently by four
5
+ * subsystems, and nothing anywhere recorded WHERE a given id came from. Every id-layer defect this
6
+ * release fixes is a direct consequence of that one omission:
7
+ *
8
+ * • a rejecting storage adapter's in-memory id was indistinguishable from a persisted one, so the
9
+ * kit injected a fresh per-launch join key on every launch — nothing carried `durable`.
10
+ * • an app-OPEN session id could be posted into a field that means the ONBOARDING session, and the
11
+ * caller was told `true` — nothing carried `space`.
12
+ * • an auto-minted `wdev_*` could be injected beside a device id the host demonstrably owns on
13
+ * another surface, silently — nothing carried `source`.
14
+ *
15
+ * WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT. It is a small record plus a process-wide registry of
16
+ * the ids a HOST supplied. It is NOT a branded-type refactor (`OnboardingSessionId` / `AppSessionId` /
17
+ * `DeviceKey` across every signature) — that is real value and it is deferred, because it touches
18
+ * every file and is not what makes a number correct this week. Nothing here changes the wire.
19
+ *
20
+ * WHY A `Symbol.for` REGISTRY. Same reason as `analytics/currentSession` and `context/deviceId`: tsup
21
+ * inlines a separate copy of a module into each bundle (`.` and `./analytics`), so a plain module
22
+ * `let` would give every bundle its own registry and the cross-surface question this exists to answer
23
+ * ("did ANY surface in this process get a host-supplied device key?") would read `no` from the wrong
24
+ * copy. `Symbol.for` resolves to one slot on `globalThis` no matter how many copies exist.
25
+ */
26
+
27
+ /**
28
+ * Which id space a value belongs to. These are NOT interchangeable, and the whole point of naming
29
+ * them is that a value from one space must never be posted into a field that means another:
30
+ * • `onboarding-session` — the A2A `contextId` for ONE onboarding run.
31
+ * • `app-session` — the per-app-open session id (`app.session_started`).
32
+ * • `device` — the per-install `device_key`; the only cross-family join key.
33
+ */
34
+ export type IdentitySpace = "onboarding-session" | "app-session" | "device";
35
+
36
+ /** Where the value came from: the host handed it over, or the kit minted it. */
37
+ export type IdentitySource = "host" | "auto";
38
+
39
+ /** An id plus everything a consumer needs to decide whether it may use it. */
40
+ export type IdentityRecord = {
41
+ /** The id itself, trimmed. Never empty (a blank input yields no record at all). */
42
+ value: string;
43
+ /** Which id space {@link value} belongs to. */
44
+ space: IdentitySpace;
45
+ /** `host` = the integrator supplied it; `auto` = the kit minted it. */
46
+ source: IdentitySource;
47
+ /**
48
+ * Whether the value was actually PERSISTED (or adopted from persistence), as opposed to living
49
+ * only in this process's memory. A non-durable auto id is a DIFFERENT id on the next launch, which
50
+ * for a `device` value is worse than no value at all: the server counts `min_sessions` by distinct
51
+ * opens grouped on `device_key`, so a per-launch key corrupts the counter rather than leaving it
52
+ * empty. A host-supplied value is durable by definition — the host owns its lifetime.
53
+ */
54
+ durable: boolean;
55
+ };
56
+
57
+ /** Input to {@link resolveIdentity}. `value` is `unknown` so callers can pass a raw prop through. */
58
+ export type ResolveIdentityInput = {
59
+ value: unknown;
60
+ space: IdentitySpace;
61
+ source: IdentitySource;
62
+ /** Defaults to `true` for a host value (the host owns its lifetime) and `false` otherwise. */
63
+ durable?: boolean;
64
+ /** Tenant/app id — two tenants in one process never share a provenance entry. */
65
+ scope?: string;
66
+ };
67
+
68
+ /** Well-known key into the runtime-global symbol registry — one provenance registry per process. */
69
+ const IDENTITY_PROVENANCE_SLOT: unique symbol = Symbol.for("@wireai/activation:identityProvenance");
70
+
71
+ /** `"<space>:<scope>"` → the HOST-supplied value seen for it. Auto values are never recorded. */
72
+ type ProvenanceRegistry = { host: Map<string, string> };
73
+
74
+ type GlobalWithProvenance = typeof globalThis & {
75
+ [IDENTITY_PROVENANCE_SLOT]?: ProvenanceRegistry;
76
+ };
77
+
78
+ const provenanceGlobal = globalThis as GlobalWithProvenance;
79
+
80
+ const provenanceRegistry = (): ProvenanceRegistry => {
81
+ const existing = provenanceGlobal[IDENTITY_PROVENANCE_SLOT];
82
+ if (existing) return existing;
83
+ const created: ProvenanceRegistry = { host: new Map() };
84
+ provenanceGlobal[IDENTITY_PROVENANCE_SLOT] = created;
85
+ return created;
86
+ };
87
+
88
+ const provenanceKey = (space: IdentitySpace, scope?: string): string =>
89
+ `${space}:${scope ?? "default"}`;
90
+
91
+ /**
92
+ * Build an {@link IdentityRecord} from a candidate value, or `undefined` when there is nothing usable
93
+ * (a non-string, or blank after trimming) — so a caller can `if (record)`-gate instead of guessing
94
+ * whether an empty string means "none" or "not yet".
95
+ *
96
+ * SIDE EFFECT, deliberate and the reason this is a function and not an object literal: a `host`-sourced
97
+ * record is RECORDED on the process registry, so a later surface can ask {@link hostIdentity} whether
98
+ * this process demonstrably owns a host id in that space. That is what turns "the kit injected its own
99
+ * key" from a silent third id space into a warnable condition. Never throws.
100
+ */
101
+ export const resolveIdentity = (input: ResolveIdentityInput): IdentityRecord | undefined => {
102
+ if (typeof input.value !== "string") return undefined;
103
+ const value = input.value.trim();
104
+ if (!value) return undefined;
105
+ const durable = input.durable ?? input.source === "host";
106
+ if (input.source === "host") {
107
+ provenanceRegistry().host.set(provenanceKey(input.space, input.scope), value);
108
+ }
109
+ return { value, space: input.space, source: input.source, durable };
110
+ };
111
+
112
+ /**
113
+ * The HOST-supplied id this process has seen for a space, or `undefined` when every surface so far
114
+ * let the kit mint its own. Answers the cross-surface question no single mount can answer alone:
115
+ * "does this app own a device id that this particular mount was not given?"
116
+ */
117
+ export const hostIdentity = (space: IdentitySpace, scope?: string): string | undefined =>
118
+ provenanceRegistry().host.get(provenanceKey(space, scope));
119
+
120
+ /** Test-only: forget every recorded host identity so a unit test starts from a clean registry. */
121
+ export const resetIdentityProvenance = (): void => {
122
+ provenanceRegistry().host.clear();
123
+ };
@@ -76,20 +76,49 @@ export type IdentifyOnboardingOptions = {
76
76
  appId?: string;
77
77
  /** Storage key override — pass the same `persistKey` you gave `<WireOnboarding>`, if any. */
78
78
  persistKey?: string;
79
+ /**
80
+ * OPT-IN LAST RESORT, default `false`. When no ONBOARDING session can be resolved (no `contextId`,
81
+ * nothing in `storage`), bind the user to the LIVE PER-OPEN app session instead and return
82
+ * `"app_session"`.
83
+ *
84
+ * ⚠️ These are two different id spaces sharing one wire field. An onboarding session id is the A2A
85
+ * `contextId`; a per-open id is what `app.session_started` registers. The server's onboarding funnel
86
+ * groups by `session_id`, so a per-open id posted here does not attach the user to their onboarding
87
+ * — it writes a row nothing in that funnel can join. Until 0.13.0 this happened SILENTLY and
88
+ * returned `true`, in exactly the documented post-completion case (completion clears the persisted
89
+ * session), so the funnel stayed unattributed while the host was told it had worked.
90
+ *
91
+ * Turn it on only if binding the id to *some* session the server saw is genuinely worth more to you
92
+ * than knowing the onboarding bind failed — and read the return value, which now says which it was.
93
+ */
94
+ allowAppSessionFallback?: boolean;
79
95
  };
80
96
 
97
+ /**
98
+ * What {@link identifyOnboarding} bound, and to WHICH id space — because `true` could not say.
99
+ *
100
+ * • `"onboarding"` — bound to the A2A `contextId`. This is the one that attributes the funnel.
101
+ * • `"app_session"` — bound to the live per-open app session, via `allowAppSessionFallback`. The
102
+ * server saw that session, but it is not this user's onboarding.
103
+ * • `false` — nothing was dispatched (no user id, no server url, no resolvable session).
104
+ *
105
+ * ⚠️ 0.13.0 widened this from `boolean`. `"onboarding"` is truthy, so an `if (await identify…)` still
106
+ * behaves identically; only an explicit `: boolean` annotation needs updating.
107
+ */
108
+ export type IdentifyOnboardingBinding = "onboarding" | "app_session" | false;
109
+
81
110
  /**
82
111
  * Attach a host user id to an onboarding session AFTER the fact (post-registration), by sending
83
112
  * an `identify` client event to `/v1/events`. Resolves the contextId from an explicit
84
113
  * `contextId` or, failing that, from the persisted session in the host `storage`.
85
114
  *
86
- * Fire-and-forget under the hood (never throws, never blocks onboarding). Resolves to `true`
87
- * when an identify event was dispatched, `false` when it couldn't (no user id, no server url,
88
- * or no resolvable contextId).
115
+ * Fire-and-forget under the hood (never throws, never blocks onboarding). Resolves to the
116
+ * {@link IdentifyOnboardingBinding} that says WHICH id space was bound, or `false` when nothing could
117
+ * be (no user id, no server url, or no resolvable session).
89
118
  */
90
119
  export const identifyOnboarding = async (
91
120
  opts: IdentifyOnboardingOptions,
92
- ): Promise<boolean> => {
121
+ ): Promise<IdentifyOnboardingBinding> => {
93
122
  const userId = sanitizeUserId(opts.userId);
94
123
  if (!userId || !opts.config?.serverUrl) return false;
95
124
 
@@ -101,15 +130,22 @@ export const identifyOnboarding = async (
101
130
  contextId = stored?.id;
102
131
  }
103
132
  }
104
- // Last resort: bind to the LIVE per-open session (registered by `reportSessionStart`) so a
105
- // post-flow identify with no captured contextId still attaches to a session the server saw,
106
- // instead of no-oping. The onboarding contextId (above) is still preferred when available.
107
- if (!contextId) contextId = getCurrentSessionId();
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
135
+ // it posted the LIVE PER-OPEN session id in the `session_id` field which on this endpoint means
136
+ // the ONBOARDING session — and then returned `true`. Two disjoint id spaces share that field, so
137
+ // the row it wrote could never join the onboarding funnel, and the host got a success signal for a
138
+ // bind that had not happened. It is now off unless the caller asks, and when it does fire it says
139
+ // so in the return value instead of impersonating an onboarding bind.
140
+ if (!contextId && opts.allowAppSessionFallback) {
141
+ contextId = getCurrentSessionId();
142
+ space = "app_session";
143
+ }
108
144
  if (!contextId) return false;
109
145
 
110
146
  reportClientEvent(
111
147
  { serverUrl: opts.config.serverUrl, apiKey: opts.config.apiKey },
112
148
  { event_type: "identify", session_id: contextId, user_id: userId },
113
149
  );
114
- return true;
150
+ return space;
115
151
  };
@@ -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
@@ -30,8 +30,6 @@ export { LoadingBlock } from "./components/LoadingBlock";
30
30
  export { LoadingScreen } from "./components/LoadingScreen";
31
31
  export { AnimatedSparkle } from "./components/AnimatedSparkle";
32
32
  export { ErrorBlock } from "./components/ErrorBlock";
33
- /** @deprecated No longer used internally — the terminal screen is CompletionView. */
34
- export { DoneBlock } from "./components/DoneBlock";
35
33
  export { CompletionView } from "./components/CompletionView";
36
34
  export { IllustrationProvider, useIllustration } from "./components/Illustration";
37
35
  export type { IllustrationRegistry } from "./components/Illustration";
@@ -59,6 +57,10 @@ export {
59
57
  onboardingComponents,
60
58
  } from "./cards";
61
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";
62
64
 
63
65
  // ─── Icons (semantic vocabulary → optional @expo/vector-icons → nothing) ──────
64
66
  // WIRE_ICON_NAMES is the list to paste into the server/AI prompt as the allowed `icon` values.
@@ -147,7 +149,9 @@ export { detectNativeModel } from "./device/deviceModel";
147
149
 
148
150
  // ─── User identity (opaque pseudonymous id; late binding, dependency-free) ────
149
151
  export { identifyOnboarding, sanitizeUserId, looksLikeEmail, USER_ID_MAX_LENGTH } from "./identity/userIdentity";
150
- export type { IdentifyOnboardingOptions } from "./identity/userIdentity";
152
+ export type { IdentifyOnboardingOptions, IdentifyOnboardingBinding } from "./identity/userIdentity";
153
+ export { resolveIdentity, hostIdentity, resetIdentityProvenance } from "./identity/identityRecord";
154
+ export type { IdentityRecord, IdentitySpace, IdentitySource } from "./identity/identityRecord";
151
155
 
152
156
  // ─── Rich user context (one object → every event's user_context; opt-in email PII) ────
153
157
  export {
@@ -159,7 +163,6 @@ export {
159
163
  clearPiiFromContext,
160
164
  analyticsUserIdStorageKey,
161
165
  activationJoinContext,
162
- RESERVED_USER_CONTEXT_KEYS,
163
166
  EXTRA_KEY_PREFIX,
164
167
  } from "./context/userContext";
165
168
  export type {
@@ -182,6 +185,9 @@ export {
182
185
  // The awaitable sibling: resolves AFTER the persisted id has been read back, for a caller that can
183
186
  // afford one storage read and must not stamp a key minted a millisecond ago (the lifecycle mount).
184
187
  hydrateAutoDeviceKey,
188
+ // The PROVENANCE-carrying form of the same read: it also says whether the id was actually persisted,
189
+ // so a caller writing a cross-launch join key onto the wire can refuse a per-launch one.
190
+ hydrateDeviceIdentity,
185
191
  resetAutoDeviceKeys,
186
192
  } from "./context/deviceId";
187
193
  export type { DeviceKeyStorage, ResolveAutoDeviceKeyOptions } from "./context/deviceId";
@@ -241,6 +247,40 @@ export type {
241
247
  RevenueCatSink,
242
248
  } from "./revenuecat";
243
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
+
244
284
  // ─── Session mapping (one `app.session_started` per app-open → /v1/events) ─────
245
285
  export {
246
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";