@wireai/activation 0.12.2 → 0.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -1
- package/CHANGELOG.md +259 -1
- package/README.md +87 -3
- package/dist/analytics/index.d.mts +2 -2
- package/dist/analytics/index.d.ts +2 -2
- package/dist/analytics/index.js +174 -36
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +174 -37
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{currentSession-BlCeDP0f.d.mts → currentSession-ClkLjcJ0.d.mts} +456 -19
- package/dist/{currentSession-BxEB37xt.d.ts → currentSession-DOVZEWJl.d.ts} +456 -19
- package/dist/index.d.mts +197 -16
- package/dist/index.d.ts +197 -16
- package/dist/index.js +1056 -390
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +836 -193
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.js +20 -7
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +20 -7
- package/dist/reviews/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/OnboardingFlow.tsx +141 -3
- package/src/WireOnboarding.tsx +178 -34
- package/src/activation/wireActivation.ts +13 -7
- package/src/analytics/analyticsEvent.ts +16 -1
- package/src/analytics/analyticsFacade.ts +11 -10
- package/src/analytics/currentSession.ts +6 -20
- package/src/analytics/eventQueue.ts +71 -1
- package/src/analytics/index.ts +1 -1
- package/src/analytics/reportClientEvent.ts +157 -38
- package/src/cards/PermissionCard.tsx +438 -0
- package/src/cards/index.ts +7 -0
- package/src/config/wireConfigFromEnv.ts +1 -10
- package/src/context/deviceId.ts +77 -16
- package/src/context/userContext.ts +4 -15
- package/src/identity/identityRecord.ts +123 -0
- package/src/identity/userIdentity.ts +45 -9
- package/src/illustrations/defaultIllustrations.tsx +44 -3
- package/src/index.ts +44 -4
- package/src/permissions/index.ts +64 -0
- package/src/permissions/permissionCopy.ts +87 -0
- package/src/permissions/permissionEvents.ts +76 -0
- package/src/permissions/permissionMemory.ts +88 -0
- package/src/permissions/placement.ts +88 -0
- package/src/permissions/types.ts +131 -0
- package/src/session/persistedSession.ts +10 -3
- package/src/session-analytics/useLifecycleEvents.ts +10 -1
- package/src/types.ts +77 -1
- package/src/utils/deriveAnswers.ts +6 -2
- package/src/utils/readProgress.ts +4 -0
- package/src/utils/warnInDev.ts +33 -0
- package/src/components/DoneBlock.tsx +0 -37
|
@@ -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
|
-
|
|
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
|
|
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
|
|
@@ -33,6 +33,7 @@ import { AppState, Platform, type AppStateStatus } from "react-native";
|
|
|
33
33
|
import { createEventQueue, type EnvelopeSource, type EventQueue } from "../analytics/eventQueue";
|
|
34
34
|
import { makeSessionId, type ClientEvent, type ClientEventTarget } from "../analytics/reportClientEvent";
|
|
35
35
|
import { hydrateAutoDeviceKey, resolveAutoDeviceKey } from "../context/deviceId";
|
|
36
|
+
import { resolveIdentity } from "../identity/identityRecord";
|
|
36
37
|
import { collectDeviceContext } from "../device/deviceContext";
|
|
37
38
|
import type { WireOnboardingStorage } from "../session/persistedSession";
|
|
38
39
|
import { reportFirstOpen } from "./lifecycle";
|
|
@@ -137,7 +138,15 @@ export const useLifecycleEvents = (
|
|
|
137
138
|
cfg: LifecycleConfig | undefined,
|
|
138
139
|
opts: UseLifecycleEventsOptions,
|
|
139
140
|
): string | undefined => {
|
|
140
|
-
|
|
141
|
+
// A HOST-supplied key is recorded on the process provenance registry, so a `<WireOnboarding>`
|
|
142
|
+
// mount that was not given one can tell "this app owns no device id" from "this app owns one
|
|
143
|
+
// and forgot it there" — the silent third id space (K9). Recording only; nothing reads it here.
|
|
144
|
+
const host = resolveIdentity({
|
|
145
|
+
value: opts.deviceKey,
|
|
146
|
+
space: "device",
|
|
147
|
+
source: "host",
|
|
148
|
+
scope: cfg?.appId,
|
|
149
|
+
})?.value;
|
|
141
150
|
if (host) return host;
|
|
142
151
|
if (!cfg?.storage) return undefined;
|
|
143
152
|
return resolveAutoDeviceKey({ appId: cfg.appId, storage: cfg.storage });
|
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
|
/**
|
|
@@ -272,6 +331,23 @@ export type OnboardingProgress = {
|
|
|
272
331
|
total: number;
|
|
273
332
|
/** Base-question key for the CURRENT screen, when known (used to pick a validator). */
|
|
274
333
|
key?: string;
|
|
334
|
+
/**
|
|
335
|
+
* STABLE per-slot identity for the CURRENT screen, when the backend sends one. Preferred over
|
|
336
|
+
* {@link key} for both the answer key and the validator lookup.
|
|
337
|
+
*
|
|
338
|
+
* WHY IT EXISTS: `key` is authored from the question's prompt text (the tenant flows slugify it and
|
|
339
|
+
* cut at 32 chars), so re-wording a question mints a NEW key — the answer a host reads as
|
|
340
|
+
* `answers.interests` silently becomes `answers.what_are_you_into_v2`, with no error anywhere. A
|
|
341
|
+
* slot is the question's identity independent of its wording.
|
|
342
|
+
*
|
|
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.
|
|
349
|
+
*/
|
|
350
|
+
slot_id?: string;
|
|
275
351
|
/** Whether the CURRENT screen may be skipped (backend-marked; default false → no Skip shown). */
|
|
276
352
|
skippable?: boolean;
|
|
277
353
|
};
|
|
@@ -29,10 +29,14 @@ export const deriveAnswers = (messages: Message[]): Record<string, unknown> => {
|
|
|
29
29
|
if (!m || m.role !== "assistant" || !r || r.action !== "render") continue;
|
|
30
30
|
|
|
31
31
|
const props = (r.props ?? {}) as Record<string, unknown>;
|
|
32
|
-
const progress = props.progress as { key?: string } | undefined;
|
|
32
|
+
const progress = props.progress as { key?: string; slot_id?: string } | undefined;
|
|
33
33
|
const questionText =
|
|
34
34
|
(props.title as string) ?? (props.label as string) ?? (props.question as string);
|
|
35
|
-
|
|
35
|
+
// slot_id > key > question text, decided PER CARD. A thread that mixes slotted and unslotted
|
|
36
|
+
// cards is the real shape during a server rollout, so caching a "this backend does slots" verdict
|
|
37
|
+
// off the first card would mis-key every later one. Against a backend that sends no `slot_id`
|
|
38
|
+
// this is byte-identical to 0.12.2.
|
|
39
|
+
const key = progress?.slot_id || progress?.key || questionText;
|
|
36
40
|
|
|
37
41
|
const reply = messages[i + 1];
|
|
38
42
|
if (key && reply?.role === "user") {
|
|
@@ -15,6 +15,8 @@ type PartialProgress = {
|
|
|
15
15
|
step?: number;
|
|
16
16
|
total?: number;
|
|
17
17
|
key?: string;
|
|
18
|
+
/** The stable per-slot identity, when the backend sends one. See `OnboardingProgress.slot_id`. */
|
|
19
|
+
slot_id?: string;
|
|
18
20
|
skippable?: boolean;
|
|
19
21
|
};
|
|
20
22
|
|
|
@@ -28,6 +30,8 @@ export const readProgress = (response?: WireAIResponse): PartialProgress => {
|
|
|
28
30
|
step: typeof p.step === "number" ? p.step : undefined,
|
|
29
31
|
total: typeof p.total === "number" ? p.total : undefined,
|
|
30
32
|
key: typeof p.key === "string" ? p.key : undefined,
|
|
33
|
+
// Whitelisted the same way as every other field: an old backend simply omits it.
|
|
34
|
+
slot_id: typeof p.slot_id === "string" ? p.slot_id : undefined,
|
|
31
35
|
skippable: typeof p.skippable === "boolean" ? p.skippable : undefined,
|
|
32
36
|
};
|
|
33
37
|
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* warnInDev — the ONE developer-warning primitive.
|
|
3
|
+
*
|
|
4
|
+
* The kit warns a developer in four places (the onboarding host, the env-config helper, the analytics
|
|
5
|
+
* façade, the current-session registry) and until 0.13.0 each carried its own copy of the same five
|
|
6
|
+
* lines. Three were byte-identical; the fourth differed only by returning whether it actually warned.
|
|
7
|
+
* Four copies of a guard is four chances for one of them to drift out of the `__DEV__` gate, which is
|
|
8
|
+
* the failure that matters: a warning that runs in production is a string built for nobody.
|
|
9
|
+
*
|
|
10
|
+
* ON THE TREE-SHAKING NOTE THIS REPLACES: `analytics/currentSession` used to justify its copy as
|
|
11
|
+
* keeping the module import-free for the tree-shaken `./analytics` bundle. That rationale had already
|
|
12
|
+
* lapsed — the module imports `makeSessionId` from `./reportClientEvent` — and this module has no
|
|
13
|
+
* imports of its own, so it adds one leaf to the graph and nothing to the bundle. The `treeShake`
|
|
14
|
+
* canary still holds the real guarantee (the analytics graph reaches no UI module).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** RN sets this global; absent under node/SSR. Read defensively, never assumed. */
|
|
18
|
+
declare const __DEV__: boolean | undefined;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests.
|
|
22
|
+
*
|
|
23
|
+
* Returns whether it ACTUALLY warned, which a caller holding a once-flag must honour: marking
|
|
24
|
+
* "already warned" after a no-op would burn the single warning in production, and the one dev build
|
|
25
|
+
* that needed it would then run silent.
|
|
26
|
+
*/
|
|
27
|
+
export const warnInDev = (message: string): boolean => {
|
|
28
|
+
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
29
|
+
console.warn(message);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
};
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DoneBlock — a brief themed "all set" state shown when the flow completes, while
|
|
3
|
-
* the host persists results and navigates away. Purely cosmetic; the real
|
|
4
|
-
* terminal signal is the SDK's StatusCard (handled in OnboardingFlow).
|
|
5
|
-
*/
|
|
6
|
-
import React from "react";
|
|
7
|
-
import { StyleSheet, Text, View } from "react-native";
|
|
8
|
-
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
9
|
-
import { bodyStyle, headingStyle } from "../theme/typography";
|
|
10
|
-
|
|
11
|
-
export type DoneBlockProps = {
|
|
12
|
-
title?: string;
|
|
13
|
-
message?: string;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
const _DoneBlock: React.FC<DoneBlockProps> = ({
|
|
17
|
-
title = "You're all set",
|
|
18
|
-
message = "Personalizing your experience…",
|
|
19
|
-
}) => {
|
|
20
|
-
const t = useOnboardingTheme();
|
|
21
|
-
return (
|
|
22
|
-
<View style={[styles.center, { gap: t.spacing.sm, padding: t.spacing.lg }]}>
|
|
23
|
-
<Text style={[headingStyle(t.fonts), { color: t.colors.success, textAlign: "center" }]}>
|
|
24
|
-
{title}
|
|
25
|
-
</Text>
|
|
26
|
-
<Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted, textAlign: "center" }]}>
|
|
27
|
-
{message}
|
|
28
|
-
</Text>
|
|
29
|
-
</View>
|
|
30
|
-
);
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export const DoneBlock = React.memo(_DoneBlock);
|
|
34
|
-
|
|
35
|
-
const styles = StyleSheet.create({
|
|
36
|
-
center: { flex: 1, alignItems: "center", justifyContent: "center" },
|
|
37
|
-
});
|