@teardown/react-native 2.0.28 → 2.0.30

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.
@@ -0,0 +1,102 @@
1
+ import type { AsyncResult } from "@teardown/types";
2
+ import type { ApiClient } from "../api";
3
+ import type { DeviceClient } from "../device/device.client";
4
+ import type { Logger, LoggingClient } from "../logging";
5
+
6
+ /**
7
+ * Event payload for tracking events
8
+ */
9
+ export interface EventPayload {
10
+ /** Name of the event (e.g., "user_signed_out", "button_clicked") */
11
+ event_name: string;
12
+ /** Type of event */
13
+ event_type?: "action" | "screen_view" | "custom";
14
+ /** Additional properties to attach to the event */
15
+ properties?: Record<string, unknown>;
16
+ /** ISO timestamp. Defaults to current time if not provided */
17
+ timestamp?: string;
18
+ }
19
+
20
+ /**
21
+ * EventsClient - Universal client for sending events to the backend
22
+ *
23
+ * This client provides a centralized way to track events from anywhere in the SDK.
24
+ * It automatically handles device ID retrieval and API communication.
25
+ */
26
+ export class EventsClient {
27
+ private readonly logger: Logger;
28
+
29
+ constructor(
30
+ logging: LoggingClient,
31
+ private readonly api: ApiClient,
32
+ private readonly device: DeviceClient
33
+ ) {
34
+ this.logger = logging.createLogger({
35
+ name: "EventsClient",
36
+ });
37
+ }
38
+
39
+ /**
40
+ * Track a single event
41
+ * @param event - The event payload to track
42
+ * @param sessionId - Optional session ID to associate with the event
43
+ * @returns AsyncResult indicating success/failure
44
+ */
45
+ async track(event: EventPayload, sessionId?: string): AsyncResult<void> {
46
+ return this.trackBatch([event], sessionId);
47
+ }
48
+
49
+ /**
50
+ * Track multiple events in a single batch
51
+ * @param events - Array of event payloads to track
52
+ * @param sessionId - Optional session ID to associate with all events
53
+ * @returns AsyncResult indicating success/failure
54
+ */
55
+ async trackBatch(events: EventPayload[], sessionId?: string): AsyncResult<void> {
56
+ if (events.length === 0) {
57
+ return { success: true, data: undefined };
58
+ }
59
+
60
+ this.logger.debug(`Tracking ${events.length} event(s)`, {
61
+ eventNames: events.map((e) => e.event_name),
62
+ });
63
+
64
+ try {
65
+ const deviceId = await this.device.getDeviceId();
66
+
67
+ const response = await this.api.client("/v1/events", {
68
+ method: "POST",
69
+ headers: {
70
+ "td-api-key": this.api.apiKey,
71
+ "td-org-id": this.api.orgId,
72
+ "td-project-id": this.api.projectId,
73
+ "td-environment-slug": this.api.environmentSlug,
74
+ "td-device-id": deviceId,
75
+ ...(sessionId ? { "td-session-id": sessionId } : {}),
76
+ },
77
+ body: {
78
+ events: events.map((event) => ({
79
+ event_name: event.event_name,
80
+ event_type: event.event_type ?? "custom",
81
+ properties: event.properties,
82
+ timestamp: event.timestamp ?? new Date().toISOString(),
83
+ })),
84
+ },
85
+ });
86
+
87
+ if (response.error != null) {
88
+ this.logger.warn("Failed to track events", { error: response.error });
89
+ return { success: false, error: "Failed to track events" };
90
+ }
91
+
92
+ this.logger.debug(`Successfully tracked ${events.length} event(s)`);
93
+ return { success: true, data: undefined };
94
+ } catch (error) {
95
+ this.logger.debugError("Error tracking events", { error });
96
+ return {
97
+ success: false,
98
+ error: error instanceof Error ? error.message : "Unknown error",
99
+ };
100
+ }
101
+ }
102
+ }
@@ -0,0 +1 @@
1
+ export { type EventPayload, EventsClient } from "./events.client";
@@ -32,9 +32,18 @@ export enum IdentifyVersionStatusEnum {
32
32
  export const InitializingVersionStatusSchema = z.object({ type: z.literal("initializing") });
33
33
  export const CheckingVersionStatusSchema = z.object({ type: z.literal("checking") });
34
34
  export const UpToDateVersionStatusSchema = z.object({ type: z.literal("up_to_date") });
35
- export const UpdateAvailableVersionStatusSchema = z.object({ type: z.literal("update_available") });
36
- export const UpdateRecommendedVersionStatusSchema = z.object({ type: z.literal("update_recommended") });
37
- export const UpdateRequiredVersionStatusSchema = z.object({ type: z.literal("update_required") });
35
+ export const UpdateAvailableVersionStatusSchema = z.object({
36
+ type: z.literal("update_available"),
37
+ releaseNotes: z.string().nullable().optional(),
38
+ });
39
+ export const UpdateRecommendedVersionStatusSchema = z.object({
40
+ type: z.literal("update_recommended"),
41
+ releaseNotes: z.string().nullable().optional(),
42
+ });
43
+ export const UpdateRequiredVersionStatusSchema = z.object({
44
+ type: z.literal("update_required"),
45
+ releaseNotes: z.string().nullable().optional(),
46
+ });
38
47
  export const DisabledVersionStatusSchema = z.object({ type: z.literal("disabled") });
39
48
  /**
40
49
  * The version status schema.
@@ -143,7 +152,7 @@ export class ForceUpdateClient {
143
152
  this.logger.debug(
144
153
  `Identity already identified, syncing version status from: ${currentState.version_info.status}`
145
154
  );
146
- this.updateFromVersionStatus(currentState.version_info.status);
155
+ this.updateFromVersionInfo(currentState.version_info);
147
156
  } else {
148
157
  this.logger.debug(`Identity not yet identified (${currentState.type}), waiting for identify event`);
149
158
  }
@@ -189,13 +198,19 @@ export class ForceUpdateClient {
189
198
  break;
190
199
  case "identified":
191
200
  this.logger.debug(`Identified with version_info.status: ${state.version_info.status}`);
192
- this.updateFromVersionStatus(state.version_info.status ?? IdentifyVersionStatusEnum.UP_TO_DATE);
201
+ this.updateFromVersionInfo(state.version_info);
193
202
  break;
194
203
  }
195
204
  });
196
205
  }
197
206
 
198
- private updateFromVersionStatus(status?: IdentifyVersionStatusEnum) {
207
+ private updateFromVersionInfo(versionInfo: {
208
+ status: IdentifyVersionStatusEnum;
209
+ update: { release_notes: string | null } | null;
210
+ }) {
211
+ const status = versionInfo.status;
212
+ const releaseNotes = versionInfo.update?.release_notes ?? null;
213
+
199
214
  if (!status) {
200
215
  this.setVersionStatus({ type: "up_to_date" });
201
216
  return;
@@ -203,13 +218,15 @@ export class ForceUpdateClient {
203
218
 
204
219
  switch (status) {
205
220
  case "UPDATE_AVAILABLE":
206
- this.setVersionStatus({ type: "update_available" });
221
+ this.setVersionStatus(releaseNotes ? { type: "update_available", releaseNotes } : { type: "update_available" });
207
222
  break;
208
223
  case "UPDATE_RECOMMENDED":
209
- this.setVersionStatus({ type: "update_recommended" });
224
+ this.setVersionStatus(
225
+ releaseNotes ? { type: "update_recommended", releaseNotes } : { type: "update_recommended" }
226
+ );
210
227
  break;
211
228
  case "UPDATE_REQUIRED":
212
- this.setVersionStatus({ type: "update_required" });
229
+ this.setVersionStatus(releaseNotes ? { type: "update_required", releaseNotes } : { type: "update_required" });
213
230
  break;
214
231
  case "UP_TO_DATE":
215
232
  this.setVersionStatus({ type: "up_to_date" });
@@ -127,6 +127,7 @@ function createMockApiClient(
127
127
  apiKey: "test-api-key",
128
128
  orgId: "test-org-id",
129
129
  projectId: "test-project-id",
130
+ environmentSlug: "production",
130
131
  client: async (endpoint: string, config: ApiCallRecord["config"]) => {
131
132
  calls.push({ endpoint, config });
132
133
 
@@ -2,9 +2,11 @@ import type { AsyncResult } from "@teardown/types";
2
2
  import { EventEmitter } from "eventemitter3";
3
3
  import { z } from "zod";
4
4
  import type { ApiClient } from "../api";
5
- import type { DeviceClient } from "../device/device.client";
5
+ import type { DeviceClient, NotificationPlatformEnum } from "../device/device.client";
6
+ import type { EventsClient } from "../events";
6
7
  import { IdentifyVersionStatusEnum } from "../force-update";
7
8
  import type { Logger, LoggingClient } from "../logging";
9
+ import type { NotificationsClient } from "../notifications/notifications.client";
8
10
  import type { StorageClient, SupportedStorage } from "../storage";
9
11
  import type { UtilsClient } from "../utils";
10
12
 
@@ -14,6 +16,26 @@ export interface Persona {
14
16
  email?: string | undefined;
15
17
  }
16
18
 
19
+ export interface SignOutOptions {
20
+ /**
21
+ * Additional properties to include in the sign out event
22
+ */
23
+ properties?: Record<string, unknown>;
24
+ /**
25
+ * Whether to wait for the event to be sent before clearing state.
26
+ * Default: true (fire-and-forget if false)
27
+ */
28
+ waitForEvent?: boolean;
29
+ }
30
+
31
+ export interface UpdateInfo {
32
+ version: string;
33
+ build: string;
34
+ update_id: string;
35
+ effective_date: Date;
36
+ release_notes: string | null;
37
+ }
38
+
17
39
  export interface IdentityUser {
18
40
  session_id: string;
19
41
  device_id: string;
@@ -21,7 +43,7 @@ export interface IdentityUser {
21
43
  token: string;
22
44
  version_info: {
23
45
  status: IdentifyVersionStatusEnum;
24
- update: null;
46
+ update: UpdateInfo | null;
25
47
  };
26
48
  }
27
49
 
@@ -49,13 +71,50 @@ export const VersionStatusResponseSchema = z.object({
49
71
  latest_version: z.string().optional(),
50
72
  });
51
73
 
74
+ export const UpdateInfoSchema = z.object({
75
+ version: z.string(),
76
+ build: z.string(),
77
+ update_id: z.string(),
78
+ effective_date: z.coerce.date(),
79
+ release_notes: z.string().nullable(),
80
+ });
81
+
82
+ /** Type guard to check if update has nested structure */
83
+ function isNestedUpdate(update: unknown): update is { status: string; update: UpdateInfo } {
84
+ return typeof update === "object" && update !== null && "status" in update && "update" in update;
85
+ }
86
+
87
+ // Accept either the nested schema from API or the flat UpdateInfo schema
88
+ export const VersionInfoResponseSchema = z.object({
89
+ status: z.enum(IdentifyVersionStatusEnum),
90
+ update: z.union([
91
+ z.object({
92
+ status: z.enum(IdentifyVersionStatusEnum),
93
+ update: UpdateInfoSchema,
94
+ }),
95
+ UpdateInfoSchema,
96
+ z.null(),
97
+ ]),
98
+ });
99
+
52
100
  export const IdentifiedSessionStateSchema = z.object({
53
101
  type: z.literal("identified"),
54
102
  session: SessionSchema,
55
- version_info: z.object({
56
- status: z.enum(IdentifyVersionStatusEnum),
57
- update: z.null(),
58
- }),
103
+ version_info: z
104
+ .object({
105
+ status: z.enum(IdentifyVersionStatusEnum),
106
+ update: UpdateInfoSchema.nullable(),
107
+ })
108
+ .transform((data) => {
109
+ // Flatten nested structure if present
110
+ if (isNestedUpdate(data.update)) {
111
+ return {
112
+ status: data.status,
113
+ update: data.update.update,
114
+ };
115
+ }
116
+ return data;
117
+ }),
59
118
  });
60
119
 
61
120
  export const IdentifyStateSchema = z.discriminatedUnion("type", [
@@ -89,7 +148,9 @@ export class IdentityClient {
89
148
  utils: UtilsClient,
90
149
  storage: StorageClient,
91
150
  private readonly api: ApiClient,
92
- private readonly device: DeviceClient
151
+ private readonly device: DeviceClient,
152
+ private readonly events: EventsClient,
153
+ private readonly notificationsClient?: NotificationsClient
93
154
  ) {
94
155
  this.logger = logging.createLogger({
95
156
  name: "IdentityClient",
@@ -187,11 +248,109 @@ export class IdentityClient {
187
248
  this.emitter.removeAllListeners("IDENTIFY_STATE_CHANGED");
188
249
  }
189
250
 
190
- public reset() {
251
+ /**
252
+ * Reset identity state to unidentified.
253
+ * Clears stored state and emits state change.
254
+ */
255
+ public reset(): void {
256
+ this.setIdentifyState({ type: "unidentified" });
257
+ }
258
+
259
+ /**
260
+ * Internal method to clear identity state.
261
+ * Used by signOut() and signOutAll().
262
+ */
263
+ private clearIdentityState(): void {
191
264
  this.storage.removeItem(IDENTIFY_STORAGE_KEY);
192
265
  this.setIdentifyState({ type: "unidentified" });
193
266
  }
194
267
 
268
+ /**
269
+ * Sign out the current user.
270
+ * Sends a sign_out event to the backend and clears session/user identity.
271
+ * The deviceId is preserved - the same device will be recognized on next identify.
272
+ *
273
+ * @param options - Optional configuration for the sign out
274
+ * @returns AsyncResult indicating success/failure of the event send
275
+ */
276
+ async signOut(options?: SignOutOptions): AsyncResult<void> {
277
+ this.logger.debugInfo("Signing out user");
278
+
279
+ const session = this.getSessionState();
280
+ const waitForEvent = options?.waitForEvent ?? true;
281
+
282
+ // Send event FIRST while session/device data still exists
283
+ const eventPromise = this.events.track(
284
+ {
285
+ event_name: "user_signed_out",
286
+ event_type: "action",
287
+ properties: {
288
+ sign_out_type: "sign_out",
289
+ session_id: session?.session_id,
290
+ user_id: session?.user_id,
291
+ ...options?.properties,
292
+ },
293
+ },
294
+ session?.session_id
295
+ );
296
+
297
+ if (waitForEvent) {
298
+ const result = await eventPromise;
299
+ // Clear state regardless of event send result
300
+ this.clearIdentityState();
301
+ return result;
302
+ }
303
+
304
+ // Fire-and-forget mode - don't await but still clear state
305
+ void eventPromise;
306
+ this.clearIdentityState();
307
+ return { success: true, data: undefined };
308
+ }
309
+
310
+ /**
311
+ * Sign out and reset all state including deviceId.
312
+ * Sends a sign_out_all event to the backend and clears all SDK state.
313
+ * The device will appear as a fresh install on next identify.
314
+ *
315
+ * @param options - Optional configuration for the sign out
316
+ * @returns AsyncResult indicating success/failure of the event send
317
+ */
318
+ async signOutAll(options?: SignOutOptions): AsyncResult<void> {
319
+ this.logger.debugInfo("Signing out all - full reset");
320
+
321
+ const session = this.getSessionState();
322
+ const waitForEvent = options?.waitForEvent ?? true;
323
+
324
+ // Send event FIRST while session/device data still exists
325
+ const eventPromise = this.events.track(
326
+ {
327
+ event_name: "user_signed_out",
328
+ event_type: "action",
329
+ properties: {
330
+ sign_out_type: "sign_out_all",
331
+ session_id: session?.session_id,
332
+ user_id: session?.user_id,
333
+ ...options?.properties,
334
+ },
335
+ },
336
+ session?.session_id
337
+ );
338
+
339
+ if (waitForEvent) {
340
+ const result = await eventPromise;
341
+ // Clear all state regardless of event send result
342
+ this.clearIdentityState();
343
+ this.device.reset();
344
+ return result;
345
+ }
346
+
347
+ // Fire-and-forget mode - don't await but still clear state
348
+ void eventPromise;
349
+ this.clearIdentityState();
350
+ this.device.reset();
351
+ return { success: true, data: undefined };
352
+ }
353
+
195
354
  /**
196
355
  * Re-identify the current persona to refresh session data.
197
356
  * Only works if already identified.
@@ -223,6 +382,82 @@ export class IdentityClient {
223
382
  }
224
383
  }
225
384
 
385
+ /**
386
+ * Extract error message from API validation error response (422)
387
+ */
388
+ private extractValidationErrorMessage(errorValue: unknown): string {
389
+ if (typeof errorValue === "string") return errorValue;
390
+ if (typeof errorValue === "object" && errorValue !== null) {
391
+ const obj = errorValue as { summary?: string; message?: string };
392
+ if (obj.summary) return obj.summary;
393
+ if (obj.message) return obj.message;
394
+ }
395
+ return "Unknown error";
396
+ }
397
+
398
+ /**
399
+ * Extract error message from API error response (non-422)
400
+ */
401
+ private extractErrorMessage(errorValue: unknown): string {
402
+ if (typeof errorValue === "string") return errorValue;
403
+ if (typeof errorValue === "object" && errorValue !== null) {
404
+ const obj = errorValue as { code?: string; message?: string };
405
+ if (obj.message) return obj.message;
406
+ }
407
+ return "Unknown error";
408
+ }
409
+
410
+ /**
411
+ * Get push notification info if notifications client is configured
412
+ */
413
+ private async getNotificationsInfo(): Promise<
414
+ | {
415
+ push: {
416
+ enabled: boolean;
417
+ granted: boolean;
418
+ token: string | null;
419
+ platform: NotificationPlatformEnum;
420
+ };
421
+ }
422
+ | undefined
423
+ > {
424
+ if (!this.notificationsClient) return undefined;
425
+
426
+ this.logger.debug("Getting push notification token...");
427
+ const token = await this.notificationsClient.getToken();
428
+ this.logger.debug(`Push token retrieved: ${token ? "yes" : "no"}, platform: ${this.notificationsClient.platform}`);
429
+
430
+ return {
431
+ push: {
432
+ enabled: true,
433
+ granted: token !== null,
434
+ token,
435
+ platform: this.notificationsClient.platform,
436
+ },
437
+ };
438
+ }
439
+
440
+ /**
441
+ * Flatten nested version_info structure from API response
442
+ */
443
+ private flattenVersionInfo(rawVersionInfo: { status: string; update: unknown }): {
444
+ status: IdentifyVersionStatusEnum;
445
+ update: UpdateInfo | null;
446
+ } {
447
+ let flattenedUpdate: UpdateInfo | null = null;
448
+
449
+ if (rawVersionInfo.update) {
450
+ flattenedUpdate = isNestedUpdate(rawVersionInfo.update)
451
+ ? rawVersionInfo.update.update
452
+ : (rawVersionInfo.update as UpdateInfo);
453
+ }
454
+
455
+ return {
456
+ status: rawVersionInfo.status as IdentifyVersionStatusEnum,
457
+ update: flattenedUpdate,
458
+ };
459
+ }
460
+
226
461
  async identify(user?: Persona): AsyncResult<IdentityUser> {
227
462
  this.logger.debugInfo(`Identifying user with persona: ${user?.name ?? "none"}`);
228
463
  const previousState = this.identifyState;
@@ -233,6 +468,8 @@ export class IdentityClient {
233
468
  this.logger.debug("Getting device ID...");
234
469
  const deviceId = await this.device.getDeviceId();
235
470
  const deviceInfo = await this.device.getDeviceInfo();
471
+ const notificationsInfo = await this.getNotificationsInfo();
472
+
236
473
  this.logger.debug("Calling identify API...");
237
474
  const response = await this.api.client("/v1/identify", {
238
475
  method: "POST",
@@ -240,7 +477,7 @@ export class IdentityClient {
240
477
  "td-api-key": this.api.apiKey,
241
478
  "td-org-id": this.api.orgId,
242
479
  "td-project-id": this.api.projectId,
243
- "td-environment-slug": "production",
480
+ "td-environment-slug": this.api.environmentSlug,
244
481
  "td-device-id": deviceId,
245
482
  },
246
483
  body: {
@@ -251,6 +488,7 @@ export class IdentityClient {
251
488
  application: deviceInfo.application,
252
489
  hardware: deviceInfo.hardware,
253
490
  update: null,
491
+ notifications: notificationsInfo,
254
492
  },
255
493
  },
256
494
  });
@@ -260,48 +498,34 @@ export class IdentityClient {
260
498
  this.logger.warn("Identify API error", response.error.status, response.error.value);
261
499
  this.setIdentifyState(previousState);
262
500
 
263
- if (response.error.status === 422) {
264
- this.logger.warn("422 Error identifying user", response.error.value);
265
- return {
266
- success: false,
267
- error: response.error.value.message ?? "Unknown error",
268
- };
269
- }
270
-
271
- const value = response.error.value;
272
- return {
273
- success: false,
274
- error: value?.error?.message ?? "Unknown error",
275
- };
501
+ const errorMessage =
502
+ response.error.status === 422
503
+ ? this.extractValidationErrorMessage(response.error.value)
504
+ : this.extractErrorMessage(response.error.value);
505
+
506
+ return { success: false, error: errorMessage };
276
507
  }
277
508
 
509
+ const parsedSession = SessionSchema.parse(response.data.data);
510
+ const flattenedVersionInfo = this.flattenVersionInfo(response.data.data.version_info);
511
+
512
+ const identityUser: IdentityUser = {
513
+ ...parsedSession,
514
+ version_info: flattenedVersionInfo,
515
+ };
516
+
278
517
  this.setIdentifyState({
279
518
  type: "identified",
280
- session: response.data.data,
281
- version_info: {
282
- status: response.data.data.version_info.status,
283
- update: null,
284
- },
519
+ session: parsedSession,
520
+ version_info: flattenedVersionInfo,
285
521
  });
286
522
 
287
- return {
288
- success: true,
289
- data: {
290
- ...response.data.data,
291
- version_info: {
292
- status: response.data.data.version_info.status,
293
- update: null,
294
- },
295
- },
296
- };
523
+ return { success: true, data: identityUser };
297
524
  },
298
525
  (error) => {
299
526
  this.logger.error("Error identifying user", error);
300
527
  this.setIdentifyState(previousState);
301
- return {
302
- success: false,
303
- error: error.message,
304
- };
528
+ return { success: false, error: error.message };
305
529
  }
306
530
  );
307
531
  }
@@ -1,6 +1,7 @@
1
1
  // Clients
2
2
  export * from "../clients/api";
3
3
  export * from "../clients/device/device.client";
4
+ export * from "../clients/events";
4
5
  export * from "../clients/logging";
5
6
  export * from "../clients/notifications";
6
7
  export * from "../clients/storage";
@@ -19,6 +19,11 @@ export interface UseForceUpdateResult {
19
19
  * Whether the the current version is out of date and is forced to be updated.
20
20
  */
21
21
  isUpdateRequired: boolean;
22
+ /**
23
+ * Release notes for the update, if available.
24
+ * Only present when there's an update (update_available, update_recommended, or update_required).
25
+ */
26
+ releaseNotes: string | null;
22
27
  }
23
28
 
24
29
  export const useForceUpdate = (): UseForceUpdateResult => {
@@ -35,10 +40,19 @@ export const useForceUpdate = (): UseForceUpdateResult => {
35
40
  const isUpdateRecommended = versionStatus.type === "update_recommended";
36
41
  const isUpdateAvailable = isUpdateRequired || isUpdateRecommended || versionStatus.type === "update_available";
37
42
 
43
+ // Extract release notes from update status types
44
+ const releaseNotes =
45
+ versionStatus.type === "update_available" ||
46
+ versionStatus.type === "update_recommended" ||
47
+ versionStatus.type === "update_required"
48
+ ? (versionStatus.releaseNotes ?? null)
49
+ : null;
50
+
38
51
  return {
39
52
  versionStatus,
40
53
  isUpdateRequired,
41
54
  isUpdateRecommended,
42
55
  isUpdateAvailable,
56
+ releaseNotes,
43
57
  };
44
58
  };