@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.
- package/docs/adapters/device/basic.mdx +59 -0
- package/docs/adapters/device/device-info.mdx +76 -0
- package/docs/adapters/device/expo.mdx +61 -0
- package/docs/adapters/device/index.mdx +102 -0
- package/docs/adapters/device/meta.json +4 -0
- package/docs/adapters/index.mdx +96 -0
- package/docs/adapters/meta.json +4 -0
- package/docs/adapters/notifications/expo.mdx +127 -0
- package/docs/adapters/notifications/firebase.mdx +142 -0
- package/docs/adapters/notifications/index.mdx +100 -0
- package/docs/adapters/notifications/meta.json +4 -0
- package/docs/adapters/notifications/wix.mdx +140 -0
- package/docs/adapters/storage/async-storage.mdx +95 -0
- package/docs/adapters/storage/index.mdx +93 -0
- package/docs/adapters/storage/meta.json +4 -0
- package/docs/adapters/storage/mmkv.mdx +86 -0
- package/docs/advanced.mdx +280 -0
- package/docs/api-reference.mdx +241 -0
- package/docs/core-concepts.mdx +158 -0
- package/docs/force-updates.mdx +185 -0
- package/docs/getting-started.mdx +156 -0
- package/docs/hooks-reference.mdx +232 -0
- package/docs/identity.mdx +171 -0
- package/docs/index.mdx +61 -0
- package/docs/logging.mdx +144 -0
- package/docs/meta.json +14 -0
- package/package.json +49 -31
- package/src/clients/api/index.ts +1 -1
- package/src/clients/device/adapters/basic.adapter.ts +57 -66
- package/src/clients/device/adapters/device-info.adapter.ts +21 -28
- package/src/clients/device/adapters/device.adpater-interface.ts +1 -8
- package/src/clients/device/adapters/expo.adapter.ts +33 -40
- package/src/clients/device/device.client.test.ts +20 -35
- package/src/clients/device/device.client.ts +0 -3
- package/src/clients/force-update/force-update.client.test.ts +69 -23
- package/src/clients/force-update/force-update.client.ts +42 -59
- package/src/clients/identity/identity.client.test.ts +22 -14
- package/src/clients/identity/identity.client.ts +1 -1
- package/src/clients/identity/index.ts +1 -1
- package/src/clients/logging/index.ts +1 -1
- package/src/clients/notifications/adapters/expo-notifications.adapter.ts +105 -0
- package/src/clients/notifications/adapters/firebase-messaging.adapter.ts +87 -0
- package/src/clients/notifications/adapters/notifications.adapter-interface.ts +112 -0
- package/src/clients/notifications/adapters/wix-notifications.adapter.ts +183 -0
- package/src/clients/notifications/index.ts +2 -0
- package/src/clients/notifications/notifications.client.ts +214 -3
- package/src/clients/storage/adapters/async-storage.adapter.ts +2 -6
- package/src/clients/storage/adapters/storage-adapters.test.ts +2 -7
- package/src/clients/storage/adapters/storage.adpater-interface.ts +1 -5
- package/src/clients/utils/index.ts +1 -1
- package/src/clients/utils/utils.client.ts +1 -1
- package/src/exports/adapters/async-storage.ts +1 -1
- package/src/exports/adapters/expo.ts +1 -1
- package/src/exports/expo.ts +2 -0
- package/src/exports/firebase.ts +1 -0
- package/src/exports/index.ts +6 -9
- package/src/exports/wix.ts +1 -0
- package/src/hooks/use-force-update.ts +31 -34
- package/src/index.ts +1 -0
- package/src/teardown.core.ts +0 -2
- package/src/.DS_Store +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, expect, mock, 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("
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
1052
|
+
expect(JSON.parse(intermediateStored ?? "{}").type).toBe("identifying");
|
|
1045
1053
|
|
|
1046
1054
|
// Complete API call
|
|
1047
|
-
resolveApi
|
|
1055
|
+
resolveApi?.(null);
|
|
1048
1056
|
await identifyPromise;
|
|
1049
1057
|
|
|
1050
1058
|
const finalStored = mockStorage.getStorage().get(IDENTIFY_STORAGE_KEY);
|
|
1051
|
-
expect(JSON.parse(finalStored
|
|
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
|
});
|
|
@@ -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
|
+
}
|