@thravic/react-native 0.0.0-stage → 0.2.0

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/src/client.ts ADDED
@@ -0,0 +1,449 @@
1
+ import type {
2
+ CollectEvent,
3
+ Environment,
4
+ Experiment,
5
+ NavigationRefLike,
6
+ ThravicOptions,
7
+ } from './types';
8
+
9
+ export const VISITOR_KEY = '_tf_vid';
10
+ export const QUEUE_KEY = '_tf_queue';
11
+ export const OPT_OUT_KEY = '_tf_optout';
12
+
13
+ export const DEFAULT_API_URL = 'https://api.thravic.com';
14
+ /** The collector takes at most 50 events per batch. */
15
+ export const MAX_BATCH = 50;
16
+ /** Queued events kept while offline; the oldest go first. */
17
+ export const MAX_QUEUE = 1000;
18
+ /** Send without waiting for the timer once this many are queued. */
19
+ const FLUSH_AT = 10;
20
+ const MAX_RETRY_DELAY_MS = 30_000;
21
+ const PERSIST_DELAY_MS = 1_000;
22
+ /** How long `variant` waits for the running tests before answering null. */
23
+ export const TESTS_WAIT_MS = 3_000;
24
+
25
+ const DEFAULTS = {
26
+ apiUrl: DEFAULT_API_URL,
27
+ sessionTimeoutMinutes: 30,
28
+ flushIntervalSeconds: 10,
29
+ debug: false,
30
+ };
31
+
32
+ type Resolved = typeof DEFAULTS & ThravicOptions;
33
+
34
+ /** A v4-shaped id, like the web tracker's. Not cryptographic: ids only need to be unique. */
35
+ export function generateId(): string {
36
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
37
+ const r = (Math.random() * 16) | 0;
38
+ return (c === 'x' ? r : (r & 3) | 8).toString(16);
39
+ });
40
+ }
41
+
42
+ /** FNV-1a, 32-bit, as a number in [0, 1). The web tracker's hash, so both split alike. */
43
+ export function unitHash(text: string): number {
44
+ let h = 0x811c9dc5;
45
+ for (let i = 0; i < text.length; i++) {
46
+ h ^= text.charCodeAt(i);
47
+ h = Math.imul(h, 0x01000193);
48
+ }
49
+ return (h >>> 0) / 4294967296;
50
+ }
51
+
52
+ /**
53
+ * The version of `test` for `visitorId`, or null when they are outside its traffic
54
+ * share. Worked out from a hash, so the same user always gets the same version,
55
+ * exactly as the web tracker would give it.
56
+ */
57
+ export function assignVariant(visitorId: string, test: Experiment): string | null {
58
+ if (!test.variants.length) return null;
59
+ if (unitHash(`${visitorId}:${test.id}:traffic`) >= test.traffic / 100) return null;
60
+ const weights = test.variants.map((v) => Math.max(v.weight, 0));
61
+ const total = weights.reduce((sum, w) => sum + w, 0);
62
+ const pick = unitHash(`${visitorId}:${test.id}:variant`);
63
+ let edge = 0;
64
+ for (let i = 0; i < test.variants.length; i++) {
65
+ edge += total > 0 ? weights[i] / total : 1 / test.variants.length;
66
+ if (pick < edge) return test.variants[i].id;
67
+ }
68
+ return test.variants[test.variants.length - 1].id;
69
+ }
70
+
71
+ /**
72
+ * The SDK. Screen views are sent as `pageview` events on `app://<bundleId>/<Screen>`
73
+ * URLs, so the dashboard's pages, paths and funnels work for apps as they do for
74
+ * websites. Events are queued on the device and sent in batches; a batch the API
75
+ * has not acknowledged stays queued and is retried, including after a restart.
76
+ */
77
+ export class ThravicClient {
78
+ private env: Environment;
79
+ private trackingId = '';
80
+ private options: Resolved = { ...DEFAULTS };
81
+ private initialized = false;
82
+ private ready = false;
83
+ /** Calls made before storage has loaded, replayed in order once it has. */
84
+ private pending: Array<() => void> = [];
85
+
86
+ private visitorId = '';
87
+ private sessionId = '';
88
+ private sessionStart = 0;
89
+ private backgroundAt: number | null = null;
90
+ private currentScreen: string | null = null;
91
+ private optedOut = false;
92
+
93
+ private queue: CollectEvent[] = [];
94
+ private sending = false;
95
+ private retryAttempt = 0;
96
+ private retryScheduled = false;
97
+ private persistScheduled = false;
98
+ private timer: unknown = null;
99
+ private unsubscribeAppState: (() => void) | null = null;
100
+
101
+ /** This session's running tests, loaded the first time `variant` is asked. */
102
+ private tests: Promise<Experiment[]> | null = null;
103
+ /** Tests whose `experiment_view` this session has already sent. */
104
+ private exposed = new Set<string>();
105
+
106
+ constructor(env: Environment) {
107
+ this.env = env;
108
+ }
109
+
110
+ /** Start tracking. Call once, when the app starts; later calls are ignored. */
111
+ async init(trackingId: string, options: ThravicOptions = {}): Promise<void> {
112
+ if (this.initialized) return;
113
+ if (!trackingId) {
114
+ this.env.log('[Thravic] init needs your tracking ID, e.g. Thravic.init("TF-1A2B3C4D").');
115
+ return;
116
+ }
117
+ this.initialized = true;
118
+ this.trackingId = trackingId;
119
+ this.options = { ...DEFAULTS, ...stripUndefined(options) };
120
+ this.options.apiUrl = this.options.apiUrl.replace(/\/+$/, '');
121
+
122
+ const { storage } = this.env;
123
+ try {
124
+ this.optedOut = (await storage.getItem(OPT_OUT_KEY)) === '1';
125
+ this.visitorId = (await storage.getItem(VISITOR_KEY)) || '';
126
+ if (!this.visitorId) {
127
+ this.visitorId = generateId();
128
+ await storage.setItem(VISITOR_KEY, this.visitorId);
129
+ }
130
+ this.queue = parseQueue(await storage.getItem(QUEUE_KEY));
131
+ } catch (err) {
132
+ // Storage unavailable: track this run only.
133
+ this.visitorId = this.visitorId || generateId();
134
+ this.debug('storage unavailable', err);
135
+ }
136
+
137
+ this.startSession();
138
+ this.unsubscribeAppState = this.env.onAppStateChange((active) =>
139
+ active ? this.foreground() : this.background(),
140
+ );
141
+ this.timer = this.env.setInterval(
142
+ () => void this.flush(),
143
+ this.options.flushIntervalSeconds * 1000,
144
+ );
145
+
146
+ this.ready = true;
147
+ const pending = this.pending;
148
+ this.pending = [];
149
+ pending.forEach((call) => call());
150
+ void this.flush();
151
+ }
152
+
153
+ /** Record a screen view. Automatic with `trackNavigation`; call it yourself otherwise. */
154
+ screen(name: string, params?: Record<string, unknown>): void {
155
+ this.whenReady(() => {
156
+ const screen = String(name || '').trim().replace(/^\/+/, '');
157
+ if (!screen) return;
158
+ this.currentScreen = screen;
159
+ this.enqueue('pageview', params && Object.keys(params).length ? { params } : undefined);
160
+ });
161
+ }
162
+
163
+ /** Record a custom event, e.g. `track('purchase', { amount: 49 })`. */
164
+ track(event: string, properties?: Record<string, unknown>): void {
165
+ this.whenReady(() => {
166
+ if (!event) return;
167
+ this.enqueue('custom', { ...properties, event });
168
+ });
169
+ }
170
+
171
+ /** Link this device to your own user id. Sent as the web tracker sends it. */
172
+ identify(userId: string, traits?: Record<string, unknown>): void {
173
+ this.whenReady(() => {
174
+ if (!userId) return;
175
+ this.enqueue('custom', { ...traits, event: 'identify', userId: String(userId) });
176
+ });
177
+ }
178
+
179
+ /**
180
+ * The version of A/B test `key` to show this user: a version id from the
181
+ * dashboard, or null for the original (not in the test, the test is not
182
+ * running, tracking is off, or the tests could not be loaded in time).
183
+ * The first answer in a session records that the user saw the test.
184
+ */
185
+ variant(key: string): Promise<string | null> {
186
+ return new Promise((resolve) => {
187
+ if (!this.initialized) {
188
+ this.env.log('[Thravic] Call Thravic.init before asking for a variant.');
189
+ resolve(null);
190
+ return;
191
+ }
192
+ this.whenReady(() => {
193
+ if (this.optedOut) {
194
+ resolve(null);
195
+ return;
196
+ }
197
+ void this.loadTests().then((tests) => {
198
+ const test = tests.find((t) => t.key === key);
199
+ const id = test ? assignVariant(this.visitorId, test) : null;
200
+ if (test && id && !this.exposed.has(test.id)) {
201
+ this.exposed.add(test.id);
202
+ this.enqueue('custom', { event: 'experiment_view', experiment: test.id, variant: id });
203
+ }
204
+ resolve(id);
205
+ });
206
+ });
207
+ });
208
+ }
209
+
210
+ /** Stop tracking this device, and drop anything not yet sent. Remembered across launches. */
211
+ optOut(): void {
212
+ this.optedOut = true;
213
+ this.queue = [];
214
+ void this.env.storage.setItem(OPT_OUT_KEY, '1').catch(() => undefined);
215
+ void this.env.storage.removeItem(QUEUE_KEY).catch(() => undefined);
216
+ }
217
+
218
+ /** Resume tracking after `optOut`. */
219
+ optIn(): void {
220
+ this.optedOut = false;
221
+ void this.env.storage.removeItem(OPT_OUT_KEY).catch(() => undefined);
222
+ }
223
+
224
+ isOptedOut(): boolean {
225
+ return this.optedOut;
226
+ }
227
+
228
+ /**
229
+ * Record a screen view whenever the focused route changes. Pass the ref from
230
+ * `createNavigationContainerRef()` / `useNavigationContainerRef()` (React
231
+ * Navigation or Expo Router). Returns a function that stops it.
232
+ */
233
+ trackNavigation(ref: NavigationRefLike): () => void {
234
+ let last: string | null = null;
235
+ const onState = () => {
236
+ if (ref.isReady && !ref.isReady()) return;
237
+ const route = ref.getCurrentRoute();
238
+ if (!route || route.name === last) return;
239
+ last = route.name;
240
+ this.screen(route.name);
241
+ };
242
+ const unsubscribe = ref.addListener('state', onState);
243
+ onState();
244
+ return unsubscribe;
245
+ }
246
+
247
+ /** Send everything queued now. Resolves when the attempt is done. */
248
+ async flush(): Promise<void> {
249
+ if (!this.ready || this.sending || this.optedOut || this.queue.length === 0) return;
250
+ this.sending = true;
251
+ const batch = this.queue.slice(0, MAX_BATCH);
252
+ const sent = new Set(batch.map((e) => e.eventId));
253
+ let more = false;
254
+ try {
255
+ const response = await this.env.fetch(
256
+ `${this.options.apiUrl}/api/collect/${encodeURIComponent(this.trackingId)}/batch`,
257
+ {
258
+ method: 'POST',
259
+ headers: { 'Content-Type': 'application/json' },
260
+ body: JSON.stringify({ events: batch }),
261
+ },
262
+ );
263
+ // A 4xx other than 429 will never succeed: drop the batch rather than
264
+ // retrying it forever, as the web tracker does.
265
+ const rejected =
266
+ response.status >= 400 && response.status < 500 && response.status !== 429;
267
+ if (!response.ok && !rejected) throw new Error(`HTTP ${response.status}`);
268
+ if (rejected) this.debug(`batch rejected with ${response.status}`);
269
+
270
+ this.queue = this.queue.filter((e) => !sent.has(e.eventId));
271
+ this.retryAttempt = 0;
272
+ this.persist();
273
+ more = this.queue.length > 0;
274
+ } catch (err) {
275
+ this.debug('send failed, will retry', err);
276
+ this.scheduleRetry();
277
+ } finally {
278
+ this.sending = false;
279
+ }
280
+ if (more) await this.flush();
281
+ }
282
+
283
+ /** Stop timers and listeners. For tests and hot reload; apps never need it. */
284
+ shutdown(): void {
285
+ if (this.timer !== null) this.env.clearInterval(this.timer);
286
+ this.unsubscribeAppState?.();
287
+ this.timer = null;
288
+ this.unsubscribeAppState = null;
289
+ }
290
+
291
+ // ── Sessions ─────────────────────────────────────────────────────────────
292
+
293
+ private startSession(): void {
294
+ this.sessionId = generateId();
295
+ this.sessionStart = this.env.now();
296
+ this.backgroundAt = null;
297
+ // A new session picks up tests started or ended since the last one.
298
+ this.tests = null;
299
+ this.exposed.clear();
300
+ this.enqueue('custom', { event: 'app_open' });
301
+ }
302
+
303
+ private background(): void {
304
+ if (!this.ready || this.backgroundAt !== null) return;
305
+ this.backgroundAt = this.env.now();
306
+ this.enqueue('session_end', {
307
+ duration: Math.round((this.backgroundAt - this.sessionStart) / 1000),
308
+ lastPage: this.screenUrl(),
309
+ });
310
+ void this.flush();
311
+ }
312
+
313
+ private foreground(): void {
314
+ if (!this.ready || this.backgroundAt === null) return;
315
+ const away = this.env.now() - this.backgroundAt;
316
+ this.backgroundAt = null;
317
+ if (away < this.options.sessionTimeoutMinutes * 60_000) return;
318
+
319
+ // Long enough away that this is a new visit; it starts on the screen in view.
320
+ this.startSession();
321
+ if (this.currentScreen) this.enqueue('pageview');
322
+ }
323
+
324
+ // ── A/B tests ────────────────────────────────────────────────────────────
325
+
326
+ /** The running tests, or none when they fail to load or take too long. */
327
+ private loadTests(): Promise<Experiment[]> {
328
+ if (!this.tests) {
329
+ this.tests = new Promise((resolve) => {
330
+ let done = false;
331
+ const settle = (list: unknown) => {
332
+ if (done) return;
333
+ done = true;
334
+ resolve(parseTests(list));
335
+ };
336
+ this.env.setTimeout(() => settle([]), TESTS_WAIT_MS);
337
+ this.env
338
+ .fetch(`${this.options.apiUrl}/api/collect/${encodeURIComponent(this.trackingId)}/config`)
339
+ .then((response) => (response.ok ? response.json() : null))
340
+ .then((config) => settle(config?.experiments))
341
+ .catch((err) => {
342
+ this.debug('tests could not be loaded', err);
343
+ settle([]);
344
+ });
345
+ });
346
+ }
347
+ return this.tests;
348
+ }
349
+
350
+ // ── Queue ────────────────────────────────────────────────────────────────
351
+
352
+ private whenReady(call: () => void): void {
353
+ if (this.ready) call();
354
+ else if (this.initialized) this.pending.push(call);
355
+ else this.env.log('[Thravic] Call Thravic.init before tracking.');
356
+ }
357
+
358
+ private screenUrl(): string {
359
+ const host = this.options.bundleId || 'app';
360
+ return `app://${host}/${encodeURI(this.currentScreen || '')}`;
361
+ }
362
+
363
+ private enqueue(type: CollectEvent['type'], data?: Record<string, unknown>): void {
364
+ if (this.optedOut) return;
365
+ const device = this.env.device();
366
+ const event: CollectEvent = {
367
+ eventId: generateId(),
368
+ type,
369
+ url: this.screenUrl(),
370
+ visitorId: this.visitorId,
371
+ sessionId: this.sessionId,
372
+ ...stripUndefined({
373
+ os: device.os,
374
+ osVersion: device.osVersion,
375
+ deviceModel: device.deviceModel,
376
+ appVersion: this.options.appVersion,
377
+ screenWidth: device.screenWidth,
378
+ screenHeight: device.screenHeight,
379
+ language: device.language,
380
+ data,
381
+ }),
382
+ };
383
+ this.queue.push(event);
384
+ if (this.queue.length > MAX_QUEUE) this.queue = this.queue.slice(-MAX_QUEUE);
385
+ this.debug(type, event.url, data ?? '');
386
+ this.persist();
387
+ if (this.queue.length >= FLUSH_AT) void this.flush();
388
+ }
389
+
390
+ /** Write the queue to storage, at most once a second. */
391
+ private persist(): void {
392
+ if (this.persistScheduled) return;
393
+ this.persistScheduled = true;
394
+ this.env.setTimeout(() => {
395
+ this.persistScheduled = false;
396
+ const write =
397
+ this.queue.length === 0
398
+ ? this.env.storage.removeItem(QUEUE_KEY)
399
+ : this.env.storage.setItem(QUEUE_KEY, JSON.stringify(this.queue));
400
+ void write.catch(() => undefined);
401
+ }, PERSIST_DELAY_MS);
402
+ }
403
+
404
+ private scheduleRetry(): void {
405
+ if (this.retryScheduled) return;
406
+ this.retryScheduled = true;
407
+ const delay = Math.min(1000 * 2 ** this.retryAttempt, MAX_RETRY_DELAY_MS);
408
+ this.retryAttempt += 1;
409
+ this.env.setTimeout(() => {
410
+ this.retryScheduled = false;
411
+ void this.flush();
412
+ }, delay);
413
+ }
414
+
415
+ private debug(...args: unknown[]): void {
416
+ if (this.options.debug) this.env.log('[Thravic]', ...args);
417
+ }
418
+ }
419
+
420
+ function parseQueue(raw: string | null): CollectEvent[] {
421
+ if (!raw) return [];
422
+ try {
423
+ const parsed = JSON.parse(raw);
424
+ if (!Array.isArray(parsed)) return [];
425
+ return parsed
426
+ .filter((e) => e && typeof e === 'object' && typeof e.eventId === 'string')
427
+ .slice(-MAX_QUEUE);
428
+ } catch {
429
+ return [];
430
+ }
431
+ }
432
+
433
+ function parseTests(list: unknown): Experiment[] {
434
+ if (!Array.isArray(list)) return [];
435
+ return list.filter(
436
+ (t): t is Experiment =>
437
+ !!t &&
438
+ typeof t.id === 'string' &&
439
+ typeof t.key === 'string' &&
440
+ typeof t.traffic === 'number' &&
441
+ Array.isArray(t.variants),
442
+ );
443
+ }
444
+
445
+ function stripUndefined<T extends object>(value: T): Partial<T> {
446
+ return Object.fromEntries(
447
+ Object.entries(value).filter(([, v]) => v !== undefined && v !== null && v !== ''),
448
+ ) as Partial<T>;
449
+ }
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ import { ThravicClient } from './client';
4
+ import { reactNativeEnvironment } from './react-native';
5
+
6
+ export { ThravicClient, assignVariant } from './client';
7
+ export type {
8
+ CollectEvent,
9
+ DeviceInfo,
10
+ Environment,
11
+ Experiment,
12
+ NavigationRefLike,
13
+ Storage,
14
+ ThravicOptions,
15
+ } from './types';
16
+
17
+ /**
18
+ * Thravic for React Native.
19
+ *
20
+ * ```ts
21
+ * import { Thravic } from '@thravic/react-native';
22
+ *
23
+ * Thravic.init('TF-1A2B3C4D', { appVersion: '1.4.0', bundleId: 'com.acme.shop' });
24
+ * Thravic.trackNavigation(navigationRef); // screen views, automatically
25
+ * Thravic.track('purchase', { amount: 49 }); // your own events
26
+ * const version = await Thravic.variant('new-paywall'); // A/B tests
27
+ * ```
28
+ */
29
+ export const Thravic = new ThravicClient(reactNativeEnvironment);
30
+
31
+ /**
32
+ * The version of A/B test `key` to show, for use in a component: `undefined`
33
+ * while it loads, then a version id or null for the original.
34
+ *
35
+ * ```tsx
36
+ * const version = useVariant('new-paywall');
37
+ * if (version === undefined) return null;
38
+ * return version === 'b' ? <NewPaywall /> : <Paywall />;
39
+ * ```
40
+ */
41
+ export function useVariant(key: string): string | null | undefined {
42
+ const [version, setVersion] = useState<string | null | undefined>(undefined);
43
+ useEffect(() => {
44
+ let live = true;
45
+ setVersion(undefined);
46
+ void Thravic.variant(key).then((id) => {
47
+ if (live) setVersion(id);
48
+ });
49
+ return () => {
50
+ live = false;
51
+ };
52
+ }, [key]);
53
+ return version;
54
+ }
55
+
56
+ export default Thravic;
@@ -0,0 +1,71 @@
1
+ import AsyncStorage from '@react-native-async-storage/async-storage';
2
+ import { AppState, Dimensions, Platform } from 'react-native';
3
+
4
+ import type { DeviceInfo, Environment } from './types';
5
+
6
+ type Constants = {
7
+ Release?: string;
8
+ Model?: string;
9
+ Manufacturer?: string;
10
+ osVersion?: string;
11
+ interfaceIdiom?: string;
12
+ };
13
+
14
+ function language(): string | undefined {
15
+ try {
16
+ return Intl.DateTimeFormat().resolvedOptions().locale;
17
+ } catch {
18
+ return undefined;
19
+ }
20
+ }
21
+
22
+ /** What React Native itself knows about the device, with no native module to install. */
23
+ function device(): DeviceInfo {
24
+ const constants = (Platform.constants || {}) as Constants;
25
+ const screen = Dimensions.get('screen');
26
+ const base = {
27
+ screenWidth: Math.round(screen.width),
28
+ screenHeight: Math.round(screen.height),
29
+ language: language(),
30
+ };
31
+
32
+ if (Platform.OS === 'android') {
33
+ return {
34
+ ...base,
35
+ os: 'Android',
36
+ // `Platform.Version` is the API level on Android; Release is "14".
37
+ osVersion: constants.Release || String(Platform.Version),
38
+ deviceModel: [constants.Manufacturer, constants.Model].filter(Boolean).join(' ') || undefined,
39
+ };
40
+ }
41
+ if (Platform.OS === 'ios') {
42
+ return {
43
+ ...base,
44
+ os: 'iOS',
45
+ osVersion: String(Platform.Version),
46
+ // iOS does not expose the model without a native module.
47
+ deviceModel: constants.interfaceIdiom === 'pad' ? 'iPad' : 'iPhone',
48
+ };
49
+ }
50
+ return { ...base, os: Platform.OS };
51
+ }
52
+
53
+ export const reactNativeEnvironment: Environment = {
54
+ storage: AsyncStorage,
55
+ device,
56
+ fetch: (...args) => fetch(...args),
57
+ now: () => Date.now(),
58
+ onAppStateChange(listener) {
59
+ // 'inactive' is iOS passing through (Control Center, a call banner): not a
60
+ // departure. Only 'background' ends the visit and only 'active' resumes it.
61
+ const subscription = AppState.addEventListener('change', (state) => {
62
+ if (state === 'active') listener(true);
63
+ else if (state === 'background') listener(false);
64
+ });
65
+ return () => subscription.remove();
66
+ },
67
+ setInterval: (fn, ms) => setInterval(fn, ms),
68
+ clearInterval: (handle) => clearInterval(handle as ReturnType<typeof setInterval>),
69
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
70
+ log: (...args) => console.warn(...args),
71
+ };
package/src/types.ts ADDED
@@ -0,0 +1,86 @@
1
+ /** Options for `Thravic.init`. */
2
+ export interface ThravicOptions {
3
+ /** Where the Thravic API lives. The dashboard's install snippet fills this in. */
4
+ apiUrl?: string;
5
+ /** Your app's version, e.g. `Application.nativeApplicationVersion` (Expo) or
6
+ * `DeviceInfo.getVersion()` (react-native-device-info). Shown under Versions. */
7
+ appVersion?: string;
8
+ /** Your app's bundle ID, used as the host of screen URLs (`app://<bundleId>/Home`). */
9
+ bundleId?: string;
10
+ /** Minutes in the background after which the next open starts a new session. */
11
+ sessionTimeoutMinutes?: number;
12
+ /** Seconds between automatic sends of queued events. */
13
+ flushIntervalSeconds?: number;
14
+ /** Log what the SDK does to the console. */
15
+ debug?: boolean;
16
+ }
17
+
18
+ /** One event as `POST /api/collect/{trackingId}/batch` takes it. */
19
+ export interface CollectEvent {
20
+ eventId: string;
21
+ type: 'pageview' | 'custom' | 'session_end';
22
+ url: string;
23
+ visitorId: string;
24
+ sessionId: string;
25
+ screenWidth?: number;
26
+ screenHeight?: number;
27
+ language?: string;
28
+ os?: string;
29
+ osVersion?: string;
30
+ appVersion?: string;
31
+ deviceModel?: string;
32
+ utmSource?: string;
33
+ utmMedium?: string;
34
+ utmCampaign?: string;
35
+ data?: Record<string, unknown>;
36
+ }
37
+
38
+ /** A running A/B test, as `GET /api/collect/{trackingId}/config` lists it. */
39
+ export interface Experiment {
40
+ id: string;
41
+ /** What the app asks for: `Thravic.variant(key)`. */
42
+ key: string;
43
+ /** Share of users in the test, 0–100. */
44
+ traffic: number;
45
+ variants: Array<{ id: string; weight: number }>;
46
+ }
47
+
48
+ /** What the device reports about itself. */
49
+ export interface DeviceInfo {
50
+ os: string;
51
+ osVersion?: string;
52
+ deviceModel?: string;
53
+ screenWidth?: number;
54
+ screenHeight?: number;
55
+ language?: string;
56
+ }
57
+
58
+ /** Key-value storage that survives app restarts (AsyncStorage's shape). */
59
+ export interface Storage {
60
+ getItem(key: string): Promise<string | null>;
61
+ setItem(key: string, value: string): Promise<void>;
62
+ removeItem(key: string): Promise<void>;
63
+ }
64
+
65
+ /** Everything the client needs from its platform, so it can run outside React Native. */
66
+ export interface Environment {
67
+ storage: Storage;
68
+ device: () => DeviceInfo;
69
+ fetch: typeof fetch;
70
+ now: () => number;
71
+ /** Calls `listener(true)` when the app comes to the foreground and `listener(false)`
72
+ * when it leaves; returns an unsubscribe. */
73
+ onAppStateChange: (listener: (active: boolean) => void) => () => void;
74
+ setInterval: (fn: () => void, ms: number) => unknown;
75
+ clearInterval: (handle: unknown) => void;
76
+ setTimeout: (fn: () => void, ms: number) => unknown;
77
+ log: (...args: unknown[]) => void;
78
+ }
79
+
80
+ /** The parts of a React Navigation container ref the screen tracker uses. Expo
81
+ * Router's `useNavigationContainerRef()` returns one too. */
82
+ export interface NavigationRefLike {
83
+ addListener(event: 'state', listener: () => void): () => void;
84
+ getCurrentRoute(): { name: string; params?: object } | undefined;
85
+ isReady?(): boolean;
86
+ }