@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,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PermissionCard - the priming screen that stands in front of an OS permission dialog.
|
|
3
|
+
*
|
|
4
|
+
* THE PRIMING PATTERN, which is the entire point: the OS dialog opens from the primary press
|
|
5
|
+
* handler and from nowhere else. There is no mount effect, no auto-fire, no timer, and no code path
|
|
6
|
+
* from render to `request()`. iOS grants an app exactly ONE native notification prompt for its
|
|
7
|
+
* whole lifetime, so this cheap in-app screen spends first and only forwards the users who said
|
|
8
|
+
* yes. The secondary ("Maybe later") advances the flow with the prompt still unspent.
|
|
9
|
+
*
|
|
10
|
+
* THREE PRIMARY ACTIONS, picked from the non-prompting `getStatus` probe (never from `request`):
|
|
11
|
+
* • ask (the default, and the only branch that can reach `request`)
|
|
12
|
+
* • settings (status `blocked`: the OS would show nothing, so the only route left is Settings)
|
|
13
|
+
* • continue (already granted, or a host that supplied no `request` at all - the kit declines to
|
|
14
|
+
* fabricate a prompt it has no way to open)
|
|
15
|
+
*
|
|
16
|
+
* IT IS NOT A QUESTION. It sends nothing to the backend, appends nothing to the thread, and mints
|
|
17
|
+
* no `key` / `slot_id`, so `deriveAnswers` and every completion semantic are untouched. Every
|
|
18
|
+
* outcome, including a denial, continues the flow.
|
|
19
|
+
*
|
|
20
|
+
* Motion: the same register as the other value beats. The illustration springs in
|
|
21
|
+
* (STATUS_POP_SPRING, the StatusCard glyph pop) and the copy rises behind it on the interstitial
|
|
22
|
+
* head stagger. Reduce motion: final frame at once, like every other card.
|
|
23
|
+
*
|
|
24
|
+
* Registered as a `WireAIComponent` so a later server-emitted placement (AI-chosen timing) renders
|
|
25
|
+
* through the same component with no rewrite. It is deliberately NOT in `onboardingComponents`:
|
|
26
|
+
* that array is what the device ADVERTISES as renderable, and a backend told it may emit this card
|
|
27
|
+
* could emit one for a host that wired no `request`.
|
|
28
|
+
*/
|
|
29
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
30
|
+
import { Animated, Easing, StyleSheet, Text, View } from "react-native";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
import type { InjectedProps, WireAIComponent } from "wireai-rn";
|
|
33
|
+
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
34
|
+
import { bodyStyle, headingStyle } from "../theme/typography";
|
|
35
|
+
import { useIllustration } from "../components/Illustration";
|
|
36
|
+
import { Button } from "../components/Button";
|
|
37
|
+
import { CardLayout } from "../components/CardLayout";
|
|
38
|
+
import {
|
|
39
|
+
INTERSTITIAL_HEAD_MS,
|
|
40
|
+
INTERSTITIAL_HEAD_STAGGER_MS,
|
|
41
|
+
STATUS_POP_SCALE_FROM,
|
|
42
|
+
STATUS_POP_SPRING,
|
|
43
|
+
WIRE_BEZIER,
|
|
44
|
+
scaledMs,
|
|
45
|
+
} from "../motion/motionSpec";
|
|
46
|
+
import { useReducedMotion } from "../motion/useReducedMotion";
|
|
47
|
+
import { normalizePermissionStatus } from "../permissions/permissionEvents";
|
|
48
|
+
import type {
|
|
49
|
+
PermissionStage,
|
|
50
|
+
WirePermissionOutcome,
|
|
51
|
+
WirePermissionStatus,
|
|
52
|
+
} from "../permissions/types";
|
|
53
|
+
import { playHaptic } from "../haptics/haptics";
|
|
54
|
+
import { warnInDev } from "../utils/warnInDev";
|
|
55
|
+
|
|
56
|
+
const easeWire = Easing.bezier(...WIRE_BEZIER);
|
|
57
|
+
const RISE_PX = 10;
|
|
58
|
+
|
|
59
|
+
/** The name the card is registered under, and the `component` stamped on its events. */
|
|
60
|
+
export const PERMISSION_CARD_NAME = "PermissionCard";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* How long a host's `request` may stay outstanding before the kit hands the controls back.
|
|
64
|
+
*
|
|
65
|
+
* DELIBERATELY GENEROUS. This is not a race against the user: someone reading an OS permission
|
|
66
|
+
* dialog, switching apps mid-prompt, or hitting a slow native bridge is doing nothing wrong, and a
|
|
67
|
+
* short ceiling would advance the flow underneath a dialog that is still open. It exists for one
|
|
68
|
+
* failure only, a `request` that never settles at all (a swallowed native callback, a promise
|
|
69
|
+
* nobody resolves), which would otherwise leave the user on a screen whose buttons are all
|
|
70
|
+
* disabled. On expiry the kit records NO outcome, because a pending request is not a denial.
|
|
71
|
+
*/
|
|
72
|
+
export const REQUEST_WATCHDOG_MS = 90_000;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The COPY half of the card, i.e. everything a server could legitimately author. The capability
|
|
76
|
+
* half (`request` / `getStatus` / `openSettings`) is host-injected and deliberately absent here:
|
|
77
|
+
* a schema field can only ever carry data, never a function, and the kit imports no native module.
|
|
78
|
+
*/
|
|
79
|
+
const schema = z.object({
|
|
80
|
+
permission: z
|
|
81
|
+
.string()
|
|
82
|
+
.describe("Which OS permission this screen primes, e.g. 'notifications'"),
|
|
83
|
+
title: z.string().describe("Headline for the ask"),
|
|
84
|
+
message: z.string().describe("Why the app needs it, in the user's terms"),
|
|
85
|
+
primaryLabel: z.string().describe("Primary button, the only control that can open the OS dialog"),
|
|
86
|
+
secondaryLabel: z.string().describe("Secondary button, advances without spending the OS prompt"),
|
|
87
|
+
blockedTitle: z.string().optional().describe("Headline once the permission is permanently refused"),
|
|
88
|
+
blockedMessage: z.string().optional().describe("Rationale for the settings route"),
|
|
89
|
+
settingsLabel: z.string().optional().describe("Primary button label on the blocked route"),
|
|
90
|
+
continueLabel: z.string().optional().describe("Primary button label when there is nothing to ask"),
|
|
91
|
+
illustration: z
|
|
92
|
+
.string()
|
|
93
|
+
.optional()
|
|
94
|
+
.describe("Name of an app-provided illustration (defaults to the permission name)"),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
export type PermissionCardProps = z.infer<typeof schema> &
|
|
98
|
+
Partial<InjectedProps> & {
|
|
99
|
+
/** THE ONLY function that can open an OS dialog. Called from the primary handler alone. */
|
|
100
|
+
request?: () => Promise<WirePermissionStatus>;
|
|
101
|
+
/** NON-PROMPTING status read. Decides which primary action is offered, nothing else. */
|
|
102
|
+
getStatus?: () => Promise<WirePermissionStatus>;
|
|
103
|
+
/** Open the OS settings page. Only reachable on the `blocked` route. */
|
|
104
|
+
openSettings?: () => void | Promise<void>;
|
|
105
|
+
/** Reports each moment for analytics. Never control flow. */
|
|
106
|
+
onStage?: (stage: PermissionStage, status?: WirePermissionStatus) => void;
|
|
107
|
+
/** Fires exactly once, with the outcome this screen produced. The flow advances on it. */
|
|
108
|
+
onSettled?: (outcome: WirePermissionOutcome) => void;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Which action the primary button performs. `ask` is the only one that can reach `request`. */
|
|
112
|
+
type PrimaryAction = "ask" | "settings" | "continue";
|
|
113
|
+
|
|
114
|
+
const _PermissionCard: React.FC<PermissionCardProps> = ({
|
|
115
|
+
permission,
|
|
116
|
+
title,
|
|
117
|
+
message,
|
|
118
|
+
primaryLabel,
|
|
119
|
+
secondaryLabel,
|
|
120
|
+
blockedTitle,
|
|
121
|
+
blockedMessage,
|
|
122
|
+
settingsLabel,
|
|
123
|
+
continueLabel,
|
|
124
|
+
illustration,
|
|
125
|
+
request,
|
|
126
|
+
getStatus,
|
|
127
|
+
openSettings,
|
|
128
|
+
onStage,
|
|
129
|
+
onSettled,
|
|
130
|
+
}) => {
|
|
131
|
+
const t = useOnboardingTheme();
|
|
132
|
+
const reduced = useReducedMotion();
|
|
133
|
+
const art = useIllustration(illustration ?? permission);
|
|
134
|
+
// The probed status. `undefined` means "not known", which is the ASK state: the kit never
|
|
135
|
+
// assumes a grant it has not been told about.
|
|
136
|
+
const [status, setStatus] = useState<WirePermissionStatus | undefined>(undefined);
|
|
137
|
+
const [busy, setBusy] = useState(false);
|
|
138
|
+
// One settle per mount. The card is keyed by screen id, so this is one settle per screen. It is
|
|
139
|
+
// also what makes a late `request` settlement a no-op, since `finish` reaches `settle`
|
|
140
|
+
// synchronously (see `handlePrimary`).
|
|
141
|
+
const settledRef = useRef(false);
|
|
142
|
+
// `accepted` is a per-screen boolean, so `accepted / shown` stays a readable rate.
|
|
143
|
+
const acceptedRef = useRef(false);
|
|
144
|
+
const watchdogRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
145
|
+
const clearWatchdog = useCallback(() => {
|
|
146
|
+
if (watchdogRef.current) {
|
|
147
|
+
clearTimeout(watchdogRef.current);
|
|
148
|
+
watchdogRef.current = null;
|
|
149
|
+
}
|
|
150
|
+
}, []);
|
|
151
|
+
// A pending watchdog must never outlive the screen (it would setState on an unmounted card).
|
|
152
|
+
useEffect(() => clearWatchdog, [clearWatchdog]);
|
|
153
|
+
|
|
154
|
+
// Host callbacks held in refs so the mount-once effects below stay mount-once no matter how a
|
|
155
|
+
// host passes them (the documented usage is an INLINE `permissionScreens={[...]}`, which mints a
|
|
156
|
+
// fresh closure for every one of them on every render).
|
|
157
|
+
const onStageRef = useRef(onStage);
|
|
158
|
+
onStageRef.current = onStage;
|
|
159
|
+
const onSettledRef = useRef(onSettled);
|
|
160
|
+
onSettledRef.current = onSettled;
|
|
161
|
+
const getStatusRef = useRef(getStatus);
|
|
162
|
+
getStatusRef.current = getStatus;
|
|
163
|
+
|
|
164
|
+
// `shown` fires once per mount, ref-guarded so StrictMode's dev double-invoke cannot
|
|
165
|
+
// double-count the denominator every rate in this funnel is measured against.
|
|
166
|
+
const shownRef = useRef(false);
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (shownRef.current) return;
|
|
169
|
+
shownRef.current = true;
|
|
170
|
+
onStageRef.current?.("shown");
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
// The status probe, MOUNT-ONCE. `getPermissionsAsync()` and its equivalents READ, they never
|
|
174
|
+
// prompt - which is exactly why `request` is not called here and why this effect may exist at
|
|
175
|
+
// all. All it decides is which primary action the screen offers.
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
const probe = getStatusRef.current;
|
|
178
|
+
if (!probe) return;
|
|
179
|
+
let cancelled = false;
|
|
180
|
+
try {
|
|
181
|
+
void Promise.resolve(probe())
|
|
182
|
+
.then((value) => {
|
|
183
|
+
if (!cancelled) setStatus(normalizePermissionStatus(value));
|
|
184
|
+
})
|
|
185
|
+
.catch(() => {
|
|
186
|
+
// A host probe that throws just leaves the screen in its ask state.
|
|
187
|
+
});
|
|
188
|
+
} catch {
|
|
189
|
+
// A synchronously-throwing probe, same treatment.
|
|
190
|
+
}
|
|
191
|
+
return () => {
|
|
192
|
+
cancelled = true;
|
|
193
|
+
};
|
|
194
|
+
}, []);
|
|
195
|
+
|
|
196
|
+
// A host that configured a screen with no `request` gets a screen that cannot ask. The kit says
|
|
197
|
+
// so instead of rendering a button that silently does nothing (dev only, never a throw).
|
|
198
|
+
const requestRef = useRef(request);
|
|
199
|
+
requestRef.current = request;
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
if (requestRef.current) return;
|
|
202
|
+
warnInDev(
|
|
203
|
+
`[wireai] <WireOnboarding> got a permission screen for "${permission}" with no \`request\` ` +
|
|
204
|
+
"function, so it cannot open the OS dialog and renders as a plain continue. Pass " +
|
|
205
|
+
"request: () => Promise<'granted' | 'denied' | 'blocked'> (5 lines around " +
|
|
206
|
+
"expo-notifications, see the README).",
|
|
207
|
+
);
|
|
208
|
+
}, [permission]);
|
|
209
|
+
|
|
210
|
+
const settle = useCallback((outcome: WirePermissionOutcome) => {
|
|
211
|
+
if (settledRef.current) return;
|
|
212
|
+
settledRef.current = true;
|
|
213
|
+
clearWatchdog();
|
|
214
|
+
onSettledRef.current?.(outcome);
|
|
215
|
+
}, [clearWatchdog]);
|
|
216
|
+
|
|
217
|
+
const primaryAction: PrimaryAction =
|
|
218
|
+
status === "blocked" ? "settings" : status === "granted" || !request ? "continue" : "ask";
|
|
219
|
+
|
|
220
|
+
const handlePrimary = useCallback(() => {
|
|
221
|
+
if (settledRef.current || busy) return;
|
|
222
|
+
|
|
223
|
+
if (primaryAction === "settings") {
|
|
224
|
+
onStageRef.current?.("settings", "blocked");
|
|
225
|
+
try {
|
|
226
|
+
void Promise.resolve(openSettings?.()).catch(() => {});
|
|
227
|
+
} catch {
|
|
228
|
+
// A settings redirect that fails is not a reason to trap the user on this screen.
|
|
229
|
+
}
|
|
230
|
+
settle("blocked");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (primaryAction === "continue") {
|
|
235
|
+
// Already granted (nothing to ask), or no `request` was supplied. Report the state as it is;
|
|
236
|
+
// never report a grant this screen did not produce as anything other than what it is.
|
|
237
|
+
if (status === "granted") {
|
|
238
|
+
onStageRef.current?.("granted", "granted");
|
|
239
|
+
settle("granted");
|
|
240
|
+
} else {
|
|
241
|
+
onStageRef.current?.("skipped");
|
|
242
|
+
settle("skipped");
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// THE ONE PATH TO THE OS DIALOG, reachable from this press handler only.
|
|
248
|
+
//
|
|
249
|
+
// `accepted` is a per-screen boolean, not a per-tap counter: the screen is shown once, and
|
|
250
|
+
// `accepted / shown` is the rate that says whether the rationale copy works. A second tap after
|
|
251
|
+
// the watchdog re-armed the UI would push that rate past 100%, so it is emitted once.
|
|
252
|
+
if (!acceptedRef.current) {
|
|
253
|
+
acceptedRef.current = true;
|
|
254
|
+
onStageRef.current?.("accepted");
|
|
255
|
+
}
|
|
256
|
+
setBusy(true);
|
|
257
|
+
// FIRST SETTLEMENT WINS, and `settledRef` is the whole mechanism. The watchdog below can hand
|
|
258
|
+
// the UI back while a `request` is still outstanding, so a second tap can put a SECOND one in
|
|
259
|
+
// flight, and the original can answer late. What decides between them is that this function
|
|
260
|
+
// runs STRAIGHT THROUGH to `settle` with no await in between: the first settlement to arrive
|
|
261
|
+
// has already flipped that ref by the time any later one is invoked, so the later one returns
|
|
262
|
+
// on the line below. The flow advances once and each stage event is emitted once. The same ref
|
|
263
|
+
// is why a settlement landing after the user skipped changes nothing.
|
|
264
|
+
const finish = (value: unknown) => {
|
|
265
|
+
if (settledRef.current) return;
|
|
266
|
+
clearWatchdog();
|
|
267
|
+
const resolved = normalizePermissionStatus(value);
|
|
268
|
+
setBusy(false);
|
|
269
|
+
setStatus(resolved);
|
|
270
|
+
if (resolved === "granted") {
|
|
271
|
+
// A light success tap on the grant, through the kit's optional-peer haptics: a host
|
|
272
|
+
// without `expo-haptics` simply feels nothing and nothing throws.
|
|
273
|
+
playHaptic("success");
|
|
274
|
+
onStageRef.current?.("granted", "granted");
|
|
275
|
+
} else {
|
|
276
|
+
onStageRef.current?.("denied", resolved);
|
|
277
|
+
}
|
|
278
|
+
settle(resolved);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
// THE WATCHDOG, and what it deliberately does NOT do.
|
|
282
|
+
//
|
|
283
|
+
// A host `request` that never settles (a native module that swallows its callback, a promise
|
|
284
|
+
// that is never resolved) would otherwise BRICK the screen: `busy` disables both buttons, the
|
|
285
|
+
// secondary re-guards on it, and nothing upstream rescues a rendered card. Dead end, and
|
|
286
|
+
// `onComplete` never fires.
|
|
287
|
+
//
|
|
288
|
+
// So the ceiling exists to un-brick a HOST BUG, never to race the user. A person can sit on an
|
|
289
|
+
// OS permission dialog for a long time, so a short ceiling that recorded `denied` on expiry
|
|
290
|
+
// would advance the flow underneath a dialog that is still open and log an outcome the user
|
|
291
|
+
// never gave. On expiry this therefore fabricates NOTHING: no outcome, no event, no settle. It
|
|
292
|
+
// only hands the controls back so the user can tap again or skip, and says so in dev.
|
|
293
|
+
clearWatchdog();
|
|
294
|
+
watchdogRef.current = setTimeout(() => {
|
|
295
|
+
watchdogRef.current = null;
|
|
296
|
+
if (settledRef.current) return;
|
|
297
|
+
setBusy(false);
|
|
298
|
+
warnInDev(
|
|
299
|
+
`[wireai] the \`request\` for the "${permission}" permission screen has not settled after ` +
|
|
300
|
+
`${Math.round(REQUEST_WATCHDOG_MS / 1000)}s, so the kit handed the controls back rather ` +
|
|
301
|
+
"than leaving the user on a dead-end screen. It recorded NO outcome, because a pending " +
|
|
302
|
+
"request is not a denial. Make sure your request resolves to 'granted' | 'denied' | " +
|
|
303
|
+
"'blocked' on every branch, including the one where the user dismisses the OS dialog.",
|
|
304
|
+
);
|
|
305
|
+
}, REQUEST_WATCHDOG_MS);
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
void Promise.resolve(request?.()).then(finish, () => finish("denied"));
|
|
309
|
+
} catch {
|
|
310
|
+
// A synchronously-throwing request is a denial, never a stuck screen.
|
|
311
|
+
finish("denied");
|
|
312
|
+
}
|
|
313
|
+
}, [busy, primaryAction, status, request, openSettings, settle, clearWatchdog, permission]);
|
|
314
|
+
|
|
315
|
+
const handleSecondary = useCallback(() => {
|
|
316
|
+
if (settledRef.current || busy) return;
|
|
317
|
+
onStageRef.current?.("skipped");
|
|
318
|
+
settle("skipped");
|
|
319
|
+
}, [busy, settle]);
|
|
320
|
+
|
|
321
|
+
// Art springs in, copy rises behind it (final frame at once under reduce motion).
|
|
322
|
+
const popT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
|
|
323
|
+
const titleT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
|
|
324
|
+
const bodyT = useRef(new Animated.Value(reduced ? 1 : 0)).current;
|
|
325
|
+
useEffect(() => {
|
|
326
|
+
if (reduced) {
|
|
327
|
+
popT.setValue(1);
|
|
328
|
+
titleT.setValue(1);
|
|
329
|
+
bodyT.setValue(1);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const rise = (value: Animated.Value, delay: number) =>
|
|
333
|
+
Animated.timing(value, {
|
|
334
|
+
toValue: 1,
|
|
335
|
+
duration: scaledMs(INTERSTITIAL_HEAD_MS),
|
|
336
|
+
delay,
|
|
337
|
+
easing: easeWire,
|
|
338
|
+
useNativeDriver: true,
|
|
339
|
+
});
|
|
340
|
+
const anims = [
|
|
341
|
+
Animated.spring(popT, {
|
|
342
|
+
toValue: 1,
|
|
343
|
+
friction: STATUS_POP_SPRING.friction,
|
|
344
|
+
tension: STATUS_POP_SPRING.tension,
|
|
345
|
+
useNativeDriver: true,
|
|
346
|
+
}),
|
|
347
|
+
rise(titleT, scaledMs(INTERSTITIAL_HEAD_STAGGER_MS)),
|
|
348
|
+
rise(bodyT, scaledMs(INTERSTITIAL_HEAD_STAGGER_MS * 2)),
|
|
349
|
+
];
|
|
350
|
+
anims.forEach((a) => a.start());
|
|
351
|
+
return () => anims.forEach((a) => a.stop());
|
|
352
|
+
}, [reduced, popT, titleT, bodyT]);
|
|
353
|
+
|
|
354
|
+
const popScale = popT.interpolate({
|
|
355
|
+
inputRange: [0, 1],
|
|
356
|
+
outputRange: [STATUS_POP_SCALE_FROM, 1],
|
|
357
|
+
});
|
|
358
|
+
const riseStyle = (value: Animated.Value) => ({
|
|
359
|
+
opacity: value,
|
|
360
|
+
transform: [
|
|
361
|
+
{ translateY: value.interpolate({ inputRange: [0, 1], outputRange: [RISE_PX, 0] }) },
|
|
362
|
+
],
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
const blocked = status === "blocked";
|
|
366
|
+
const shownTitle = blocked ? (blockedTitle ?? title) : title;
|
|
367
|
+
const shownMessage = blocked ? (blockedMessage ?? message) : message;
|
|
368
|
+
const primaryTitle =
|
|
369
|
+
primaryAction === "settings"
|
|
370
|
+
? (settingsLabel ?? primaryLabel)
|
|
371
|
+
: primaryAction === "continue"
|
|
372
|
+
? (continueLabel ?? primaryLabel)
|
|
373
|
+
: primaryLabel;
|
|
374
|
+
// Nothing left to decline once the permission is already granted.
|
|
375
|
+
const showSecondary = status !== "granted";
|
|
376
|
+
|
|
377
|
+
return (
|
|
378
|
+
<CardLayout
|
|
379
|
+
align="center"
|
|
380
|
+
footer={
|
|
381
|
+
<View style={[styles.footer, { gap: t.spacing.sm }]}>
|
|
382
|
+
<Button title={primaryTitle} onPress={handlePrimary} variant="primary" disabled={busy} />
|
|
383
|
+
{showSecondary ? (
|
|
384
|
+
<Button
|
|
385
|
+
title={secondaryLabel}
|
|
386
|
+
onPress={handleSecondary}
|
|
387
|
+
variant="outline"
|
|
388
|
+
disabled={busy}
|
|
389
|
+
/>
|
|
390
|
+
) : null}
|
|
391
|
+
</View>
|
|
392
|
+
}
|
|
393
|
+
>
|
|
394
|
+
<View style={[styles.center, { gap: t.spacing.md }]}>
|
|
395
|
+
{art ? (
|
|
396
|
+
<Animated.View
|
|
397
|
+
style={[styles.art, { opacity: popT, transform: [{ scale: popScale }] }]}
|
|
398
|
+
>
|
|
399
|
+
{art}
|
|
400
|
+
</Animated.View>
|
|
401
|
+
) : null}
|
|
402
|
+
|
|
403
|
+
<Animated.View style={riseStyle(titleT)}>
|
|
404
|
+
<Text style={[headingStyle(t.fonts), { color: t.colors.text, textAlign: "center" }]}>
|
|
405
|
+
{shownTitle}
|
|
406
|
+
</Text>
|
|
407
|
+
</Animated.View>
|
|
408
|
+
|
|
409
|
+
<Animated.View style={riseStyle(bodyT)}>
|
|
410
|
+
<Text style={[bodyStyle(t.fonts), { color: t.colors.textMuted, textAlign: "center" }]}>
|
|
411
|
+
{shownMessage}
|
|
412
|
+
</Text>
|
|
413
|
+
</Animated.View>
|
|
414
|
+
</View>
|
|
415
|
+
</CardLayout>
|
|
416
|
+
);
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/** The typed component the flow renders directly (host-injected placement). */
|
|
420
|
+
export const PermissionCardView = React.memo(_PermissionCard);
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* The SDK registration object, so a server-emitted placement can adopt this exact screen later.
|
|
424
|
+
* Not part of `onboardingComponents` on purpose - see the file header.
|
|
425
|
+
*/
|
|
426
|
+
export const PermissionCard: WireAIComponent = {
|
|
427
|
+
name: PERMISSION_CARD_NAME,
|
|
428
|
+
description:
|
|
429
|
+
"A priming screen shown BEFORE an OS permission dialog: it explains why the app wants the permission and asks only on the primary tap. Never first, never last. The user is never blocked by it: the secondary advances the flow with the OS prompt unspent.",
|
|
430
|
+
component: PermissionCardView as WireAIComponent["component"],
|
|
431
|
+
propsSchema: schema,
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const styles = StyleSheet.create({
|
|
435
|
+
center: { width: "100%", alignItems: "center", justifyContent: "center" },
|
|
436
|
+
art: { alignItems: "center", justifyContent: "center" },
|
|
437
|
+
footer: { width: "100%", alignItems: "center" },
|
|
438
|
+
});
|
package/src/cards/index.ts
CHANGED
|
@@ -21,9 +21,16 @@ export {
|
|
|
21
21
|
NumberStepperCard,
|
|
22
22
|
InterstitialCard,
|
|
23
23
|
};
|
|
24
|
+
export { PermissionCard, PermissionCardView, PERMISSION_CARD_NAME } from "./PermissionCard";
|
|
25
|
+
export type { PermissionCardProps } from "./PermissionCard";
|
|
24
26
|
export { normalizeOptions, optionsField, optionObjectSchema } from "./optionSchema";
|
|
25
27
|
export type { CardOption } from "./optionSchema";
|
|
26
28
|
|
|
29
|
+
// ⚠️ `PermissionCard` is deliberately NOT in this array. The list is what the device ADVERTISES to
|
|
30
|
+
// the backend as renderable (`metadata.supportedComponents`), and a backend told it may emit a
|
|
31
|
+
// permission screen could emit one into a host that injected no `request` function, which the kit
|
|
32
|
+
// has no way to honour. Permission screens are host-declared for now (`permissionScreens`); the
|
|
33
|
+
// card is registered and ready for the day a server-emitted placement lands.
|
|
27
34
|
export const onboardingComponents: WireAIComponent[] = [
|
|
28
35
|
ChipSelectCard,
|
|
29
36
|
TextInputCard,
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { detectAppVersion } from "../device/appVersion";
|
|
19
19
|
import type { WireOnboardingConfig } from "../types";
|
|
20
|
+
import { warnInDev } from "../utils/warnInDev";
|
|
20
21
|
|
|
21
22
|
export type WireConfigOverrides = Partial<WireOnboardingConfig>;
|
|
22
23
|
|
|
@@ -27,16 +28,6 @@ export const WIRE_ENV_VARS = [
|
|
|
27
28
|
"EXPO_PUBLIC_WIREAI_APP_ID",
|
|
28
29
|
] as const;
|
|
29
30
|
|
|
30
|
-
/** RN sets this global; absent under node/SSR. Read defensively via {@link warnInDev}. */
|
|
31
|
-
declare const __DEV__: boolean | undefined;
|
|
32
|
-
|
|
33
|
-
/** Emit a one-line developer warning, but ONLY in a dev build (RN `__DEV__`). No-op in prod/tests. */
|
|
34
|
-
const warnInDev = (message: string): void => {
|
|
35
|
-
if (typeof __DEV__ !== "undefined" && __DEV__ && typeof console !== "undefined" && console.warn) {
|
|
36
|
-
console.warn(message);
|
|
37
|
-
}
|
|
38
|
-
};
|
|
39
|
-
|
|
40
31
|
/**
|
|
41
32
|
* Minimal `process.env` declaration so the kit stays RN-pure (no `@types/node`).
|
|
42
33
|
* In RN/Expo, `process.env.EXPO_PUBLIC_*` is provided/inlined by Metro at build
|
package/src/context/deviceId.ts
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* that verbatim and never mints/persists an auto id.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { resolveIdentity, type IdentityRecord } from "../identity/identityRecord";
|
|
22
|
+
|
|
21
23
|
/** Prefix so an auto-minted id is visibly the kit's (distinguishable from a host-supplied `deviceKey`). */
|
|
22
24
|
export const AUTO_DEVICE_ID_PREFIX = "wdev_";
|
|
23
25
|
|
|
@@ -69,12 +71,21 @@ export const mintDeviceId = (): string => {
|
|
|
69
71
|
/** Well-known key into the runtime-global symbol registry — one auto-id registry across every bundle. */
|
|
70
72
|
const AUTO_DEVICE_KEY_SLOT: unique symbol = Symbol.for("@wireai/activation:autoDeviceKeys");
|
|
71
73
|
|
|
74
|
+
/** What a hydration settled on: the id, and whether persistence actually CONFIRMED it.
|
|
75
|
+
*
|
|
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
|
|
78
|
+
* before 0.13.0 a caller could not tell a persisted id from a per-launch mint, and the auto-join
|
|
79
|
+
* gate (`Boolean(storage)`) was reading the PRESENCE of the prop rather than the SUCCESS of the
|
|
80
|
+
* write. See {@link hydrateDeviceIdentity}. */
|
|
81
|
+
type HydrationOutcome = { value: string; durable: boolean };
|
|
82
|
+
|
|
72
83
|
/** The shared registry: the live id per `appId`, the set of appIds whose hydration already started,
|
|
73
84
|
* and the in-flight (or settled) hydration promise per `appId` so a waiter can join it. */
|
|
74
85
|
type AutoDeviceKeyRegistry = {
|
|
75
86
|
keys: Map<string, string>;
|
|
76
87
|
hydrating: Set<string>;
|
|
77
|
-
pending?: Map<string, Promise<
|
|
88
|
+
pending?: Map<string, Promise<HydrationOutcome>>;
|
|
78
89
|
};
|
|
79
90
|
|
|
80
91
|
type GlobalWithDeviceKeys = typeof globalThis & {
|
|
@@ -108,40 +119,65 @@ export interface ResolveAutoDeviceKeyOptions {
|
|
|
108
119
|
}
|
|
109
120
|
|
|
110
121
|
/**
|
|
111
|
-
* Start (or join) the SINGLE-FLIGHT storage read for `appId` and resolve to the
|
|
112
|
-
* The promise is parked on the registry so a later `hydrateAutoDeviceKey` awaits the
|
|
113
|
-
* instead of starting a second one. Never rejects: any storage failure resolves to the live
|
|
122
|
+
* Start (or join) the SINGLE-FLIGHT storage read for `appId` and resolve to the {@link HydrationOutcome}
|
|
123
|
+
* it settles on. The promise is parked on the registry so a later `hydrateAutoDeviceKey` awaits the
|
|
124
|
+
* SAME read instead of starting a second one. Never rejects: any storage failure resolves to the live
|
|
125
|
+
* id with `durable: false`.
|
|
126
|
+
*
|
|
127
|
+
* TWO OUTCOMES, NOT ONE STRING:
|
|
128
|
+
* • ADOPTED (`durable: true`) — a persisted id was read back, or the freshly minted one was
|
|
129
|
+
* written successfully. The next launch will see the same id.
|
|
130
|
+
* • DEGRADED (`durable: false`) — the adapter rejected, threw, or returned nothing and then failed
|
|
131
|
+
* the write. The id is real but PROCESS-scoped, so anything that
|
|
132
|
+
* counts a device across launches must refuse it.
|
|
133
|
+
*
|
|
134
|
+
* A DEGRADED outcome also drops the registry latches so the NEXT caller starts a fresh read (K8). A
|
|
135
|
+
* cold-boot storage lock is transient; caching it as a verdict for the process lifetime turned a
|
|
136
|
+
* one-second problem into a whole-launch one, and nothing ever retried.
|
|
114
137
|
*/
|
|
115
138
|
const startHydration = (
|
|
116
139
|
registry: AutoDeviceKeyRegistry,
|
|
117
140
|
appId: string,
|
|
118
141
|
storage: DeviceKeyStorage,
|
|
119
142
|
minted: string,
|
|
120
|
-
): Promise<
|
|
143
|
+
): Promise<HydrationOutcome> => {
|
|
121
144
|
if (!registry.pending) registry.pending = new Map();
|
|
122
145
|
const existing = registry.pending.get(appId);
|
|
123
146
|
if (existing) return existing;
|
|
124
147
|
|
|
125
148
|
const slot = deviceIdStorageKey(appId);
|
|
126
|
-
const
|
|
127
|
-
|
|
149
|
+
const degraded = (): HydrationOutcome => ({ value: registry.keys.get(appId) ?? minted, durable: false });
|
|
150
|
+
const adopted = (value: string): HydrationOutcome => ({ value, durable: true });
|
|
151
|
+
let run: Promise<HydrationOutcome>;
|
|
128
152
|
try {
|
|
129
153
|
run = Promise.resolve(storage.getItem(slot))
|
|
130
154
|
.then((saved) => {
|
|
131
155
|
const persisted = typeof saved === "string" && saved.trim() ? saved.trim() : undefined;
|
|
132
156
|
if (persisted) {
|
|
133
157
|
registry.keys.set(appId, persisted);
|
|
134
|
-
return persisted;
|
|
158
|
+
return adopted(persisted);
|
|
135
159
|
}
|
|
136
160
|
// First run on this install: persist the id we just minted so the next launch adopts it.
|
|
137
|
-
|
|
161
|
+
// ONLY a resolved write earns `durable` — a rejected one leaves the id in memory alone.
|
|
162
|
+
return Promise.resolve(storage.setItem(slot, minted)).then(
|
|
163
|
+
() => adopted(registry.keys.get(appId) ?? minted),
|
|
164
|
+
degraded,
|
|
165
|
+
);
|
|
138
166
|
})
|
|
139
|
-
.catch(
|
|
167
|
+
.catch(degraded);
|
|
140
168
|
} catch {
|
|
141
169
|
// A storage adapter that throws synchronously — degrade to the in-memory id.
|
|
142
|
-
run = Promise.resolve(
|
|
170
|
+
run = Promise.resolve(degraded());
|
|
143
171
|
}
|
|
144
172
|
registry.pending.set(appId, run);
|
|
173
|
+
// Retry-on-failure (K8): release the latches once a degraded outcome settles, so a later caller
|
|
174
|
+
// is not permanently bound to one bad read. Registered AFTER the `set` above, so the clean-up can
|
|
175
|
+
// never race ahead of the entry it is clearing.
|
|
176
|
+
void run.then((outcome) => {
|
|
177
|
+
if (outcome.durable) return;
|
|
178
|
+
registry.pending?.delete(appId);
|
|
179
|
+
registry.hydrating.delete(appId);
|
|
180
|
+
});
|
|
145
181
|
return run;
|
|
146
182
|
};
|
|
147
183
|
|
|
@@ -196,15 +232,40 @@ export const resolveAutoDeviceKey = (opts: ResolveAutoDeviceKeyOptions = {}): st
|
|
|
196
232
|
*/
|
|
197
233
|
export const hydrateAutoDeviceKey = async (
|
|
198
234
|
opts: ResolveAutoDeviceKeyOptions = {},
|
|
199
|
-
): Promise<string> =>
|
|
235
|
+
): Promise<string> => (await hydrateDeviceIdentity(opts))?.value ?? resolveAutoDeviceKey(opts);
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The PROVENANCE-CARRYING sibling of {@link hydrateAutoDeviceKey}: the same awaited read, but it
|
|
239
|
+
* answers "is this id one this install will KEEP?" instead of only "what is the id?".
|
|
240
|
+
*
|
|
241
|
+
* WHY IT EXISTS (K1). `<WireOnboarding>` gated auto-injection on `Boolean(storage)` — the presence of
|
|
242
|
+
* the prop — because a string carries no provenance and there was nothing better to gate on. A
|
|
243
|
+
* REJECTING adapter therefore injected a fresh `wdev_*` on every launch: strictly worse than
|
|
244
|
+
* injecting nothing, since the server counts `min_sessions` by distinct opens grouped on `device_key`,
|
|
245
|
+
* so a per-launch key corrupts that counter AND inflates distinct-device counts. Callers that write a
|
|
246
|
+
* key onto the wire as a cross-launch join must read `durable` and refuse a `false`.
|
|
247
|
+
*
|
|
248
|
+
* Resolves `undefined` only when there is no usable id at all. With no `storage` it resolves
|
|
249
|
+
* immediately with `durable: false` — a process-scoped id is exactly what "no persistence" means.
|
|
250
|
+
* Never throws or rejects.
|
|
251
|
+
*/
|
|
252
|
+
export const hydrateDeviceIdentity = async (
|
|
253
|
+
opts: ResolveAutoDeviceKeyOptions = {},
|
|
254
|
+
): Promise<IdentityRecord | undefined> => {
|
|
200
255
|
// Mint + register synchronously first, so a waiter and a concurrent sync caller share ONE id.
|
|
201
256
|
const id = resolveAutoDeviceKey(opts);
|
|
202
|
-
if (!opts.storage) return id;
|
|
203
|
-
const registry = autoDeviceKeyRegistry();
|
|
204
257
|
const appId = opts.appId ?? "default";
|
|
258
|
+
const record = (value: string, durable: boolean): IdentityRecord | undefined =>
|
|
259
|
+
resolveIdentity({ value, space: "device", source: "auto", durable, scope: appId });
|
|
260
|
+
|
|
261
|
+
if (!opts.storage) return record(id, false);
|
|
262
|
+
const registry = autoDeviceKeyRegistry();
|
|
205
263
|
const pending = registry.pending?.get(appId);
|
|
206
|
-
|
|
207
|
-
|
|
264
|
+
// No pending entry means a previous hydration already settled DEGRADED and released its latches
|
|
265
|
+
// (see `startHydration`), so the live id is the in-memory one — real, but not durable.
|
|
266
|
+
if (!pending) return record(registry.keys.get(appId) ?? id, false);
|
|
267
|
+
const outcome = await pending;
|
|
268
|
+
return record(outcome.value, outcome.durable);
|
|
208
269
|
};
|
|
209
270
|
|
|
210
271
|
/** Test-only: forget every auto id + hydration flag so a unit test starts from a clean registry. */
|
|
@@ -88,19 +88,6 @@ export interface ResolvedUserContext {
|
|
|
88
88
|
userContext?: Record<string, string | number | boolean>;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
/** Reserved `user_context` keys the kit itself writes; host `extra` is namespaced away from these. */
|
|
92
|
-
export const RESERVED_USER_CONTEXT_KEYS = [
|
|
93
|
-
"device_key",
|
|
94
|
-
"app_version",
|
|
95
|
-
"app_build",
|
|
96
|
-
"network_type",
|
|
97
|
-
"session_count",
|
|
98
|
-
"returning",
|
|
99
|
-
"platform",
|
|
100
|
-
"user_email",
|
|
101
|
-
"user_email_hashed",
|
|
102
|
-
] as const;
|
|
103
|
-
|
|
104
91
|
/** The prefix applied to every host `extra` key so it can never collide with a reserved key. */
|
|
105
92
|
export const EXTRA_KEY_PREFIX = "custom." as const;
|
|
106
93
|
|
|
@@ -127,8 +114,10 @@ export const hashEmailFnv1a = (email: string): string => {
|
|
|
127
114
|
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
128
115
|
};
|
|
129
116
|
|
|
130
|
-
/** Trim a candidate string; return `undefined` for a non-string / blank so callers can `if`-gate.
|
|
131
|
-
|
|
117
|
+
/** Trim a candidate string; return `undefined` for a non-string / blank so callers can `if`-gate.
|
|
118
|
+
* Shared with `activation/wireActivation`, which carried a byte-identical private copy named `clean`.
|
|
119
|
+
* Not re-exported from the package barrel — this is an internal helper, not public surface. */
|
|
120
|
+
export const cleanString = (value: unknown): string | undefined => {
|
|
132
121
|
if (typeof value !== "string") return undefined;
|
|
133
122
|
const trimmed = value.trim();
|
|
134
123
|
return trimmed.length > 0 ? trimmed : undefined;
|