@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,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* identityRecord — the ONE provenance-carrying shape for an id the kit puts on the wire.
|
|
3
|
+
*
|
|
4
|
+
* WHY IT EXISTS. `session_id` and `device_key` are bare `string`s minted independently by four
|
|
5
|
+
* subsystems, and nothing anywhere recorded WHERE a given id came from. Every id-layer defect this
|
|
6
|
+
* release fixes is a direct consequence of that one omission:
|
|
7
|
+
*
|
|
8
|
+
* • a rejecting storage adapter's in-memory id was indistinguishable from a persisted one, so the
|
|
9
|
+
* kit injected a fresh per-launch join key on every launch — nothing carried `durable`.
|
|
10
|
+
* • an app-OPEN session id could be posted into a field that means the ONBOARDING session, and the
|
|
11
|
+
* caller was told `true` — nothing carried `space`.
|
|
12
|
+
* • an auto-minted `wdev_*` could be injected beside a device id the host demonstrably owns on
|
|
13
|
+
* another surface, silently — nothing carried `source`.
|
|
14
|
+
*
|
|
15
|
+
* WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT. It is a small record plus a process-wide registry of
|
|
16
|
+
* the ids a HOST supplied. It is NOT a branded-type refactor (`OnboardingSessionId` / `AppSessionId` /
|
|
17
|
+
* `DeviceKey` across every signature) — that is real value and it is deferred, because it touches
|
|
18
|
+
* every file and is not what makes a number correct this week. Nothing here changes the wire.
|
|
19
|
+
*
|
|
20
|
+
* WHY A `Symbol.for` REGISTRY. Same reason as `analytics/currentSession` and `context/deviceId`: tsup
|
|
21
|
+
* inlines a separate copy of a module into each bundle (`.` and `./analytics`), so a plain module
|
|
22
|
+
* `let` would give every bundle its own registry and the cross-surface question this exists to answer
|
|
23
|
+
* ("did ANY surface in this process get a host-supplied device key?") would read `no` from the wrong
|
|
24
|
+
* copy. `Symbol.for` resolves to one slot on `globalThis` no matter how many copies exist.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Which id space a value belongs to. These are NOT interchangeable, and the whole point of naming
|
|
29
|
+
* them is that a value from one space must never be posted into a field that means another:
|
|
30
|
+
* • `onboarding-session` — the A2A `contextId` for ONE onboarding run.
|
|
31
|
+
* • `app-session` — the per-app-open session id (`app.session_started`).
|
|
32
|
+
* • `device` — the per-install `device_key`; the only cross-family join key.
|
|
33
|
+
*/
|
|
34
|
+
export type IdentitySpace = "onboarding-session" | "app-session" | "device";
|
|
35
|
+
|
|
36
|
+
/** Where the value came from: the host handed it over, or the kit minted it. */
|
|
37
|
+
export type IdentitySource = "host" | "auto";
|
|
38
|
+
|
|
39
|
+
/** An id plus everything a consumer needs to decide whether it may use it. */
|
|
40
|
+
export type IdentityRecord = {
|
|
41
|
+
/** The id itself, trimmed. Never empty (a blank input yields no record at all). */
|
|
42
|
+
value: string;
|
|
43
|
+
/** Which id space {@link value} belongs to. */
|
|
44
|
+
space: IdentitySpace;
|
|
45
|
+
/** `host` = the integrator supplied it; `auto` = the kit minted it. */
|
|
46
|
+
source: IdentitySource;
|
|
47
|
+
/**
|
|
48
|
+
* Whether the value was actually PERSISTED (or adopted from persistence), as opposed to living
|
|
49
|
+
* only in this process's memory. A non-durable auto id is a DIFFERENT id on the next launch, which
|
|
50
|
+
* for a `device` value is worse than no value at all: the server counts `min_sessions` by distinct
|
|
51
|
+
* opens grouped on `device_key`, so a per-launch key corrupts the counter rather than leaving it
|
|
52
|
+
* empty. A host-supplied value is durable by definition — the host owns its lifetime.
|
|
53
|
+
*/
|
|
54
|
+
durable: boolean;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Input to {@link resolveIdentity}. `value` is `unknown` so callers can pass a raw prop through. */
|
|
58
|
+
export type ResolveIdentityInput = {
|
|
59
|
+
value: unknown;
|
|
60
|
+
space: IdentitySpace;
|
|
61
|
+
source: IdentitySource;
|
|
62
|
+
/** Defaults to `true` for a host value (the host owns its lifetime) and `false` otherwise. */
|
|
63
|
+
durable?: boolean;
|
|
64
|
+
/** Tenant/app id — two tenants in one process never share a provenance entry. */
|
|
65
|
+
scope?: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Well-known key into the runtime-global symbol registry — one provenance registry per process. */
|
|
69
|
+
const IDENTITY_PROVENANCE_SLOT: unique symbol = Symbol.for("@wireai/activation:identityProvenance");
|
|
70
|
+
|
|
71
|
+
/** `"<space>:<scope>"` → the HOST-supplied value seen for it. Auto values are never recorded. */
|
|
72
|
+
type ProvenanceRegistry = { host: Map<string, string> };
|
|
73
|
+
|
|
74
|
+
type GlobalWithProvenance = typeof globalThis & {
|
|
75
|
+
[IDENTITY_PROVENANCE_SLOT]?: ProvenanceRegistry;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const provenanceGlobal = globalThis as GlobalWithProvenance;
|
|
79
|
+
|
|
80
|
+
const provenanceRegistry = (): ProvenanceRegistry => {
|
|
81
|
+
const existing = provenanceGlobal[IDENTITY_PROVENANCE_SLOT];
|
|
82
|
+
if (existing) return existing;
|
|
83
|
+
const created: ProvenanceRegistry = { host: new Map() };
|
|
84
|
+
provenanceGlobal[IDENTITY_PROVENANCE_SLOT] = created;
|
|
85
|
+
return created;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const provenanceKey = (space: IdentitySpace, scope?: string): string =>
|
|
89
|
+
`${space}:${scope ?? "default"}`;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build an {@link IdentityRecord} from a candidate value, or `undefined` when there is nothing usable
|
|
93
|
+
* (a non-string, or blank after trimming) — so a caller can `if (record)`-gate instead of guessing
|
|
94
|
+
* whether an empty string means "none" or "not yet".
|
|
95
|
+
*
|
|
96
|
+
* SIDE EFFECT, deliberate and the reason this is a function and not an object literal: a `host`-sourced
|
|
97
|
+
* record is RECORDED on the process registry, so a later surface can ask {@link hostIdentity} whether
|
|
98
|
+
* this process demonstrably owns a host id in that space. That is what turns "the kit injected its own
|
|
99
|
+
* key" from a silent third id space into a warnable condition. Never throws.
|
|
100
|
+
*/
|
|
101
|
+
export const resolveIdentity = (input: ResolveIdentityInput): IdentityRecord | undefined => {
|
|
102
|
+
if (typeof input.value !== "string") return undefined;
|
|
103
|
+
const value = input.value.trim();
|
|
104
|
+
if (!value) return undefined;
|
|
105
|
+
const durable = input.durable ?? input.source === "host";
|
|
106
|
+
if (input.source === "host") {
|
|
107
|
+
provenanceRegistry().host.set(provenanceKey(input.space, input.scope), value);
|
|
108
|
+
}
|
|
109
|
+
return { value, space: input.space, source: input.source, durable };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The HOST-supplied id this process has seen for a space, or `undefined` when every surface so far
|
|
114
|
+
* let the kit mint its own. Answers the cross-surface question no single mount can answer alone:
|
|
115
|
+
* "does this app own a device id that this particular mount was not given?"
|
|
116
|
+
*/
|
|
117
|
+
export const hostIdentity = (space: IdentitySpace, scope?: string): string | undefined =>
|
|
118
|
+
provenanceRegistry().host.get(provenanceKey(space, scope));
|
|
119
|
+
|
|
120
|
+
/** Test-only: forget every recorded host identity so a unit test starts from a clean registry. */
|
|
121
|
+
export const resetIdentityProvenance = (): void => {
|
|
122
|
+
provenanceRegistry().host.clear();
|
|
123
|
+
};
|
|
@@ -76,20 +76,49 @@ export type IdentifyOnboardingOptions = {
|
|
|
76
76
|
appId?: string;
|
|
77
77
|
/** Storage key override — pass the same `persistKey` you gave `<WireOnboarding>`, if any. */
|
|
78
78
|
persistKey?: string;
|
|
79
|
+
/**
|
|
80
|
+
* OPT-IN LAST RESORT, default `false`. When no ONBOARDING session can be resolved (no `contextId`,
|
|
81
|
+
* nothing in `storage`), bind the user to the LIVE PER-OPEN app session instead and return
|
|
82
|
+
* `"app_session"`.
|
|
83
|
+
*
|
|
84
|
+
* ⚠️ These are two different id spaces sharing one wire field. An onboarding session id is the A2A
|
|
85
|
+
* `contextId`; a per-open id is what `app.session_started` registers. The server's onboarding funnel
|
|
86
|
+
* groups by `session_id`, so a per-open id posted here does not attach the user to their onboarding
|
|
87
|
+
* — it writes a row nothing in that funnel can join. Until 0.13.0 this happened SILENTLY and
|
|
88
|
+
* returned `true`, in exactly the documented post-completion case (completion clears the persisted
|
|
89
|
+
* session), so the funnel stayed unattributed while the host was told it had worked.
|
|
90
|
+
*
|
|
91
|
+
* Turn it on only if binding the id to *some* session the server saw is genuinely worth more to you
|
|
92
|
+
* than knowing the onboarding bind failed — and read the return value, which now says which it was.
|
|
93
|
+
*/
|
|
94
|
+
allowAppSessionFallback?: boolean;
|
|
79
95
|
};
|
|
80
96
|
|
|
97
|
+
/**
|
|
98
|
+
* What {@link identifyOnboarding} bound, and to WHICH id space — because `true` could not say.
|
|
99
|
+
*
|
|
100
|
+
* • `"onboarding"` — bound to the A2A `contextId`. This is the one that attributes the funnel.
|
|
101
|
+
* • `"app_session"` — bound to the live per-open app session, via `allowAppSessionFallback`. The
|
|
102
|
+
* server saw that session, but it is not this user's onboarding.
|
|
103
|
+
* • `false` — nothing was dispatched (no user id, no server url, no resolvable session).
|
|
104
|
+
*
|
|
105
|
+
* ⚠️ 0.13.0 widened this from `boolean`. `"onboarding"` is truthy, so an `if (await identify…)` still
|
|
106
|
+
* behaves identically; only an explicit `: boolean` annotation needs updating.
|
|
107
|
+
*/
|
|
108
|
+
export type IdentifyOnboardingBinding = "onboarding" | "app_session" | false;
|
|
109
|
+
|
|
81
110
|
/**
|
|
82
111
|
* Attach a host user id to an onboarding session AFTER the fact (post-registration), by sending
|
|
83
112
|
* an `identify` client event to `/v1/events`. Resolves the contextId from an explicit
|
|
84
113
|
* `contextId` or, failing that, from the persisted session in the host `storage`.
|
|
85
114
|
*
|
|
86
|
-
* Fire-and-forget under the hood (never throws, never blocks onboarding). Resolves to
|
|
87
|
-
*
|
|
88
|
-
* or no resolvable
|
|
115
|
+
* Fire-and-forget under the hood (never throws, never blocks onboarding). Resolves to the
|
|
116
|
+
* {@link IdentifyOnboardingBinding} that says WHICH id space was bound, or `false` when nothing could
|
|
117
|
+
* be (no user id, no server url, or no resolvable session).
|
|
89
118
|
*/
|
|
90
119
|
export const identifyOnboarding = async (
|
|
91
120
|
opts: IdentifyOnboardingOptions,
|
|
92
|
-
): Promise<
|
|
121
|
+
): Promise<IdentifyOnboardingBinding> => {
|
|
93
122
|
const userId = sanitizeUserId(opts.userId);
|
|
94
123
|
if (!userId || !opts.config?.serverUrl) return false;
|
|
95
124
|
|
|
@@ -101,15 +130,22 @@ export const identifyOnboarding = async (
|
|
|
101
130
|
contextId = stored?.id;
|
|
102
131
|
}
|
|
103
132
|
}
|
|
104
|
-
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
133
|
+
let space: Exclude<IdentifyOnboardingBinding, false> = "onboarding";
|
|
134
|
+
// The OPT-IN last resort (K3). Until 0.13.0 this ran unconditionally: with no captured contextId
|
|
135
|
+
// it posted the LIVE PER-OPEN session id in the `session_id` field — which on this endpoint means
|
|
136
|
+
// the ONBOARDING session — and then returned `true`. Two disjoint id spaces share that field, so
|
|
137
|
+
// the row it wrote could never join the onboarding funnel, and the host got a success signal for a
|
|
138
|
+
// bind that had not happened. It is now off unless the caller asks, and when it does fire it says
|
|
139
|
+
// so in the return value instead of impersonating an onboarding bind.
|
|
140
|
+
if (!contextId && opts.allowAppSessionFallback) {
|
|
141
|
+
contextId = getCurrentSessionId();
|
|
142
|
+
space = "app_session";
|
|
143
|
+
}
|
|
108
144
|
if (!contextId) return false;
|
|
109
145
|
|
|
110
146
|
reportClientEvent(
|
|
111
147
|
{ serverUrl: opts.config.serverUrl, apiKey: opts.config.apiKey },
|
|
112
148
|
{ event_type: "identify", session_id: contextId, user_id: userId },
|
|
113
149
|
);
|
|
114
|
-
return
|
|
150
|
+
return space;
|
|
115
151
|
};
|
|
@@ -16,8 +16,8 @@ import React from "react";
|
|
|
16
16
|
import { StyleSheet, Text, View } from "react-native";
|
|
17
17
|
import { useOnboardingTheme } from "../theme/ThemeContext";
|
|
18
18
|
|
|
19
|
-
/**
|
|
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
|
@@ -30,8 +30,6 @@ export { LoadingBlock } from "./components/LoadingBlock";
|
|
|
30
30
|
export { LoadingScreen } from "./components/LoadingScreen";
|
|
31
31
|
export { AnimatedSparkle } from "./components/AnimatedSparkle";
|
|
32
32
|
export { ErrorBlock } from "./components/ErrorBlock";
|
|
33
|
-
/** @deprecated No longer used internally — the terminal screen is CompletionView. */
|
|
34
|
-
export { DoneBlock } from "./components/DoneBlock";
|
|
35
33
|
export { CompletionView } from "./components/CompletionView";
|
|
36
34
|
export { IllustrationProvider, useIllustration } from "./components/Illustration";
|
|
37
35
|
export type { IllustrationRegistry } from "./components/Illustration";
|
|
@@ -59,6 +57,10 @@ export {
|
|
|
59
57
|
onboardingComponents,
|
|
60
58
|
} from "./cards";
|
|
61
59
|
export type { CardOption } from "./cards";
|
|
60
|
+
// The priming screen. NOT in `onboardingComponents` (see the note there): it is host-declared via
|
|
61
|
+
// the `permissionScreens` prop, and registered so a server-emitted placement can adopt it later.
|
|
62
|
+
export { PermissionCard, PermissionCardView, PERMISSION_CARD_NAME } from "./cards";
|
|
63
|
+
export type { PermissionCardProps } from "./cards";
|
|
62
64
|
|
|
63
65
|
// ─── Icons (semantic vocabulary → optional @expo/vector-icons → nothing) ──────
|
|
64
66
|
// WIRE_ICON_NAMES is the list to paste into the server/AI prompt as the allowed `icon` values.
|
|
@@ -147,7 +149,9 @@ export { detectNativeModel } from "./device/deviceModel";
|
|
|
147
149
|
|
|
148
150
|
// ─── User identity (opaque pseudonymous id; late binding, dependency-free) ────
|
|
149
151
|
export { identifyOnboarding, sanitizeUserId, looksLikeEmail, USER_ID_MAX_LENGTH } from "./identity/userIdentity";
|
|
150
|
-
export type { IdentifyOnboardingOptions } from "./identity/userIdentity";
|
|
152
|
+
export type { IdentifyOnboardingOptions, IdentifyOnboardingBinding } from "./identity/userIdentity";
|
|
153
|
+
export { resolveIdentity, hostIdentity, resetIdentityProvenance } from "./identity/identityRecord";
|
|
154
|
+
export type { IdentityRecord, IdentitySpace, IdentitySource } from "./identity/identityRecord";
|
|
151
155
|
|
|
152
156
|
// ─── Rich user context (one object → every event's user_context; opt-in email PII) ────
|
|
153
157
|
export {
|
|
@@ -159,7 +163,6 @@ export {
|
|
|
159
163
|
clearPiiFromContext,
|
|
160
164
|
analyticsUserIdStorageKey,
|
|
161
165
|
activationJoinContext,
|
|
162
|
-
RESERVED_USER_CONTEXT_KEYS,
|
|
163
166
|
EXTRA_KEY_PREFIX,
|
|
164
167
|
} from "./context/userContext";
|
|
165
168
|
export type {
|
|
@@ -182,6 +185,9 @@ export {
|
|
|
182
185
|
// The awaitable sibling: resolves AFTER the persisted id has been read back, for a caller that can
|
|
183
186
|
// afford one storage read and must not stamp a key minted a millisecond ago (the lifecycle mount).
|
|
184
187
|
hydrateAutoDeviceKey,
|
|
188
|
+
// The PROVENANCE-carrying form of the same read: it also says whether the id was actually persisted,
|
|
189
|
+
// so a caller writing a cross-launch join key onto the wire can refuse a per-launch one.
|
|
190
|
+
hydrateDeviceIdentity,
|
|
185
191
|
resetAutoDeviceKeys,
|
|
186
192
|
} from "./context/deviceId";
|
|
187
193
|
export type { DeviceKeyStorage, ResolveAutoDeviceKeyOptions } from "./context/deviceId";
|
|
@@ -241,6 +247,40 @@ export type {
|
|
|
241
247
|
RevenueCatSink,
|
|
242
248
|
} from "./revenuecat";
|
|
243
249
|
|
|
250
|
+
// ─── Permission screens (mid-flow priming; the OS dialog only ever on the primary tap) ──
|
|
251
|
+
export {
|
|
252
|
+
WIRE_PERMISSION_EVENTS,
|
|
253
|
+
permissionEventName,
|
|
254
|
+
permissionEventProps,
|
|
255
|
+
normalizePermissionStatus,
|
|
256
|
+
DEFAULT_PERMISSION_PLACEMENT,
|
|
257
|
+
isPermissionDue,
|
|
258
|
+
normalizeAfterCard,
|
|
259
|
+
permissionScreenId,
|
|
260
|
+
selectDuePermissionScreen,
|
|
261
|
+
DEFAULT_PERMISSION_COPY,
|
|
262
|
+
GENERIC_PERMISSION_COPY,
|
|
263
|
+
NOTIFICATIONS_PERMISSION_COPY,
|
|
264
|
+
resolvePermissionCopy,
|
|
265
|
+
clearSettledPermissions,
|
|
266
|
+
loadSettledPermissions,
|
|
267
|
+
permissionStorageKey,
|
|
268
|
+
readSettledPermissions,
|
|
269
|
+
saveSettledPermissions,
|
|
270
|
+
} from "./permissions";
|
|
271
|
+
export type {
|
|
272
|
+
PermissionPlacement,
|
|
273
|
+
PermissionScreenConfig,
|
|
274
|
+
PermissionScreenCopy,
|
|
275
|
+
PermissionStage,
|
|
276
|
+
WirePermissionKind,
|
|
277
|
+
WirePermissionOutcome,
|
|
278
|
+
WirePermissionStatus,
|
|
279
|
+
WirePermissionEventName,
|
|
280
|
+
FlowPosition,
|
|
281
|
+
DuePermissionScreen,
|
|
282
|
+
} from "./permissions";
|
|
283
|
+
|
|
244
284
|
// ─── Session mapping (one `app.session_started` per app-open → /v1/events) ─────
|
|
245
285
|
export {
|
|
246
286
|
reportSessionStart,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* permissions - injectable mid-flow permission screens.
|
|
3
|
+
*
|
|
4
|
+
* Re-exported from the main `@wireai/activation` barrel (no separate subpath), the same way
|
|
5
|
+
* `revenuecat` is: everything here is pure, dependency-free and UI-free, so it costs a consumer
|
|
6
|
+
* that never configures a screen nothing. The screen itself lives in `cards/PermissionCard.tsx`.
|
|
7
|
+
*
|
|
8
|
+
* Adopting it is one prop:
|
|
9
|
+
*
|
|
10
|
+
* <WireOnboarding
|
|
11
|
+
* permissionScreens={[{ permission: "notifications", request: askForNotifications }]}
|
|
12
|
+
* ...
|
|
13
|
+
* />
|
|
14
|
+
*
|
|
15
|
+
* See `types.ts` for why the kit imports no native permission module, and `PermissionCard.tsx` for
|
|
16
|
+
* the priming rule the whole feature exists to enforce.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// ─── Canonical permission-funnel names + the pure mappers behind them ─────────
|
|
20
|
+
export {
|
|
21
|
+
WIRE_PERMISSION_EVENTS,
|
|
22
|
+
permissionEventName,
|
|
23
|
+
permissionEventProps,
|
|
24
|
+
normalizePermissionStatus,
|
|
25
|
+
} from "./permissionEvents";
|
|
26
|
+
export type { WirePermissionEventName } from "./permissionEvents";
|
|
27
|
+
|
|
28
|
+
// ─── Placement math (server-driven stream, so a target past the end clamps) ───
|
|
29
|
+
export {
|
|
30
|
+
DEFAULT_PERMISSION_PLACEMENT,
|
|
31
|
+
isPermissionDue,
|
|
32
|
+
normalizeAfterCard,
|
|
33
|
+
permissionScreenId,
|
|
34
|
+
selectDuePermissionScreen,
|
|
35
|
+
} from "./placement";
|
|
36
|
+
export type { FlowPosition, DuePermissionScreen } from "./placement";
|
|
37
|
+
|
|
38
|
+
// ─── Rationale copy (kit defaults + host overrides) ───────────────────────────
|
|
39
|
+
export {
|
|
40
|
+
DEFAULT_PERMISSION_COPY,
|
|
41
|
+
GENERIC_PERMISSION_COPY,
|
|
42
|
+
NOTIFICATIONS_PERMISSION_COPY,
|
|
43
|
+
resolvePermissionCopy,
|
|
44
|
+
} from "./permissionCopy";
|
|
45
|
+
|
|
46
|
+
// ─── Once-only across an app kill (the same host-injected `storage`) ──────────
|
|
47
|
+
export {
|
|
48
|
+
clearSettledPermissions,
|
|
49
|
+
loadSettledPermissions,
|
|
50
|
+
permissionStorageKey,
|
|
51
|
+
readSettledPermissions,
|
|
52
|
+
saveSettledPermissions,
|
|
53
|
+
} from "./permissionMemory";
|
|
54
|
+
|
|
55
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
56
|
+
export type {
|
|
57
|
+
PermissionPlacement,
|
|
58
|
+
PermissionScreenConfig,
|
|
59
|
+
PermissionScreenCopy,
|
|
60
|
+
PermissionStage,
|
|
61
|
+
WirePermissionKind,
|
|
62
|
+
WirePermissionOutcome,
|
|
63
|
+
WirePermissionStatus,
|
|
64
|
+
} from "./types";
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* permissionCopy - the rationale a priming screen shows, and the kit-quality defaults behind it.
|
|
3
|
+
*
|
|
4
|
+
* Same job the `copy` prop does for the loaders: the kit ships English that is good enough to
|
|
5
|
+
* ship as-is, and a host overrides any single line without having to restate the rest.
|
|
6
|
+
*
|
|
7
|
+
* PURE, no React, no React Native - so the resolution is unit-testable and the same function can
|
|
8
|
+
* later resolve copy a server sent.
|
|
9
|
+
*/
|
|
10
|
+
import type { WirePermissionKind } from "./types";
|
|
11
|
+
|
|
12
|
+
/** Every string a permission screen can render. Both states (the ask and the blocked route). */
|
|
13
|
+
export type PermissionScreenCopy = {
|
|
14
|
+
/** Headline for the ask. */
|
|
15
|
+
title: string;
|
|
16
|
+
/** The rationale. The one thing that decides whether the primary gets tapped. */
|
|
17
|
+
message: string;
|
|
18
|
+
/** Primary button. Tapping it is the ONLY thing that can open the OS dialog. */
|
|
19
|
+
primaryLabel: string;
|
|
20
|
+
/** Secondary button. Advances the flow and does NOT spend the one native prompt. */
|
|
21
|
+
secondaryLabel: string;
|
|
22
|
+
/** Headline once the OS says the permission is permanently refused. */
|
|
23
|
+
blockedTitle: string;
|
|
24
|
+
/** Rationale for the blocked state, where the only remaining route is the settings app. */
|
|
25
|
+
blockedMessage: string;
|
|
26
|
+
/** Primary button on the blocked route. */
|
|
27
|
+
settingsLabel: string;
|
|
28
|
+
/** Primary button when there is nothing left to ask for (already granted, or no `request`). */
|
|
29
|
+
continueLabel: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The notification default. Written to the same bar as the kit's other shipped copy: it names the
|
|
34
|
+
* benefit in the user's terms and promises a limit, because "we would like to send you
|
|
35
|
+
* notifications" is exactly the sentence that spends the one iOS prompt on a no.
|
|
36
|
+
*/
|
|
37
|
+
export const NOTIFICATIONS_PERMISSION_COPY: PermissionScreenCopy = {
|
|
38
|
+
title: "Want a nudge at the right moment?",
|
|
39
|
+
message:
|
|
40
|
+
"Turn notifications on and we will remind you when it actually helps. No daily noise, and you can turn them off any time.",
|
|
41
|
+
primaryLabel: "Enable notifications",
|
|
42
|
+
secondaryLabel: "Maybe later",
|
|
43
|
+
blockedTitle: "Notifications are switched off",
|
|
44
|
+
blockedMessage:
|
|
45
|
+
"Notifications are turned off for this app, so we cannot ask from here. You can switch them back on in Settings.",
|
|
46
|
+
settingsLabel: "Open settings",
|
|
47
|
+
continueLabel: "Continue",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** The fallback for any permission the kit ships no copy for. A host overriding `copy` replaces it. */
|
|
51
|
+
export const GENERIC_PERMISSION_COPY: PermissionScreenCopy = {
|
|
52
|
+
title: "One quick permission",
|
|
53
|
+
message: "We need your permission for this part to work. You can change it any time.",
|
|
54
|
+
primaryLabel: "Allow",
|
|
55
|
+
secondaryLabel: "Maybe later",
|
|
56
|
+
blockedTitle: "Permission is switched off",
|
|
57
|
+
blockedMessage:
|
|
58
|
+
"This permission is turned off for this app, so we cannot ask from here. You can switch it back on in Settings.",
|
|
59
|
+
settingsLabel: "Open settings",
|
|
60
|
+
continueLabel: "Continue",
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** The kit's shipped defaults, by permission. Anything not listed falls back to the generic set. */
|
|
64
|
+
export const DEFAULT_PERMISSION_COPY: Record<string, PermissionScreenCopy> = {
|
|
65
|
+
notifications: NOTIFICATIONS_PERMISSION_COPY,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The kit default for `permission`, with the host's overrides applied on top.
|
|
70
|
+
*
|
|
71
|
+
* An override key whose value is `undefined` is IGNORED rather than allowed to blank the default:
|
|
72
|
+
* hosts build this object from their i18n layer, and a missing translation resolving to `undefined`
|
|
73
|
+
* would otherwise render an empty button.
|
|
74
|
+
*/
|
|
75
|
+
export const resolvePermissionCopy = (
|
|
76
|
+
permission: WirePermissionKind,
|
|
77
|
+
overrides?: Partial<PermissionScreenCopy>,
|
|
78
|
+
): PermissionScreenCopy => {
|
|
79
|
+
const base = DEFAULT_PERMISSION_COPY[permission] ?? GENERIC_PERMISSION_COPY;
|
|
80
|
+
if (!overrides) return base;
|
|
81
|
+
const merged: PermissionScreenCopy = { ...base };
|
|
82
|
+
for (const key of Object.keys(overrides) as Array<keyof PermissionScreenCopy>) {
|
|
83
|
+
const value = overrides[key];
|
|
84
|
+
if (typeof value === "string" && value.length > 0) merged[key] = value;
|
|
85
|
+
}
|
|
86
|
+
return merged;
|
|
87
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* permissionEvents - the canonical Wire names for a permission-priming funnel.
|
|
3
|
+
*
|
|
4
|
+
* Same job `purchaseEvents.ts` does for the subscription funnel and `analyticsEvent.ts` does for
|
|
5
|
+
* the onboarding funnel: every app was naming these itself (`push_permission`, `NOTIF_PROMPT`,
|
|
6
|
+
* `notifications_allowed`), so the same funnel read differently per tenant and no cross-app report
|
|
7
|
+
* was possible. These are the ONE set of names.
|
|
8
|
+
*
|
|
9
|
+
* They are `app_event` `question_key` values on the wire, exactly like `WIRE_PURCHASE_EVENTS`, so
|
|
10
|
+
* they are ALSO the exact strings a review / questionnaire firing trigger matches on. Never rename
|
|
11
|
+
* one: a rename silently unfires every trigger configured against the old string.
|
|
12
|
+
*
|
|
13
|
+
* PURE + dependency-free: no React, no React Native, no transport, no imports outside the type
|
|
14
|
+
* declarations - so an analytics-only bundle can carry the names without carrying a screen.
|
|
15
|
+
*/
|
|
16
|
+
import type { PermissionStage, WirePermissionKind, WirePermissionStatus } from "./types";
|
|
17
|
+
|
|
18
|
+
export const WIRE_PERMISSION_EVENTS = {
|
|
19
|
+
/** The priming screen became visible. The denominator for every rate below. */
|
|
20
|
+
screenShown: "wire_permission_screen_shown",
|
|
21
|
+
/** The user tapped the primary, so the OS dialog is about to open. The rationale worked. */
|
|
22
|
+
primerAccepted: "wire_permission_primer_accepted",
|
|
23
|
+
/** The OS granted it. */
|
|
24
|
+
granted: "wire_permission_granted",
|
|
25
|
+
/** The OS refused it (a permanently blocked answer reports here too, with `status: "blocked"`). */
|
|
26
|
+
denied: "wire_permission_denied",
|
|
27
|
+
/** The user took the secondary. The one native prompt was NOT spent. */
|
|
28
|
+
skipped: "wire_permission_skipped",
|
|
29
|
+
/** A blocked user was redirected to the OS settings page. */
|
|
30
|
+
settingsOpened: "wire_permission_settings_opened",
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
export type WirePermissionEventName =
|
|
34
|
+
(typeof WIRE_PERMISSION_EVENTS)[keyof typeof WIRE_PERMISSION_EVENTS];
|
|
35
|
+
|
|
36
|
+
/** The canonical event name for a stage. Exhaustive over the union (a new stage is a compile error). */
|
|
37
|
+
export const permissionEventName = (stage: PermissionStage): WirePermissionEventName => {
|
|
38
|
+
switch (stage) {
|
|
39
|
+
case "shown":
|
|
40
|
+
return WIRE_PERMISSION_EVENTS.screenShown;
|
|
41
|
+
case "accepted":
|
|
42
|
+
return WIRE_PERMISSION_EVENTS.primerAccepted;
|
|
43
|
+
case "granted":
|
|
44
|
+
return WIRE_PERMISSION_EVENTS.granted;
|
|
45
|
+
case "denied":
|
|
46
|
+
return WIRE_PERMISSION_EVENTS.denied;
|
|
47
|
+
case "skipped":
|
|
48
|
+
return WIRE_PERMISSION_EVENTS.skipped;
|
|
49
|
+
case "settings":
|
|
50
|
+
return WIRE_PERMISSION_EVENTS.settingsOpened;
|
|
51
|
+
default: {
|
|
52
|
+
const _exhaustive: never = stage;
|
|
53
|
+
return _exhaustive;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The small, non-PII props that ride a permission event. `permission` is always present so one
|
|
60
|
+
* funnel can be sliced per permission; `status` only appears when the OS actually answered, which
|
|
61
|
+
* is what keeps a `blocked` refusal distinguishable from a plain `denied` without a second event.
|
|
62
|
+
*/
|
|
63
|
+
export const permissionEventProps = (
|
|
64
|
+
permission: WirePermissionKind,
|
|
65
|
+
status?: WirePermissionStatus,
|
|
66
|
+
): Record<string, string> => (status ? { permission, status } : { permission });
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Normalize whatever the host's `request` / `getStatus` actually returned.
|
|
70
|
+
*
|
|
71
|
+
* A native permission bridge is the host's code, and hosts return `"undetermined"`, `true`, or a
|
|
72
|
+
* whole Expo response object. Anything the kit does not recognise is treated as `denied`: it is the
|
|
73
|
+
* only reading that cannot invent a grant, and every outcome continues the flow anyway.
|
|
74
|
+
*/
|
|
75
|
+
export const normalizePermissionStatus = (value: unknown): WirePermissionStatus =>
|
|
76
|
+
value === "granted" || value === "denied" || value === "blocked" ? value : "denied";
|