@teardown/react-native 2.0.23 → 2.0.27

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.
Files changed (61) hide show
  1. package/docs/adapters/device/basic.mdx +59 -0
  2. package/docs/adapters/device/device-info.mdx +76 -0
  3. package/docs/adapters/device/expo.mdx +61 -0
  4. package/docs/adapters/device/index.mdx +102 -0
  5. package/docs/adapters/device/meta.json +4 -0
  6. package/docs/adapters/index.mdx +96 -0
  7. package/docs/adapters/meta.json +4 -0
  8. package/docs/adapters/notifications/expo.mdx +127 -0
  9. package/docs/adapters/notifications/firebase.mdx +142 -0
  10. package/docs/adapters/notifications/index.mdx +100 -0
  11. package/docs/adapters/notifications/meta.json +4 -0
  12. package/docs/adapters/notifications/wix.mdx +140 -0
  13. package/docs/adapters/storage/async-storage.mdx +95 -0
  14. package/docs/adapters/storage/index.mdx +93 -0
  15. package/docs/adapters/storage/meta.json +4 -0
  16. package/docs/adapters/storage/mmkv.mdx +86 -0
  17. package/docs/advanced.mdx +280 -0
  18. package/docs/api-reference.mdx +241 -0
  19. package/docs/core-concepts.mdx +158 -0
  20. package/docs/force-updates.mdx +185 -0
  21. package/docs/getting-started.mdx +156 -0
  22. package/docs/hooks-reference.mdx +232 -0
  23. package/docs/identity.mdx +171 -0
  24. package/docs/index.mdx +61 -0
  25. package/docs/logging.mdx +144 -0
  26. package/docs/meta.json +14 -0
  27. package/package.json +49 -31
  28. package/src/clients/api/index.ts +1 -1
  29. package/src/clients/device/adapters/basic.adapter.ts +57 -66
  30. package/src/clients/device/adapters/device-info.adapter.ts +21 -28
  31. package/src/clients/device/adapters/device.adpater-interface.ts +1 -8
  32. package/src/clients/device/adapters/expo.adapter.ts +33 -40
  33. package/src/clients/device/device.client.test.ts +20 -35
  34. package/src/clients/device/device.client.ts +0 -3
  35. package/src/clients/force-update/force-update.client.test.ts +69 -23
  36. package/src/clients/force-update/force-update.client.ts +42 -59
  37. package/src/clients/identity/identity.client.test.ts +22 -14
  38. package/src/clients/identity/identity.client.ts +1 -1
  39. package/src/clients/identity/index.ts +1 -1
  40. package/src/clients/logging/index.ts +1 -1
  41. package/src/clients/notifications/adapters/expo-notifications.adapter.ts +105 -0
  42. package/src/clients/notifications/adapters/firebase-messaging.adapter.ts +87 -0
  43. package/src/clients/notifications/adapters/notifications.adapter-interface.ts +112 -0
  44. package/src/clients/notifications/adapters/wix-notifications.adapter.ts +183 -0
  45. package/src/clients/notifications/index.ts +2 -0
  46. package/src/clients/notifications/notifications.client.ts +214 -3
  47. package/src/clients/storage/adapters/async-storage.adapter.ts +2 -6
  48. package/src/clients/storage/adapters/storage-adapters.test.ts +2 -7
  49. package/src/clients/storage/adapters/storage.adpater-interface.ts +1 -5
  50. package/src/clients/utils/index.ts +1 -1
  51. package/src/clients/utils/utils.client.ts +1 -1
  52. package/src/exports/adapters/async-storage.ts +1 -1
  53. package/src/exports/adapters/expo.ts +1 -1
  54. package/src/exports/expo.ts +2 -0
  55. package/src/exports/firebase.ts +1 -0
  56. package/src/exports/index.ts +6 -9
  57. package/src/exports/wix.ts +1 -0
  58. package/src/hooks/use-force-update.ts +31 -34
  59. package/src/index.ts +1 -0
  60. package/src/teardown.core.ts +0 -2
  61. package/src/.DS_Store +0 -0
@@ -1,4 +1,4 @@
1
- import { describe, expect, mock, test, beforeEach } from "bun:test";
1
+ import { describe, expect, mock, test } from "bun:test";
2
2
 
3
3
  // Mock react-native before any imports that use it
4
4
  mock.module("react-native", () => ({
@@ -25,6 +25,8 @@ function createMockLoggingClient() {
25
25
  warn: (message: string, ...args: unknown[]) => logs.push({ level: "warn", message, args }),
26
26
  error: (message: string, ...args: unknown[]) => logs.push({ level: "error", message, args }),
27
27
  debug: (message: string, ...args: unknown[]) => logs.push({ level: "debug", message, args }),
28
+ debugInfo: (message: string, ...args: unknown[]) => logs.push({ level: "debugInfo", message, args }),
29
+ debugError: (message: string, ...args: unknown[]) => logs.push({ level: "debugError", message, args }),
28
30
  }),
29
31
  getLogs: () => logs,
30
32
  clearLogs: () => {
@@ -256,20 +258,26 @@ describe("IdentityClient", () => {
256
258
  expect(client.getIdentifyState().type).toBe("unidentified");
257
259
  });
258
260
 
259
- test("throws error on invalid stored state (corrupt JSON)", () => {
261
+ test("gracefully handles invalid stored state (corrupt JSON)", () => {
260
262
  const mockStorage = createMockStorageClient();
261
263
  mockStorage.getStorage().set(IDENTIFY_STORAGE_KEY, "not-valid-json{{{");
262
264
 
263
265
  const { client } = createTestClient({ storage: mockStorage });
264
- expect(() => loadStateFromStorage(client)).toThrow();
266
+ loadStateFromStorage(client);
267
+
268
+ // Should gracefully fall back to unidentified state
269
+ expect(client.getIdentifyState().type).toBe("unidentified");
265
270
  });
266
271
 
267
- test("throws error on invalid stored state (schema mismatch)", () => {
272
+ test("gracefully handles invalid stored state (schema mismatch)", () => {
268
273
  const mockStorage = createMockStorageClient();
269
274
  mockStorage.getStorage().set(IDENTIFY_STORAGE_KEY, JSON.stringify({ type: "invalid_type" }));
270
275
 
271
276
  const { client } = createTestClient({ storage: mockStorage });
272
- expect(() => loadStateFromStorage(client)).toThrow();
277
+ loadStateFromStorage(client);
278
+
279
+ // Should gracefully fall back to unidentified state
280
+ expect(client.getIdentifyState().type).toBe("unidentified");
273
281
  });
274
282
 
275
283
  test("creates logger with correct name", () => {
@@ -485,7 +493,7 @@ describe("IdentityClient", () => {
485
493
 
486
494
  const stored = mockStorage.getStorage().get(IDENTIFY_STORAGE_KEY);
487
495
  expect(stored).toBeDefined();
488
- const parsed = JSON.parse(stored!);
496
+ const parsed = JSON.parse(stored ?? "{}");
489
497
  expect(parsed.type).toBe("identified");
490
498
  expect(parsed.session.session_id).toBe("session-123");
491
499
  expect(parsed.version_info.status).toBe(IdentifyVersionStatusEnum.UP_TO_DATE);
@@ -627,10 +635,10 @@ describe("IdentityClient", () => {
627
635
 
628
636
  // Should have debug logs about state transitions
629
637
  // When already identified, identify() will transition: identified -> identifying -> identified
630
- const debugLogs = mockLogging.getLogs().filter((l) => l.level === "debug");
638
+ const debugLogs = mockLogging.getLogs().filter((l) => l.level === "debug" || l.level === "debugInfo");
631
639
  expect(debugLogs.length).toBeGreaterThan(0);
632
640
  // Check that state transitions are logged
633
- expect(debugLogs.some((l) => l.message.includes("Identify state:"))).toBe(true);
641
+ expect(debugLogs.some((l) => l.message.includes("Identify state"))).toBe(true);
634
642
  });
635
643
  });
636
644
 
@@ -747,7 +755,7 @@ describe("IdentityClient", () => {
747
755
  await client.identify();
748
756
 
749
757
  // Update mock to return new session
750
- mockApi.client = async (endpoint: string, config: ApiCallRecord["config"]) => ({
758
+ mockApi.client = async (_endpoint: string, _config: ApiCallRecord["config"]) => ({
751
759
  error: null,
752
760
  data: {
753
761
  data: {
@@ -792,7 +800,7 @@ describe("IdentityClient", () => {
792
800
  // After reset, storage should contain unidentified state
793
801
  const stored = mockStorage.getStorage().get(IDENTIFY_STORAGE_KEY);
794
802
  expect(stored).toBeDefined();
795
- expect(JSON.parse(stored!).type).toBe("unidentified");
803
+ expect(JSON.parse(stored ?? "{}").type).toBe("unidentified");
796
804
  });
797
805
 
798
806
  test("emits unidentified state on reset", async () => {
@@ -1041,14 +1049,14 @@ describe("IdentityClient", () => {
1041
1049
  // Check storage while API call is pending
1042
1050
  await new Promise((resolve) => setTimeout(resolve, 10));
1043
1051
  const intermediateStored = mockStorage.getStorage().get(IDENTIFY_STORAGE_KEY);
1044
- expect(JSON.parse(intermediateStored!).type).toBe("identifying");
1052
+ expect(JSON.parse(intermediateStored ?? "{}").type).toBe("identifying");
1045
1053
 
1046
1054
  // Complete API call
1047
- resolveApi!(null);
1055
+ resolveApi?.(null);
1048
1056
  await identifyPromise;
1049
1057
 
1050
1058
  const finalStored = mockStorage.getStorage().get(IDENTIFY_STORAGE_KEY);
1051
- expect(JSON.parse(finalStored!).type).toBe("identified");
1059
+ expect(JSON.parse(finalStored ?? "{}").type).toBe("identified");
1052
1060
  });
1053
1061
 
1054
1062
  test("storage survives client recreation", async () => {
@@ -1132,7 +1140,7 @@ describe("IdentityClient", () => {
1132
1140
 
1133
1141
  // Verify it can be stored and retrieved
1134
1142
  const stored = mockStorage.getStorage().get(IDENTIFY_STORAGE_KEY);
1135
- const parsed = JSON.parse(stored!);
1143
+ const parsed = JSON.parse(stored ?? "{}");
1136
1144
  expect(parsed.session.session_id).toBe("session-with-émojis-🚀-and-üñíçödé");
1137
1145
  });
1138
1146
  });
@@ -23,7 +23,7 @@ export interface IdentityUser {
23
23
  status: IdentifyVersionStatusEnum;
24
24
  update: null;
25
25
  };
26
- };
26
+ }
27
27
 
28
28
  export const UnidentifiedSessionStateSchema = z.object({
29
29
  type: z.literal("unidentified"),
@@ -1 +1 @@
1
- export * from "./identity.client";
1
+ export * from "./identity.client";
@@ -1 +1 @@
1
- export * from "./logging.client";
1
+ export * from "./logging.client";
@@ -0,0 +1,105 @@
1
+ import * as Notifications from "expo-notifications";
2
+ import { NotificationPlatformEnum } from "../../device/device.client";
3
+ import {
4
+ type DataMessage,
5
+ NotificationAdapter,
6
+ type PermissionStatus,
7
+ type PushNotification,
8
+ type Unsubscribe,
9
+ } from "./notifications.adapter-interface";
10
+
11
+ /**
12
+ * Notification adapter for expo-notifications library.
13
+ * Uses Expo push tokens for routing through Expo's notification service.
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * import { NotificationsClient } from "@teardown/react-native";
18
+ * import { ExpoNotificationsAdapter } from "@teardown/react-native/expo-notifications";
19
+ *
20
+ * const notifications = new NotificationsClient(logging, storage, {
21
+ * adapter: new ExpoNotificationsAdapter()
22
+ * });
23
+ * ```
24
+ */
25
+ export class ExpoNotificationsAdapter extends NotificationAdapter {
26
+ get platform(): NotificationPlatformEnum {
27
+ return NotificationPlatformEnum.EXPO;
28
+ }
29
+
30
+ async getToken(): Promise<string | null> {
31
+ try {
32
+ const { status } = await Notifications.getPermissionsAsync();
33
+ if (status !== "granted") {
34
+ return null;
35
+ }
36
+
37
+ const tokenData = await Notifications.getExpoPushTokenAsync();
38
+ return tokenData.data;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ async requestPermissions(): Promise<PermissionStatus> {
45
+ const { status, canAskAgain } = await Notifications.requestPermissionsAsync();
46
+
47
+ return {
48
+ granted: status === "granted",
49
+ canAskAgain: canAskAgain ?? true,
50
+ };
51
+ }
52
+
53
+ onTokenRefresh(listener: (token: string) => void): Unsubscribe {
54
+ const subscription = Notifications.addPushTokenListener((event) => {
55
+ // Expo push token listener returns ExpoPushToken object
56
+ if (event.data) {
57
+ listener(event.data);
58
+ }
59
+ });
60
+
61
+ return () => subscription.remove();
62
+ }
63
+
64
+ onNotificationReceived(listener: (notification: PushNotification) => void): Unsubscribe {
65
+ const subscription = Notifications.addNotificationReceivedListener((notification) => {
66
+ const content = notification.request.content;
67
+ listener({
68
+ title: content.title ?? undefined,
69
+ body: content.body ?? undefined,
70
+ data: content.data ?? undefined,
71
+ });
72
+ });
73
+
74
+ return () => subscription.remove();
75
+ }
76
+
77
+ onNotificationOpened(listener: (notification: PushNotification) => void): Unsubscribe {
78
+ const subscription = Notifications.addNotificationResponseReceivedListener((response) => {
79
+ const content = response.notification.request.content;
80
+ listener({
81
+ title: content.title ?? undefined,
82
+ body: content.body ?? undefined,
83
+ data: content.data ?? undefined,
84
+ });
85
+ });
86
+
87
+ return () => subscription.remove();
88
+ }
89
+
90
+ onDataMessage(listener: (message: DataMessage) => void): Unsubscribe {
91
+ // In Expo, data-only messages come through the same listener as regular notifications
92
+ // but without title/body. We filter for messages that have data but no display content.
93
+ const subscription = Notifications.addNotificationReceivedListener((notification) => {
94
+ const content = notification.request.content;
95
+ // Data-only message: has data but no title or body
96
+ if (content.data && !content.title && !content.body) {
97
+ listener({
98
+ data: content.data,
99
+ });
100
+ }
101
+ });
102
+
103
+ return () => subscription.remove();
104
+ }
105
+ }
@@ -0,0 +1,87 @@
1
+ import messaging, { type FirebaseMessagingTypes } from "@react-native-firebase/messaging";
2
+ import { NotificationPlatformEnum } from "../../device/device.client";
3
+ import {
4
+ type DataMessage,
5
+ NotificationAdapter,
6
+ type PermissionStatus,
7
+ type PushNotification,
8
+ type Unsubscribe,
9
+ } from "./notifications.adapter-interface";
10
+
11
+ /**
12
+ * Notification adapter for @react-native-firebase/messaging library.
13
+ * Uses FCM (Firebase Cloud Messaging) tokens for push notifications.
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * import { NotificationsClient } from "@teardown/react-native";
18
+ * import { FirebaseMessagingAdapter } from "@teardown/react-native/firebase-messaging";
19
+ *
20
+ * const notifications = new NotificationsClient(logging, storage, {
21
+ * adapter: new FirebaseMessagingAdapter()
22
+ * });
23
+ * ```
24
+ */
25
+ export class FirebaseMessagingAdapter extends NotificationAdapter {
26
+ get platform(): NotificationPlatformEnum {
27
+ return NotificationPlatformEnum.FCM;
28
+ }
29
+
30
+ async getToken(): Promise<string | null> {
31
+ try {
32
+ const token = await messaging().getToken();
33
+ return token;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ async requestPermissions(): Promise<PermissionStatus> {
40
+ const authStatus = await messaging().requestPermission();
41
+
42
+ const granted =
43
+ authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
44
+ authStatus === messaging.AuthorizationStatus.PROVISIONAL;
45
+
46
+ // Firebase doesn't expose canAskAgain, assume true if not denied
47
+ const canAskAgain = authStatus !== messaging.AuthorizationStatus.DENIED;
48
+
49
+ return { granted, canAskAgain };
50
+ }
51
+
52
+ onTokenRefresh(listener: (token: string) => void): Unsubscribe {
53
+ return messaging().onTokenRefresh(listener);
54
+ }
55
+
56
+ onNotificationReceived(listener: (notification: PushNotification) => void): Unsubscribe {
57
+ return messaging().onMessage((remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
58
+ listener({
59
+ title: remoteMessage.notification?.title,
60
+ body: remoteMessage.notification?.body,
61
+ data: remoteMessage.data,
62
+ });
63
+ });
64
+ }
65
+
66
+ onNotificationOpened(listener: (notification: PushNotification) => void): Unsubscribe {
67
+ return messaging().onNotificationOpenedApp((remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
68
+ listener({
69
+ title: remoteMessage.notification?.title,
70
+ body: remoteMessage.notification?.body,
71
+ data: remoteMessage.data,
72
+ });
73
+ });
74
+ }
75
+
76
+ onDataMessage(listener: (message: DataMessage) => void): Unsubscribe {
77
+ // Firebase data-only messages come through onMessage but without notification payload
78
+ return messaging().onMessage((remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
79
+ // Data-only message: has data but no notification
80
+ if (remoteMessage.data && !remoteMessage.notification) {
81
+ listener({
82
+ data: remoteMessage.data,
83
+ });
84
+ }
85
+ });
86
+ }
87
+ }
@@ -0,0 +1,112 @@
1
+ import type { NotificationPlatformEnum } from "../../device/device.client";
2
+
3
+ /**
4
+ * Permission status returned from notification permission requests.
5
+ */
6
+ export interface PermissionStatus {
7
+ /** Whether notifications permission is granted */
8
+ granted: boolean;
9
+ /** Whether the user can be prompted again (iOS specific) */
10
+ canAskAgain: boolean;
11
+ }
12
+
13
+ /**
14
+ * Normalized push notification payload across all adapters.
15
+ */
16
+ export interface PushNotification {
17
+ /** Notification title */
18
+ title?: string;
19
+ /** Notification body text */
20
+ body?: string;
21
+ /** Custom data payload */
22
+ data?: Record<string, unknown>;
23
+ }
24
+
25
+ /**
26
+ * Data-only message payload (silent/background push).
27
+ * These messages contain only data without notification display.
28
+ */
29
+ export interface DataMessage {
30
+ /** Custom data payload */
31
+ data: Record<string, unknown>;
32
+ }
33
+
34
+ /**
35
+ * Function to unsubscribe from event listeners.
36
+ */
37
+ export type Unsubscribe = () => void;
38
+
39
+ /**
40
+ * Abstract adapter interface for push notification providers.
41
+ *
42
+ * Implement this interface to add support for different push notification
43
+ * libraries (expo-notifications, firebase messaging, react-native-notifications).
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * class MyNotificationAdapter extends NotificationAdapter {
48
+ * get platform() { return NotificationPlatformEnum.FCM; }
49
+ * async getToken() { ... }
50
+ * // ... implement other methods
51
+ * }
52
+ * ```
53
+ */
54
+ export abstract class NotificationAdapter {
55
+ /**
56
+ * The notification platform this adapter supports.
57
+ * Used to identify token type when sending to backend.
58
+ */
59
+ abstract get platform(): NotificationPlatformEnum;
60
+
61
+ /**
62
+ * Get the current push notification token.
63
+ * Returns null if permissions not granted or token unavailable.
64
+ */
65
+ abstract getToken(): Promise<string | null>;
66
+
67
+ /**
68
+ * Request push notification permissions from the user.
69
+ * On iOS, shows the permission dialog. On Android 13+, requests POST_NOTIFICATIONS.
70
+ */
71
+ abstract requestPermissions(): Promise<PermissionStatus>;
72
+
73
+ /**
74
+ * Subscribe to token refresh events.
75
+ * Called when the push token changes (e.g., after app reinstall or token rotation).
76
+ *
77
+ * @param listener - Callback invoked with new token
78
+ * @returns Unsubscribe function to remove the listener
79
+ */
80
+ abstract onTokenRefresh(listener: (token: string) => void): Unsubscribe;
81
+
82
+ /**
83
+ * Subscribe to foreground notification events.
84
+ * Called when a notification is received while the app is in the foreground.
85
+ *
86
+ * @param listener - Callback invoked with notification payload
87
+ * @returns Unsubscribe function to remove the listener
88
+ */
89
+ abstract onNotificationReceived(listener: (notification: PushNotification) => void): Unsubscribe;
90
+
91
+ /**
92
+ * Subscribe to notification opened events.
93
+ * Called when the user taps on a notification to open the app.
94
+ *
95
+ * @param listener - Callback invoked with notification payload
96
+ * @returns Unsubscribe function to remove the listener
97
+ */
98
+ abstract onNotificationOpened(listener: (notification: PushNotification) => void): Unsubscribe;
99
+
100
+ /**
101
+ * Subscribe to data-only message events (silent/background push).
102
+ * Called when a data-only message is received without notification display.
103
+ * These are typically used for background data sync or silent updates.
104
+ *
105
+ * Note: On iOS, requires "Remote notifications" background mode enabled.
106
+ * On Android, these are handled automatically via FCM data messages.
107
+ *
108
+ * @param listener - Callback invoked with data payload
109
+ * @returns Unsubscribe function to remove the listener
110
+ */
111
+ abstract onDataMessage(listener: (message: DataMessage) => void): Unsubscribe;
112
+ }
@@ -0,0 +1,183 @@
1
+ import { Platform } from "react-native";
2
+ import { type Notification, Notifications, type Registered } from "react-native-notifications";
3
+ import { NotificationPlatformEnum } from "../../device/device.client";
4
+ import {
5
+ type DataMessage,
6
+ NotificationAdapter,
7
+ type PermissionStatus,
8
+ type PushNotification,
9
+ type Unsubscribe,
10
+ } from "./notifications.adapter-interface";
11
+
12
+ /**
13
+ * Notification adapter for react-native-notifications (Wix) library.
14
+ * Uses native FCM/APNS tokens for push notifications.
15
+ *
16
+ * Note: This library uses event-based token delivery, which is normalized
17
+ * to a Promise-based API by this adapter.
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * import { NotificationsClient } from "@teardown/react-native";
22
+ * import { WixNotificationsAdapter } from "@teardown/react-native/wix-notifications";
23
+ *
24
+ * const notifications = new NotificationsClient(logging, storage, {
25
+ * adapter: new WixNotificationsAdapter()
26
+ * });
27
+ * ```
28
+ */
29
+ export class WixNotificationsAdapter extends NotificationAdapter {
30
+ private tokenPromise: Promise<string> | null = null;
31
+ private tokenResolver: ((token: string) => void) | null = null;
32
+ private currentToken: string | null = null;
33
+ private tokenListeners: Set<(token: string) => void> = new Set();
34
+
35
+ constructor() {
36
+ super();
37
+ this.setupTokenListener();
38
+ }
39
+
40
+ get platform(): NotificationPlatformEnum {
41
+ return Platform.OS === "ios" ? NotificationPlatformEnum.APNS : NotificationPlatformEnum.FCM;
42
+ }
43
+
44
+ async getToken(): Promise<string | null> {
45
+ if (this.currentToken) {
46
+ return this.currentToken;
47
+ }
48
+
49
+ // Create a promise that will resolve when we receive the token
50
+ if (!this.tokenPromise) {
51
+ this.tokenPromise = new Promise<string>((resolve) => {
52
+ this.tokenResolver = resolve;
53
+ });
54
+
55
+ // Trigger registration to get token
56
+ Notifications.registerRemoteNotifications();
57
+ }
58
+
59
+ try {
60
+ // Wait for token with timeout
61
+ const token = await Promise.race([
62
+ this.tokenPromise,
63
+ new Promise<null>((_, reject) => setTimeout(() => reject(new Error("Token timeout")), 10000)),
64
+ ]);
65
+ return token;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ async requestPermissions(): Promise<PermissionStatus> {
72
+ return new Promise((resolve) => {
73
+ // Set up one-time listener for registration result
74
+ const registeredHandler = () => {
75
+ resolve({ granted: true, canAskAgain: true });
76
+ };
77
+
78
+ const deniedHandler = () => {
79
+ resolve({ granted: false, canAskAgain: false });
80
+ };
81
+
82
+ // Subscribe to registration events
83
+ Notifications.events().registerRemoteNotificationsRegistered(registeredHandler);
84
+ Notifications.events().registerRemoteNotificationsRegistrationDenied(deniedHandler);
85
+
86
+ // Trigger registration
87
+ Notifications.registerRemoteNotifications();
88
+
89
+ // Timeout fallback
90
+ setTimeout(() => {
91
+ resolve({ granted: false, canAskAgain: true });
92
+ }, 10000);
93
+ });
94
+ }
95
+
96
+ onTokenRefresh(listener: (token: string) => void): Unsubscribe {
97
+ this.tokenListeners.add(listener);
98
+
99
+ // If we already have a token, call listener immediately
100
+ if (this.currentToken) {
101
+ listener(this.currentToken);
102
+ }
103
+
104
+ return () => {
105
+ this.tokenListeners.delete(listener);
106
+ };
107
+ }
108
+
109
+ onNotificationReceived(listener: (notification: PushNotification) => void): Unsubscribe {
110
+ const subscription = Notifications.events().registerNotificationReceivedForeground(
111
+ (
112
+ notification: Notification,
113
+ completion: (response: { alert: boolean; sound: boolean; badge: boolean }) => void
114
+ ) => {
115
+ const payload = notification.payload;
116
+ listener({
117
+ title: payload.title,
118
+ body: payload.body,
119
+ data: payload,
120
+ });
121
+ completion({ alert: true, sound: true, badge: true });
122
+ }
123
+ );
124
+
125
+ return () => subscription.remove();
126
+ }
127
+
128
+ onNotificationOpened(listener: (notification: PushNotification) => void): Unsubscribe {
129
+ const subscription = Notifications.events().registerNotificationOpened(
130
+ (notification: Notification, completion: () => void) => {
131
+ const payload = notification.payload;
132
+ listener({
133
+ title: payload.title,
134
+ body: payload.body,
135
+ data: payload,
136
+ });
137
+ completion();
138
+ }
139
+ );
140
+
141
+ return () => subscription.remove();
142
+ }
143
+
144
+ onDataMessage(listener: (message: DataMessage) => void): Unsubscribe {
145
+ // Wix library handles data-only messages through the same foreground listener
146
+ // but without title/body in the payload
147
+ const subscription = Notifications.events().registerNotificationReceivedForeground(
148
+ (
149
+ notification: Notification,
150
+ completion: (response: { alert: boolean; sound: boolean; badge: boolean }) => void
151
+ ) => {
152
+ const payload = notification.payload;
153
+ // Data-only message: has payload but no title or body
154
+ if (payload && !payload.title && !payload.body) {
155
+ listener({
156
+ data: payload,
157
+ });
158
+ }
159
+ completion({ alert: false, sound: false, badge: false });
160
+ }
161
+ );
162
+
163
+ return () => subscription.remove();
164
+ }
165
+
166
+ private setupTokenListener(): void {
167
+ Notifications.events().registerRemoteNotificationsRegistered((event: Registered) => {
168
+ this.currentToken = event.deviceToken;
169
+
170
+ // Resolve any pending getToken promise
171
+ if (this.tokenResolver) {
172
+ this.tokenResolver(event.deviceToken);
173
+ this.tokenResolver = null;
174
+ this.tokenPromise = null;
175
+ }
176
+
177
+ // Notify all listeners
178
+ for (const listener of this.tokenListeners) {
179
+ listener(event.deviceToken);
180
+ }
181
+ });
182
+ }
183
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./adapters/notifications.adapter-interface";
2
+ export * from "./notifications.client";