@teardown/react-native 2.0.24 → 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/identity/identity.client.test.ts +8 -8
- 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,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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
1052
|
+
expect(JSON.parse(intermediateStored ?? "{}").type).toBe("identifying");
|
|
1053
1053
|
|
|
1054
1054
|
// Complete API call
|
|
1055
|
-
resolveApi
|
|
1055
|
+
resolveApi?.(null);
|
|
1056
1056
|
await identifyPromise;
|
|
1057
1057
|
|
|
1058
1058
|
const finalStored = mockStorage.getStorage().get(IDENTIFY_STORAGE_KEY);
|
|
1059
|
-
expect(JSON.parse(finalStored
|
|
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
|
});
|
|
@@ -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
|
+
}
|