@teardown/react-native 2.0.24 → 2.0.28

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 (59) 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/identity/identity.client.test.ts +8 -8
  36. package/src/clients/identity/identity.client.ts +1 -1
  37. package/src/clients/identity/index.ts +1 -1
  38. package/src/clients/logging/index.ts +1 -1
  39. package/src/clients/notifications/adapters/expo-notifications.adapter.ts +105 -0
  40. package/src/clients/notifications/adapters/firebase-messaging.adapter.ts +87 -0
  41. package/src/clients/notifications/adapters/notifications.adapter-interface.ts +112 -0
  42. package/src/clients/notifications/adapters/wix-notifications.adapter.ts +183 -0
  43. package/src/clients/notifications/index.ts +2 -0
  44. package/src/clients/notifications/notifications.client.ts +214 -3
  45. package/src/clients/storage/adapters/async-storage.adapter.ts +2 -6
  46. package/src/clients/storage/adapters/storage-adapters.test.ts +2 -7
  47. package/src/clients/storage/adapters/storage.adpater-interface.ts +1 -5
  48. package/src/clients/utils/index.ts +1 -1
  49. package/src/clients/utils/utils.client.ts +1 -1
  50. package/src/exports/adapters/async-storage.ts +1 -1
  51. package/src/exports/adapters/expo.ts +1 -1
  52. package/src/exports/expo.ts +2 -0
  53. package/src/exports/firebase.ts +1 -0
  54. package/src/exports/index.ts +6 -9
  55. package/src/exports/wix.ts +1 -0
  56. package/src/hooks/use-force-update.ts +31 -34
  57. package/src/index.ts +1 -0
  58. package/src/teardown.core.ts +0 -2
  59. package/src/.DS_Store +0 -0
@@ -1,36 +1,29 @@
1
- import type {
2
- ApplicationInfo,
3
- HardwareInfo,
4
- OSInfo
5
- } from "@teardown/schemas";
1
+ import type { ApplicationInfo, HardwareInfo, OSInfo } from "@teardown/schemas";
6
2
  import { Platform } from "react-native";
7
3
  import DeviceInfo from "react-native-device-info";
8
4
  import { DeviceInfoAdapter as BaseInfoAdapterInterface } from "./device.adpater-interface";
9
5
 
10
6
  export class DeviceInfoAdapter extends BaseInfoAdapterInterface {
11
- get applicationInfo(): ApplicationInfo {
12
- return {
13
- version: DeviceInfo.getVersion() ?? "0.0.0",
14
- build_number: Number.parseInt(
15
- DeviceInfo.getBuildNumber() ?? "0",
16
- 10
17
- ),
18
- };
19
- }
7
+ get applicationInfo(): ApplicationInfo {
8
+ return {
9
+ version: DeviceInfo.getVersion() ?? "0.0.0",
10
+ build_number: Number.parseInt(DeviceInfo.getBuildNumber() ?? "0", 10),
11
+ };
12
+ }
20
13
 
21
- get hardwareInfo(): HardwareInfo {
22
- return {
23
- device_name: DeviceInfo.getDeviceNameSync() ?? "Unknown Device",
24
- device_brand: DeviceInfo.getBrand() ?? "Unknown Brand",
25
- device_type: DeviceInfo.getDeviceType?.() ?? "unknown",
26
- };
27
- }
14
+ get hardwareInfo(): HardwareInfo {
15
+ return {
16
+ device_name: DeviceInfo.getDeviceNameSync() ?? "Unknown Device",
17
+ device_brand: DeviceInfo.getBrand() ?? "Unknown Brand",
18
+ device_type: DeviceInfo.getDeviceType?.() ?? "unknown",
19
+ };
20
+ }
28
21
 
29
- get osInfo(): OSInfo {
30
- return {
31
- platform: this.mapPlatform(Platform.OS),
32
- name: DeviceInfo.getSystemName() ?? Platform.OS,
33
- version: DeviceInfo.getSystemVersion() ?? "0.0.0",
34
- };
35
- }
22
+ get osInfo(): OSInfo {
23
+ return {
24
+ platform: this.mapPlatform(Platform.OS),
25
+ name: DeviceInfo.getSystemName() ?? Platform.OS,
26
+ version: DeviceInfo.getSystemVersion() ?? "0.0.0",
27
+ };
28
+ }
36
29
  }
@@ -1,9 +1,4 @@
1
- import type {
2
- ApplicationInfo,
3
- DeviceInfo,
4
- HardwareInfo,
5
- OSInfo
6
- } from "@teardown/schemas";
1
+ import type { ApplicationInfo, DeviceInfo, HardwareInfo, OSInfo } from "@teardown/schemas";
7
2
  import type { Platform } from "react-native";
8
3
  import { DevicePlatformEnum } from "../device.client";
9
4
 
@@ -37,7 +32,6 @@ export abstract class DeviceInfoAdapter {
37
32
  application: this.applicationInfo,
38
33
  hardware: this.hardwareInfo,
39
34
  os: this.osInfo,
40
- notifications: null,
41
35
  update: null,
42
36
  });
43
37
  }
@@ -61,5 +55,4 @@ export abstract class DeviceInfoAdapter {
61
55
  return DevicePlatformEnum.UNKNOWN;
62
56
  }
63
57
  }
64
-
65
58
  }
@@ -1,8 +1,4 @@
1
- import type {
2
- ApplicationInfo,
3
- HardwareInfo,
4
- OSInfo
5
- } from "@teardown/schemas";
1
+ import type { ApplicationInfo, HardwareInfo, OSInfo } from "@teardown/schemas";
6
2
  import * as Application from "expo-application";
7
3
  import * as Device from "expo-device";
8
4
  import { Platform } from "react-native";
@@ -13,44 +9,41 @@ import { DeviceInfoAdapter } from "./device.adpater-interface";
13
9
  * Maps expo-device DeviceType to a string representation
14
10
  */
15
11
  function mapDeviceType(deviceType: Device.DeviceType | null): string {
16
- switch (deviceType) {
17
- case Device.DeviceType.PHONE:
18
- return "phone";
19
- case Device.DeviceType.TABLET:
20
- return "tablet";
21
- case Device.DeviceType.DESKTOP:
22
- return "desktop";
23
- case Device.DeviceType.TV:
24
- return "tv";
25
- default:
26
- return "unknown";
27
- }
12
+ switch (deviceType) {
13
+ case Device.DeviceType.PHONE:
14
+ return "phone";
15
+ case Device.DeviceType.TABLET:
16
+ return "tablet";
17
+ case Device.DeviceType.DESKTOP:
18
+ return "desktop";
19
+ case Device.DeviceType.TV:
20
+ return "tv";
21
+ default:
22
+ return "unknown";
23
+ }
28
24
  }
29
25
 
30
26
  export class ExpoDeviceAdapter extends DeviceInfoAdapter {
31
- get applicationInfo(): ApplicationInfo {
32
- return {
33
- version: Application.nativeApplicationVersion ?? "0.0.0",
34
- build_number: Number.parseInt(
35
- Application.nativeBuildVersion ?? "0",
36
- 10
37
- ),
38
- };
39
- }
27
+ get applicationInfo(): ApplicationInfo {
28
+ return {
29
+ version: Application.nativeApplicationVersion ?? "0.0.0",
30
+ build_number: Number.parseInt(Application.nativeBuildVersion ?? "0", 10),
31
+ };
32
+ }
40
33
 
41
- get hardwareInfo(): HardwareInfo {
42
- return {
43
- device_name: Device.deviceName ?? "Unknown Device",
44
- device_brand: Device.brand ?? "Unknown Brand",
45
- device_type: mapDeviceType(Device.deviceType),
46
- };
47
- }
34
+ get hardwareInfo(): HardwareInfo {
35
+ return {
36
+ device_name: Device.deviceName ?? "Unknown Device",
37
+ device_brand: Device.brand ?? "Unknown Brand",
38
+ device_type: mapDeviceType(Device.deviceType),
39
+ };
40
+ }
48
41
 
49
- get osInfo(): OSInfo {
50
- return {
51
- platform: this.mapPlatform(Platform.OS),
52
- name: Device.osName ?? Platform.OS,
53
- version: Device.osVersion ?? "0.0.0",
54
- };
55
- }
42
+ get osInfo(): OSInfo {
43
+ return {
44
+ platform: this.mapPlatform(Platform.OS),
45
+ name: Device.osName ?? Platform.OS,
46
+ version: Device.osVersion ?? "0.0.0",
47
+ };
48
+ }
56
49
  }
@@ -1,15 +1,15 @@
1
- import type { DeviceInfo } from "@teardown/schemas";
2
1
  import { describe, expect, test } from "bun:test";
2
+ import type { DeviceInfo } from "@teardown/schemas";
3
3
  import type { DeviceInfoAdapter } from "./adapters/device.adpater-interface";
4
4
  import { DeviceClient } from "./device.client";
5
5
 
6
6
  function createMockLoggingClient() {
7
7
  return {
8
8
  createLogger: () => ({
9
- info: () => { },
10
- warn: () => { },
11
- error: () => { },
12
- debug: () => { },
9
+ info: () => {},
10
+ warn: () => {},
11
+ error: () => {},
12
+ debug: () => {},
13
13
  }),
14
14
  };
15
15
  }
@@ -70,12 +70,9 @@ describe("DeviceClient", () => {
70
70
  const mockUtils = createMockUtilsClient();
71
71
  const mockAdapter = createMockDeviceAdapter();
72
72
 
73
- const client = new DeviceClient(
74
- mockLogging as never,
75
- mockUtils as never,
76
- mockStorage as never,
77
- { adapter: mockAdapter }
78
- );
73
+ const client = new DeviceClient(mockLogging as never, mockUtils as never, mockStorage as never, {
74
+ adapter: mockAdapter,
75
+ });
79
76
 
80
77
  const deviceId = await client.getDeviceId();
81
78
 
@@ -92,12 +89,9 @@ describe("DeviceClient", () => {
92
89
  // Pre-populate storage with a device ID
93
90
  mockStorage.getStorage().set("deviceId", "existing-device-id");
94
91
 
95
- const client = new DeviceClient(
96
- mockLogging as never,
97
- mockUtils as never,
98
- mockStorage as never,
99
- { adapter: mockAdapter }
100
- );
92
+ const client = new DeviceClient(mockLogging as never, mockUtils as never, mockStorage as never, {
93
+ adapter: mockAdapter,
94
+ });
101
95
 
102
96
  const deviceId = await client.getDeviceId();
103
97
 
@@ -114,12 +108,9 @@ describe("DeviceClient", () => {
114
108
 
115
109
  mockStorage.getStorage().set("deviceId", "consistent-id");
116
110
 
117
- const client = new DeviceClient(
118
- mockLogging as never,
119
- mockUtils as never,
120
- mockStorage as never,
121
- { adapter: mockAdapter }
122
- );
111
+ const client = new DeviceClient(mockLogging as never, mockUtils as never, mockStorage as never, {
112
+ adapter: mockAdapter,
113
+ });
123
114
 
124
115
  const id1 = await client.getDeviceId();
125
116
  const id2 = await client.getDeviceId();
@@ -138,12 +129,9 @@ describe("DeviceClient", () => {
138
129
  const mockUtils = createMockUtilsClient();
139
130
  const mockAdapter = createMockDeviceAdapter();
140
131
 
141
- const client = new DeviceClient(
142
- mockLogging as never,
143
- mockUtils as never,
144
- mockStorage as never,
145
- { adapter: mockAdapter }
146
- );
132
+ const client = new DeviceClient(mockLogging as never, mockUtils as never, mockStorage as never, {
133
+ adapter: mockAdapter,
134
+ });
147
135
 
148
136
  const deviceInfo = await client.getDeviceInfo();
149
137
 
@@ -174,12 +162,9 @@ describe("DeviceClient", () => {
174
162
  },
175
163
  } as DeviceInfoAdapter;
176
164
 
177
- const client = new DeviceClient(
178
- mockLogging as never,
179
- mockUtils as never,
180
- mockStorage as never,
181
- { adapter: customAdapter }
182
- );
165
+ const client = new DeviceClient(mockLogging as never, mockUtils as never, mockStorage as never, {
166
+ adapter: customAdapter,
167
+ });
183
168
 
184
169
  const deviceInfo = await client.getDeviceInfo();
185
170
 
@@ -51,7 +51,6 @@ export class DeviceClient {
51
51
  }
52
52
 
53
53
  async getDeviceId(): Promise<string> {
54
-
55
54
  this.logger.debug("Getting device ID");
56
55
  const deviceId = this.storage.getItem("deviceId");
57
56
  this.logger.debug(`Device ID found in storage: ${deviceId}`);
@@ -68,6 +67,4 @@ export class DeviceClient {
68
67
  async getDeviceInfo(): Promise<DeviceInfo> {
69
68
  return this.options.adapter.getDeviceInfo();
70
69
  }
71
-
72
-
73
70
  }
@@ -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", () => ({
@@ -493,7 +493,7 @@ describe("IdentityClient", () => {
493
493
 
494
494
  const stored = mockStorage.getStorage().get(IDENTIFY_STORAGE_KEY);
495
495
  expect(stored).toBeDefined();
496
- const parsed = JSON.parse(stored!);
496
+ const parsed = JSON.parse(stored ?? "{}");
497
497
  expect(parsed.type).toBe("identified");
498
498
  expect(parsed.session.session_id).toBe("session-123");
499
499
  expect(parsed.version_info.status).toBe(IdentifyVersionStatusEnum.UP_TO_DATE);
@@ -755,7 +755,7 @@ describe("IdentityClient", () => {
755
755
  await client.identify();
756
756
 
757
757
  // Update mock to return new session
758
- mockApi.client = async (endpoint: string, config: ApiCallRecord["config"]) => ({
758
+ mockApi.client = async (_endpoint: string, _config: ApiCallRecord["config"]) => ({
759
759
  error: null,
760
760
  data: {
761
761
  data: {
@@ -800,7 +800,7 @@ describe("IdentityClient", () => {
800
800
  // After reset, storage should contain unidentified state
801
801
  const stored = mockStorage.getStorage().get(IDENTIFY_STORAGE_KEY);
802
802
  expect(stored).toBeDefined();
803
- expect(JSON.parse(stored!).type).toBe("unidentified");
803
+ expect(JSON.parse(stored ?? "{}").type).toBe("unidentified");
804
804
  });
805
805
 
806
806
  test("emits unidentified state on reset", async () => {
@@ -1049,14 +1049,14 @@ describe("IdentityClient", () => {
1049
1049
  // Check storage while API call is pending
1050
1050
  await new Promise((resolve) => setTimeout(resolve, 10));
1051
1051
  const intermediateStored = mockStorage.getStorage().get(IDENTIFY_STORAGE_KEY);
1052
- expect(JSON.parse(intermediateStored!).type).toBe("identifying");
1052
+ expect(JSON.parse(intermediateStored ?? "{}").type).toBe("identifying");
1053
1053
 
1054
1054
  // Complete API call
1055
- resolveApi!(null);
1055
+ resolveApi?.(null);
1056
1056
  await identifyPromise;
1057
1057
 
1058
1058
  const finalStored = mockStorage.getStorage().get(IDENTIFY_STORAGE_KEY);
1059
- expect(JSON.parse(finalStored!).type).toBe("identified");
1059
+ expect(JSON.parse(finalStored ?? "{}").type).toBe("identified");
1060
1060
  });
1061
1061
 
1062
1062
  test("storage survives client recreation", async () => {
@@ -1140,7 +1140,7 @@ describe("IdentityClient", () => {
1140
1140
 
1141
1141
  // Verify it can be stored and retrieved
1142
1142
  const stored = mockStorage.getStorage().get(IDENTIFY_STORAGE_KEY);
1143
- const parsed = JSON.parse(stored!);
1143
+ const parsed = JSON.parse(stored ?? "{}");
1144
1144
  expect(parsed.session.session_id).toBe("session-with-émojis-🚀-and-üñíçödé");
1145
1145
  });
1146
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
+ }