@wireai/activation 0.13.0 → 0.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +3 -1
- package/CHANGELOG.md +116 -3
- package/README.md +82 -0
- package/dist/analytics/index.d.mts +2 -2
- package/dist/analytics/index.d.ts +2 -2
- package/dist/analytics/index.js +61 -2
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +61 -2
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/{currentSession-_GynvhzT.d.mts → currentSession-ClkLjcJ0.d.mts} +298 -13
- package/dist/{currentSession-D7zabMXK.d.ts → currentSession-DOVZEWJl.d.ts} +298 -13
- package/dist/index.d.mts +195 -4
- package/dist/index.d.ts +195 -4
- package/dist/index.js +909 -340
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +693 -145
- 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 +12 -1
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +12 -1
- package/dist/reviews/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/OnboardingFlow.tsx +131 -0
- package/src/WireOnboarding.tsx +63 -2
- package/src/analytics/analyticsEvent.ts +16 -1
- package/src/analytics/eventQueue.ts +2 -0
- package/src/analytics/reportClientEvent.ts +68 -12
- package/src/cards/PermissionCard.tsx +438 -0
- package/src/cards/index.ts +7 -0
- package/src/illustrations/defaultIllustrations.tsx +44 -3
- package/src/index.ts +38 -0
- 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/types.ts +66 -4
|
@@ -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,
|
|
@@ -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
|
-
/**
|
|
20
|
-
const
|
|
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
|
-
</
|
|
42
|
+
</Disc>
|
|
33
43
|
);
|
|
34
44
|
};
|
|
35
45
|
|
|
36
46
|
/** Forward-motion / momentum: an upward arrow. */
|
|
37
47
|
const MomentumGlyph: React.FC = () => <Glyph>{"↗"}</Glyph>;
|
|
38
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Notification priming: a bell, DRAWN FROM VIEWS rather than written as an emoji.
|
|
51
|
+
*
|
|
52
|
+
* A color emoji carries its own palette and ignores the `color` its container sets, so a "🔔" here
|
|
53
|
+
* would be the one default illustration that refuses to repaint with the host's theme, sitting
|
|
54
|
+
* next to siblings that do. Three plain Views (a domed body, a rim, a clapper) take
|
|
55
|
+
* `t.colors.primary` directly, and they depend on no glyph being present in the platform font and
|
|
56
|
+
* on no guess about whether it renders as text or as emoji.
|
|
57
|
+
*
|
|
58
|
+
* Keyed by the PERMISSION name, which is what PermissionCard looks up by default, so a
|
|
59
|
+
* notification screen is never a blank box with no host wiring.
|
|
60
|
+
*/
|
|
61
|
+
const NotificationsGlyph: React.FC = () => {
|
|
62
|
+
const t = useOnboardingTheme();
|
|
63
|
+
return (
|
|
64
|
+
<Disc>
|
|
65
|
+
<View style={styles.bell}>
|
|
66
|
+
<View style={[styles.bellBody, { backgroundColor: t.colors.primary }]} />
|
|
67
|
+
<View style={[styles.bellRim, { backgroundColor: t.colors.primary }]} />
|
|
68
|
+
<View style={[styles.bellClapper, { backgroundColor: t.colors.primary }]} />
|
|
69
|
+
</View>
|
|
70
|
+
</Disc>
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
39
74
|
/** Before → after: two states with an arrow between. */
|
|
40
75
|
const BeforeAfterGlyph: React.FC = () => {
|
|
41
76
|
const t = useOnboardingTheme();
|
|
@@ -67,6 +102,7 @@ const BeforeAfterGlyph: React.FC = () => {
|
|
|
67
102
|
export const defaultIllustrations: Record<string, React.ReactNode> = {
|
|
68
103
|
momentum: <MomentumGlyph />,
|
|
69
104
|
"before-after": <BeforeAfterGlyph />,
|
|
105
|
+
notifications: <NotificationsGlyph />,
|
|
70
106
|
};
|
|
71
107
|
|
|
72
108
|
const styles = StyleSheet.create({
|
|
@@ -96,4 +132,9 @@ const styles = StyleSheet.create({
|
|
|
96
132
|
fontSize: 28,
|
|
97
133
|
fontWeight: "700",
|
|
98
134
|
},
|
|
135
|
+
// The bell: a domed body over a wider rim, with the clapper hanging below it.
|
|
136
|
+
bell: { alignItems: "center", justifyContent: "center" },
|
|
137
|
+
bellBody: { width: 34, height: 30, borderTopLeftRadius: 17, borderTopRightRadius: 17 },
|
|
138
|
+
bellRim: { width: 44, height: 5, borderRadius: 3, marginTop: 2 },
|
|
139
|
+
bellClapper: { width: 9, height: 9, borderRadius: 5, marginTop: 3 },
|
|
99
140
|
});
|
package/src/index.ts
CHANGED
|
@@ -57,6 +57,10 @@ export {
|
|
|
57
57
|
onboardingComponents,
|
|
58
58
|
} from "./cards";
|
|
59
59
|
export type { CardOption } from "./cards";
|
|
60
|
+
// The priming screen. NOT in `onboardingComponents` (see the note there): it is host-declared via
|
|
61
|
+
// the `permissionScreens` prop, and registered so a server-emitted placement can adopt it later.
|
|
62
|
+
export { PermissionCard, PermissionCardView, PERMISSION_CARD_NAME } from "./cards";
|
|
63
|
+
export type { PermissionCardProps } from "./cards";
|
|
60
64
|
|
|
61
65
|
// ─── Icons (semantic vocabulary → optional @expo/vector-icons → nothing) ──────
|
|
62
66
|
// WIRE_ICON_NAMES is the list to paste into the server/AI prompt as the allowed `icon` values.
|
|
@@ -243,6 +247,40 @@ export type {
|
|
|
243
247
|
RevenueCatSink,
|
|
244
248
|
} from "./revenuecat";
|
|
245
249
|
|
|
250
|
+
// ─── Permission screens (mid-flow priming; the OS dialog only ever on the primary tap) ──
|
|
251
|
+
export {
|
|
252
|
+
WIRE_PERMISSION_EVENTS,
|
|
253
|
+
permissionEventName,
|
|
254
|
+
permissionEventProps,
|
|
255
|
+
normalizePermissionStatus,
|
|
256
|
+
DEFAULT_PERMISSION_PLACEMENT,
|
|
257
|
+
isPermissionDue,
|
|
258
|
+
normalizeAfterCard,
|
|
259
|
+
permissionScreenId,
|
|
260
|
+
selectDuePermissionScreen,
|
|
261
|
+
DEFAULT_PERMISSION_COPY,
|
|
262
|
+
GENERIC_PERMISSION_COPY,
|
|
263
|
+
NOTIFICATIONS_PERMISSION_COPY,
|
|
264
|
+
resolvePermissionCopy,
|
|
265
|
+
clearSettledPermissions,
|
|
266
|
+
loadSettledPermissions,
|
|
267
|
+
permissionStorageKey,
|
|
268
|
+
readSettledPermissions,
|
|
269
|
+
saveSettledPermissions,
|
|
270
|
+
} from "./permissions";
|
|
271
|
+
export type {
|
|
272
|
+
PermissionPlacement,
|
|
273
|
+
PermissionScreenConfig,
|
|
274
|
+
PermissionScreenCopy,
|
|
275
|
+
PermissionStage,
|
|
276
|
+
WirePermissionKind,
|
|
277
|
+
WirePermissionOutcome,
|
|
278
|
+
WirePermissionStatus,
|
|
279
|
+
WirePermissionEventName,
|
|
280
|
+
FlowPosition,
|
|
281
|
+
DuePermissionScreen,
|
|
282
|
+
} from "./permissions";
|
|
283
|
+
|
|
246
284
|
// ─── Session mapping (one `app.session_started` per app-open → /v1/events) ─────
|
|
247
285
|
export {
|
|
248
286
|
reportSessionStart,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* permissions - injectable mid-flow permission screens.
|
|
3
|
+
*
|
|
4
|
+
* Re-exported from the main `@wireai/activation` barrel (no separate subpath), the same way
|
|
5
|
+
* `revenuecat` is: everything here is pure, dependency-free and UI-free, so it costs a consumer
|
|
6
|
+
* that never configures a screen nothing. The screen itself lives in `cards/PermissionCard.tsx`.
|
|
7
|
+
*
|
|
8
|
+
* Adopting it is one prop:
|
|
9
|
+
*
|
|
10
|
+
* <WireOnboarding
|
|
11
|
+
* permissionScreens={[{ permission: "notifications", request: askForNotifications }]}
|
|
12
|
+
* ...
|
|
13
|
+
* />
|
|
14
|
+
*
|
|
15
|
+
* See `types.ts` for why the kit imports no native permission module, and `PermissionCard.tsx` for
|
|
16
|
+
* the priming rule the whole feature exists to enforce.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// ─── Canonical permission-funnel names + the pure mappers behind them ─────────
|
|
20
|
+
export {
|
|
21
|
+
WIRE_PERMISSION_EVENTS,
|
|
22
|
+
permissionEventName,
|
|
23
|
+
permissionEventProps,
|
|
24
|
+
normalizePermissionStatus,
|
|
25
|
+
} from "./permissionEvents";
|
|
26
|
+
export type { WirePermissionEventName } from "./permissionEvents";
|
|
27
|
+
|
|
28
|
+
// ─── Placement math (server-driven stream, so a target past the end clamps) ───
|
|
29
|
+
export {
|
|
30
|
+
DEFAULT_PERMISSION_PLACEMENT,
|
|
31
|
+
isPermissionDue,
|
|
32
|
+
normalizeAfterCard,
|
|
33
|
+
permissionScreenId,
|
|
34
|
+
selectDuePermissionScreen,
|
|
35
|
+
} from "./placement";
|
|
36
|
+
export type { FlowPosition, DuePermissionScreen } from "./placement";
|
|
37
|
+
|
|
38
|
+
// ─── Rationale copy (kit defaults + host overrides) ───────────────────────────
|
|
39
|
+
export {
|
|
40
|
+
DEFAULT_PERMISSION_COPY,
|
|
41
|
+
GENERIC_PERMISSION_COPY,
|
|
42
|
+
NOTIFICATIONS_PERMISSION_COPY,
|
|
43
|
+
resolvePermissionCopy,
|
|
44
|
+
} from "./permissionCopy";
|
|
45
|
+
|
|
46
|
+
// ─── Once-only across an app kill (the same host-injected `storage`) ──────────
|
|
47
|
+
export {
|
|
48
|
+
clearSettledPermissions,
|
|
49
|
+
loadSettledPermissions,
|
|
50
|
+
permissionStorageKey,
|
|
51
|
+
readSettledPermissions,
|
|
52
|
+
saveSettledPermissions,
|
|
53
|
+
} from "./permissionMemory";
|
|
54
|
+
|
|
55
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
56
|
+
export type {
|
|
57
|
+
PermissionPlacement,
|
|
58
|
+
PermissionScreenConfig,
|
|
59
|
+
PermissionScreenCopy,
|
|
60
|
+
PermissionStage,
|
|
61
|
+
WirePermissionKind,
|
|
62
|
+
WirePermissionOutcome,
|
|
63
|
+
WirePermissionStatus,
|
|
64
|
+
} from "./types";
|