@wireai/activation 0.13.0 → 0.13.3
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 +7 -4
- package/CHANGELOG.md +341 -4
- package/README.md +98 -2
- package/dist/analytics/index.d.mts +4 -4
- package/dist/analytics/index.d.ts +4 -4
- package/dist/analytics/index.js +164 -471
- package/dist/analytics/index.js.map +1 -1
- package/dist/analytics/index.mjs +162 -469
- package/dist/analytics/index.mjs.map +1 -1
- package/dist/coachmarks/index.d.mts +14 -1
- package/dist/coachmarks/index.d.ts +14 -1
- package/dist/coachmarks/index.js +58 -14
- package/dist/coachmarks/index.js.map +1 -1
- package/dist/coachmarks/index.mjs +58 -14
- package/dist/coachmarks/index.mjs.map +1 -1
- package/dist/{currentSession-_GynvhzT.d.mts → currentSession-BoWtr3Jp.d.mts} +299 -14
- package/dist/{currentSession-D7zabMXK.d.ts → currentSession-CCOMlaQ2.d.ts} +299 -14
- package/dist/{decision-Bl_M2y3r.d.mts → decision-Cjw0jbEj.d.mts} +1 -1
- package/dist/{decision-Cau5KmP6.d.ts → decision-GfpXAsk_.d.ts} +1 -1
- package/dist/index.d.mts +218 -4
- package/dist/index.d.ts +218 -4
- package/dist/index.js +1104 -888
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +887 -692
- package/dist/index.mjs.map +1 -1
- package/dist/questionnaire/index.d.mts +42 -10
- package/dist/questionnaire/index.d.ts +42 -10
- package/dist/questionnaire/index.js +45 -13
- package/dist/questionnaire/index.js.map +1 -1
- package/dist/questionnaire/index.mjs +45 -13
- package/dist/questionnaire/index.mjs.map +1 -1
- package/dist/reviews/index.d.mts +4 -4
- package/dist/reviews/index.d.ts +4 -4
- package/dist/reviews/index.js +39 -8
- package/dist/reviews/index.js.map +1 -1
- package/dist/reviews/index.mjs +39 -8
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/showcase/index.js +7 -2
- package/dist/showcase/index.js.map +1 -1
- package/dist/showcase/index.mjs +7 -2
- package/dist/showcase/index.mjs.map +1 -1
- package/dist/{transport-DsRe4epC.d.ts → transport-CefdERPs.d.mts} +44 -5
- package/dist/{transport-CF_eHwzC.d.mts → transport-DFuPyCoJ.d.ts} +44 -5
- package/dist/{types-Buj9Lw9t.d.ts → types-UVYt9BJe.d.ts} +41 -1
- package/dist/{types-CNUqMK0D.d.mts → types-l2tfg23P.d.mts} +41 -1
- package/package.json +4 -1
- package/src/OnboardingFlow.tsx +175 -15
- package/src/WireOnboarding.tsx +70 -8
- package/src/activation/wireActivation.ts +3 -3
- package/src/analytics/analyticsEvent.ts +16 -1
- package/src/analytics/analyticsFacade.ts +2 -2
- package/src/analytics/eventQueue.ts +85 -20
- package/src/analytics/reportClientEvent.ts +68 -12
- package/src/cards/CardGridSelectCard.tsx +1 -1
- package/src/cards/ChipSelectCard.tsx +2 -2
- package/src/cards/PermissionCard.tsx +438 -0
- package/src/cards/SelectionCard.tsx +1 -1
- package/src/cards/index.ts +7 -0
- package/src/coachmarks/runtime.ts +62 -2
- package/src/coachmarks/useCoachmarkTour.ts +62 -14
- package/src/context/deviceId.ts +4 -4
- package/src/features/WireFeaturesProvider.tsx +4 -1
- package/src/identity/userIdentity.ts +1 -1
- 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/questionnaire/QuestionnaireGate.tsx +90 -10
- package/src/questionnaire/index.ts +9 -1
- package/src/questionnaire/transport.ts +46 -11
- package/src/reviews/ReviewGate.tsx +61 -6
- package/src/reviews/index.ts +10 -1
- package/src/reviews/transport.ts +58 -12
- package/src/session/persistedSession.ts +10 -3
- package/src/session-analytics/useLifecycleEvents.ts +1 -1
- package/src/theme/ThemeContext.tsx +42 -1
- package/src/types.ts +66 -4
- package/src/utils/submitResult.ts +39 -0
|
@@ -86,7 +86,7 @@ const DEFAULTS = {
|
|
|
86
86
|
/** Ceiling on the persisted-backlog read — a hung adapter degrades to an empty start, never a stall. */
|
|
87
87
|
const READ_TIMEOUT_MS = 1500;
|
|
88
88
|
|
|
89
|
-
// ── One storage slot per QUEUE, not per appId
|
|
89
|
+
// ── One storage slot per QUEUE, not per appId ────────────────────────────────────────────────────
|
|
90
90
|
//
|
|
91
91
|
// The default key was derived from `appId` alone, so two `createAnalytics` instances for one tenant —
|
|
92
92
|
// the documented double-wiring, a façade for `track`/`screen` plus an activation instance — shared ONE
|
|
@@ -153,10 +153,19 @@ type QueuedItem = { id: number; event: ClientEvent; sig: string };
|
|
|
153
153
|
/** Persisted shape — the local id + the (already envelope-stamped) event. `sig` is recomputed on load. */
|
|
154
154
|
type PersistedItem = { id: number; event: ClientEvent };
|
|
155
155
|
|
|
156
|
-
|
|
156
|
+
/**
|
|
157
|
+
* The verdict of a read that ran out of time. A DISTINCT value, and never `undefined` — an
|
|
158
|
+
* `undefined` here is byte-identical to "the adapter answered, there is no backlog", and that
|
|
159
|
+
* conflation destroys data silently: `parsePersisted(undefined)` gives `[]`, hydration takes its
|
|
160
|
+
* `length === 0` early return without throwing, and the next `persist()` writes the in-memory
|
|
161
|
+
* pending list over a blob nobody has read — or, with nothing pending, `removeItem`s it outright.
|
|
162
|
+
*/
|
|
163
|
+
const READ_TIMED_OUT: unique symbol = Symbol("wireai:storage-read-timeout");
|
|
164
|
+
|
|
165
|
+
const withTimeout = <T>(p: Promise<T>, ms: number): Promise<T | typeof READ_TIMED_OUT> => {
|
|
157
166
|
let timer: ReturnType<typeof setTimeout>;
|
|
158
|
-
const timeout = new Promise<
|
|
159
|
-
timer = setTimeout(() => resolve(
|
|
167
|
+
const timeout = new Promise<typeof READ_TIMED_OUT>((resolve) => {
|
|
168
|
+
timer = setTimeout(() => resolve(READ_TIMED_OUT), ms);
|
|
160
169
|
});
|
|
161
170
|
return Promise.race([p, timeout]).finally(() => clearTimeout(timer));
|
|
162
171
|
};
|
|
@@ -198,7 +207,7 @@ const parsePersisted = (raw: string | null | undefined): PersistedItem[] => {
|
|
|
198
207
|
export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
199
208
|
const target = options.target;
|
|
200
209
|
const storage = options.storage;
|
|
201
|
-
// One slot per QUEUE
|
|
210
|
+
// One slot per QUEUE: an explicit key is taken verbatim; the appId-derived default is rotated
|
|
202
211
|
// to `…#2` when a live queue already holds it, so two instances can never share one backlog.
|
|
203
212
|
//
|
|
204
213
|
// Only claimed when there IS storage. The defect is entirely about the persisted slot, and a
|
|
@@ -217,6 +226,13 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
217
226
|
let flushing = false;
|
|
218
227
|
let attempt = 0;
|
|
219
228
|
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
229
|
+
// The persisted blob exists but has NOT been read yet (the cold-start read blew its deadline and
|
|
230
|
+
// is still in flight). While this is true every write is suppressed: the only thing the queue
|
|
231
|
+
// could write is a view of `pending` that does not include the backlog it has not seen, and
|
|
232
|
+
// `setItem`/`removeItem` would destroy it. Cleared as soon as the slow read settles, whichever
|
|
233
|
+
// way it settles, so a read that ultimately fails resumes normal persistence rather than
|
|
234
|
+
// suppressing it for the life of the process.
|
|
235
|
+
let backlogUnread = false;
|
|
220
236
|
|
|
221
237
|
const resolveEnvelope = (): ContextEnvelope | undefined => {
|
|
222
238
|
try {
|
|
@@ -238,6 +254,8 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
238
254
|
// from a REDUNDANT re-enqueue of the same instant. The de-dup signature below includes it, so
|
|
239
255
|
// two identical events enqueued in the same millisecond still collapse (a re-render), while the
|
|
240
256
|
// same action repeated later carries a fresh `ts` and survives. A caller-set `ts` is preserved.
|
|
257
|
+
// This stays a NUMBER in the queue (the de-dup signature depends on it); `buildEventsRequest`
|
|
258
|
+
// serializes it to the ISO8601 string the server requires at send time.
|
|
241
259
|
if (stamped.ts === undefined) stamped.ts = Date.now();
|
|
242
260
|
if (!env) return stamped;
|
|
243
261
|
if (!stamped.device && env.device) stamped.device = env.device;
|
|
@@ -252,6 +270,8 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
252
270
|
|
|
253
271
|
const persist = (): void => {
|
|
254
272
|
if (!storage) return;
|
|
273
|
+
// NEVER write over a blob that has not been read yet. See `backlogUnread`.
|
|
274
|
+
if (backlogUnread) return;
|
|
255
275
|
try {
|
|
256
276
|
if (pending.length === 0) {
|
|
257
277
|
void storage.removeItem(key).catch(() => {});
|
|
@@ -278,25 +298,70 @@ export const createEventQueue = (options: EventQueueOptions): EventQueue => {
|
|
|
278
298
|
}
|
|
279
299
|
};
|
|
280
300
|
|
|
281
|
-
|
|
282
|
-
|
|
301
|
+
/**
|
|
302
|
+
* Merge a loaded backlog into the in-memory buffer: persisted (older) events go AHEAD of
|
|
303
|
+
* whatever was enqueued while the read was in flight, duplicates collapse, and the size cap
|
|
304
|
+
* applies as usual.
|
|
305
|
+
*
|
|
306
|
+
* Ids are minted FRESH from the running `nextId` rather than reset to 0. A drain may already be
|
|
307
|
+
* in flight holding a batch of ids it will filter out on ack, and re-numbering from 0 would make
|
|
308
|
+
* those ids point at different events — the ack would then dequeue (silently drop) whichever
|
|
309
|
+
* events happened to inherit them. Monotonic ids are never reused, so an in-flight ack stays
|
|
310
|
+
* correct whatever lands in between.
|
|
311
|
+
*/
|
|
312
|
+
const mergePersisted = (persistedItems: PersistedItem[]): void => {
|
|
313
|
+
if (persistedItems.length === 0) return;
|
|
314
|
+
const seen = new Set(pending.map((item) => item.sig));
|
|
315
|
+
const restored: QueuedItem[] = [];
|
|
316
|
+
for (const persisted of persistedItems) {
|
|
317
|
+
const sig = safeSig(persisted.event);
|
|
318
|
+
if (seen.has(sig)) continue; // already in memory — collapse the duplicate
|
|
319
|
+
seen.add(sig);
|
|
320
|
+
restored.push({ id: nextId++, event: persisted.event, sig });
|
|
321
|
+
}
|
|
322
|
+
if (restored.length === 0) return;
|
|
323
|
+
pending = [...restored, ...pending];
|
|
324
|
+
enforceSizeCap();
|
|
325
|
+
persist();
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
// Load any persisted backlog. Anything enqueued before this settles stays in memory; the merge
|
|
329
|
+
// above puts persisted (older) events ahead of it.
|
|
330
|
+
//
|
|
331
|
+
// A read that blows READ_TIMEOUT_MS is NOT treated as "no backlog". The race only unblocks the
|
|
332
|
+
// DRAIN — the original read is kept and merged whenever it lands, and until then every write is
|
|
333
|
+
// suppressed so the unread blob survives intact.
|
|
283
334
|
const loadPromise: Promise<void> = (async () => {
|
|
284
335
|
if (!storage) return;
|
|
285
336
|
try {
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
337
|
+
const read = Promise.resolve(storage.getItem(key));
|
|
338
|
+
// A rejecting read must not surface as an unhandled rejection when the race is won by the
|
|
339
|
+
// timeout; the recovery path below re-attaches its own handlers.
|
|
340
|
+
read.catch(() => {});
|
|
341
|
+
const raced = await withTimeout(read, READ_TIMEOUT_MS);
|
|
342
|
+
if (raced === READ_TIMED_OUT) {
|
|
343
|
+
backlogUnread = true;
|
|
344
|
+
warnInDev(
|
|
345
|
+
`[wireai] the persisted analytics backlog at "${key}" took longer than ${READ_TIMEOUT_MS}ms ` +
|
|
346
|
+
"to read, so the queue started without it. The stored events are NOT discarded: writes " +
|
|
347
|
+
"are held back until the read lands, and the backlog is merged in then. If you see this " +
|
|
348
|
+
"on every cold start, your storage adapter is too slow to be on the launch path.",
|
|
349
|
+
);
|
|
350
|
+
void read
|
|
351
|
+
.then((late) => {
|
|
352
|
+
backlogUnread = false;
|
|
353
|
+
mergePersisted(parsePersisted(late));
|
|
354
|
+
// Send whatever was just recovered; without this it would wait for the next enqueue.
|
|
355
|
+
flush();
|
|
356
|
+
})
|
|
357
|
+
.catch(() => {
|
|
358
|
+
// The slow read ultimately failed → nothing to preserve, resume normal persistence.
|
|
359
|
+
backlogUnread = false;
|
|
360
|
+
persist();
|
|
361
|
+
});
|
|
362
|
+
return;
|
|
297
363
|
}
|
|
298
|
-
|
|
299
|
-
persist();
|
|
364
|
+
mergePersisted(parsePersisted(raced));
|
|
300
365
|
} catch {
|
|
301
366
|
// Unreadable backlog → start empty; nothing enqueued in-memory is lost.
|
|
302
367
|
}
|
|
@@ -77,15 +77,24 @@ export type ClientEvent = {
|
|
|
77
77
|
*/
|
|
78
78
|
user_id?: string;
|
|
79
79
|
/**
|
|
80
|
-
* Client-stamped
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
80
|
+
* Client-stamped timestamp of when the event was ENQUEUED on the device. Optional and ADDITIVE.
|
|
81
|
+
*
|
|
82
|
+
* TWO REPRESENTATIONS, on purpose:
|
|
83
|
+
* - INTERNAL (epoch-ms `number`): what the offline queue stamps at enqueue time (see
|
|
84
|
+
* `createEventQueue`), so two otherwise byte-identical events fired seconds apart (a genuine
|
|
85
|
+
* repeat, e.g. the user taps "share" twice) are NOT collapsed by the queue's identical-JSON
|
|
86
|
+
* de-dup — while two truly simultaneous re-enqueues of the same instant (a redundant
|
|
87
|
+
* re-render) still share a `ts` and collapse. The de-dup signature depends on this number.
|
|
88
|
+
* - WIRE (ISO8601 UTC `string`): what actually leaves the device. {@link buildEventsRequest}
|
|
89
|
+
* converts the number on its way out, because the server declares `ts: str | None` and
|
|
90
|
+
* pydantic v2 does NOT coerce a number into it — a numeric `ts` made the server answer HTTP
|
|
91
|
+
* 200 with `{written: 0, skipped: N, errors: [{field: "ts", reason: "validation_error"}]}`,
|
|
92
|
+
* silently discarding EVERY `app_event` through 0.13.0.
|
|
93
|
+
*
|
|
94
|
+
* A caller-set ISO string is passed through as-is. Never a wall-clock the server trusts (it
|
|
95
|
+
* derives its own receive time); an old/strict server that does not model it ignores the field.
|
|
87
96
|
*/
|
|
88
|
-
ts?: number;
|
|
97
|
+
ts?: number | string;
|
|
89
98
|
};
|
|
90
99
|
|
|
91
100
|
/** Where to POST. Derived from `WireOnboardingConfig` (`serverUrl` + `apiKey`). */
|
|
@@ -105,12 +114,46 @@ export type ClientEventTarget = {
|
|
|
105
114
|
export const makeSessionId = (): string =>
|
|
106
115
|
`wire_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
107
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Serialize the internal epoch-ms `ts` to the ISO8601 UTC string the WIRE requires.
|
|
119
|
+
*
|
|
120
|
+
* The server's event model declares `ts: str | None` and pydantic v2 does NOT coerce int → str, so
|
|
121
|
+
* a numeric `ts` fails per-event validation: the endpoint still answers HTTP 200, but with
|
|
122
|
+
* `{written: 0, skipped: N, errors: [{index, reason: "validation_error", field: "ts"}]}` — every
|
|
123
|
+
* `app_event` from `track()`/`screen()` silently discarded behind a green response.
|
|
124
|
+
*
|
|
125
|
+
* WHY HERE and not at the queue's `stamp()`: this is the single choke point all FOUR send paths go
|
|
126
|
+
* through (offline queue, fire-and-forget, awaitable, session-start). Converting here leaves the
|
|
127
|
+
* queue's numeric `ts` — and therefore its identical-JSON de-dup signature — exactly as it was, and
|
|
128
|
+
* it also converts the persisted 0.13.0 backlogs (which hold a numeric `ts`) on their way out.
|
|
129
|
+
*
|
|
130
|
+
* NEVER mutates the caller's event: an event that needs a change is copied. A string `ts` (a
|
|
131
|
+
* caller-set ISO stamp) and an absent `ts` pass through untouched. A non-finite (`NaN`/`Infinity`)
|
|
132
|
+
* or out-of-range number — the latter makes `toISOString` throw — drops the `ts` field from the
|
|
133
|
+
* copy rather than killing the whole batch.
|
|
134
|
+
*/
|
|
135
|
+
const toWireEvents = (events: ClientEvent[]): ClientEvent[] =>
|
|
136
|
+
events.map((event) => {
|
|
137
|
+
if (typeof event.ts !== "number") return event;
|
|
138
|
+
const { ts, ...rest } = event;
|
|
139
|
+
if (!Number.isFinite(ts)) return rest;
|
|
140
|
+
try {
|
|
141
|
+
return { ...rest, ts: new Date(ts).toISOString() };
|
|
142
|
+
} catch {
|
|
143
|
+
// Out-of-range epoch-ms — send the event WITHOUT a ts rather than lose the batch.
|
|
144
|
+
return rest;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
108
148
|
/**
|
|
109
149
|
* The ONE place the `/v1/events` POST is described (url + method + headers + body). Both the
|
|
110
150
|
* fire-and-forget {@link reportClientEvents} and the awaitable {@link reportClientEventsAwait}
|
|
111
151
|
* build their request here so there is a SINGLE definition of the events transport — no second
|
|
112
152
|
* copy of the endpoint path, headers, or envelope shape to drift. Returns `null` when there is
|
|
113
153
|
* nothing to send (no target / no events) or serialization throws, so callers just bail.
|
|
154
|
+
*
|
|
155
|
+
* It is also where the internal epoch-ms `ts` becomes the wire's ISO8601 string — see
|
|
156
|
+
* {@link toWireEvents} for why the conversion belongs at this choke point.
|
|
114
157
|
*/
|
|
115
158
|
export const buildEventsRequest = (
|
|
116
159
|
target: { serverUrl: string; apiKey?: string } | undefined,
|
|
@@ -121,15 +164,18 @@ export const buildEventsRequest = (
|
|
|
121
164
|
const url = `${target.serverUrl.replace(/\/$/, "")}/v1/events`;
|
|
122
165
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
123
166
|
if (target.apiKey) headers.Authorization = `Bearer ${target.apiKey}`;
|
|
124
|
-
|
|
167
|
+
const body = JSON.stringify({ events: toWireEvents(events) });
|
|
168
|
+
return { url, init: { method: "POST", headers, body } };
|
|
125
169
|
} catch {
|
|
126
170
|
// URL construction or JSON serialization failed — nothing to send.
|
|
127
171
|
return null;
|
|
128
172
|
}
|
|
129
173
|
};
|
|
130
174
|
|
|
131
|
-
/** What the `/v1/events` endpoint says in its 200 body.
|
|
132
|
-
*
|
|
175
|
+
/** What the `/v1/events` endpoint says in its 200 body. The deployed server DOES send `errors[]`,
|
|
176
|
+
* each entry carrying `{index, reason, field}` — `field` names the property that failed validation
|
|
177
|
+
* (it is what identified the `ts` rejection), so it is folded into the reason string here. Still
|
|
178
|
+
* read defensively: an older server sends no `errors` at all. */
|
|
133
179
|
type EventsAck = { written?: number; skipped: number; reasons: string[] };
|
|
134
180
|
|
|
135
181
|
/**
|
|
@@ -153,7 +199,17 @@ const readEventsAck = async (res: unknown): Promise<EventsAck | undefined> => {
|
|
|
153
199
|
if (typeof skipped !== "number" || !Number.isFinite(skipped)) return undefined;
|
|
154
200
|
const reasons = Array.isArray(body?.errors)
|
|
155
201
|
? body.errors
|
|
156
|
-
.map((e) =>
|
|
202
|
+
.map((e) => {
|
|
203
|
+
const entry = e as { reason?: unknown; field?: unknown } | null;
|
|
204
|
+
const reason = entry?.reason;
|
|
205
|
+
if (typeof reason !== "string") return undefined;
|
|
206
|
+
// `field` is the whole point of a validation error — a bare "validation_error" sends the
|
|
207
|
+
// reader hunting; "validation_error (field: ts)" names the property the server refused.
|
|
208
|
+
const field = entry?.field;
|
|
209
|
+
return typeof field === "string" && field.length > 0
|
|
210
|
+
? `${reason} (field: ${field})`
|
|
211
|
+
: reason;
|
|
212
|
+
})
|
|
157
213
|
.filter((r): r is string => typeof r === "string")
|
|
158
214
|
: [];
|
|
159
215
|
return { written: typeof body?.written === "number" ? body.written : undefined, skipped, reasons };
|
|
@@ -104,7 +104,7 @@ const _OptionCard: React.FC<OptionCardProps> = ({ opt, isSelected, multiSelect,
|
|
|
104
104
|
const handlePressOut = useCallback(() => pressTo(1), [pressTo]);
|
|
105
105
|
|
|
106
106
|
// Selected fill: accent border + tinted wash on an absolute overlay whose opacity crossfades
|
|
107
|
-
// (native driver
|
|
107
|
+
// (native driver, never a color interpolation). Reduce motion: snap.
|
|
108
108
|
const selectT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
|
|
109
109
|
useEffect(() => {
|
|
110
110
|
const anim = Animated.timing(selectT, {
|
|
@@ -80,7 +80,7 @@ const _ChipItem: React.FC<ChipItemProps> = ({ chip, isSelected, multiSelect, onT
|
|
|
80
80
|
const handlePressOut = useCallback(() => pressTo(1), [pressTo]);
|
|
81
81
|
|
|
82
82
|
// Selected fill: full accent pill, crossfaded in over 160ms on a native-driver
|
|
83
|
-
// opacity overlay
|
|
83
|
+
// opacity overlay, never a color interpolation. Reduce motion: instant swap.
|
|
84
84
|
const fillT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
|
|
85
85
|
useEffect(() => {
|
|
86
86
|
const anim = Animated.timing(fillT, {
|
|
@@ -94,7 +94,7 @@ const _ChipItem: React.FC<ChipItemProps> = ({ chip, isSelected, multiSelect, onT
|
|
|
94
94
|
}, [isSelected, reduced, fillT]);
|
|
95
95
|
|
|
96
96
|
// Check pop on select (the width-slide of the canvas is a layout animation the
|
|
97
|
-
// native driver can't express
|
|
97
|
+
// native driver can't express, so it stays on the JS driver deliberately).
|
|
98
98
|
const checkT = useRef(new Animated.Value(isSelected ? 1 : 0)).current;
|
|
99
99
|
useEffect(() => {
|
|
100
100
|
if (!isSelected) {
|