@vvedantb/native-ui 0.8.0 → 0.9.0

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/src/push.ts ADDED
@@ -0,0 +1,186 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { Platform } from "react-native";
3
+ import type * as ExpoConstants from "expo-constants";
4
+ import type * as ExpoDevice from "expo-device";
5
+ import type * as ExpoNotifications from "expo-notifications";
6
+
7
+ // `expo-notifications`, `expo-constants` and `expo-device` are optional peers.
8
+ // Metro bundles a `require` placed directly in a `try` block as an optional
9
+ // dependency, so a missing peer does not break the bundle.
10
+
11
+ export type ExpoNotificationsModule = typeof ExpoNotifications;
12
+ export type ExpoConstantsModule = typeof ExpoConstants;
13
+ export type ExpoDeviceModule = typeof ExpoDevice;
14
+
15
+ let notifications: ExpoNotificationsModule | null | undefined;
16
+ let constants: ExpoConstantsModule | null | undefined;
17
+ let device: ExpoDeviceModule | null | undefined;
18
+
19
+ /** `expo-notifications`, or `null` when the optional peer is not installed. */
20
+ export function loadNotifications(): ExpoNotificationsModule | null {
21
+ if (notifications === undefined) {
22
+ try {
23
+ notifications = require("expo-notifications") as ExpoNotificationsModule;
24
+ } catch {
25
+ notifications = null;
26
+ }
27
+ }
28
+ return notifications;
29
+ }
30
+
31
+ /** `expo-constants` (its default export is the `Constants` object), or `null` when not installed. */
32
+ export function loadConstants(): ExpoConstantsModule | null {
33
+ if (constants === undefined) {
34
+ try {
35
+ constants = require("expo-constants") as ExpoConstantsModule;
36
+ } catch {
37
+ constants = null;
38
+ }
39
+ }
40
+ return constants;
41
+ }
42
+
43
+ /** `expo-device`, or `null` when the optional peer is not installed. */
44
+ export function loadDevice(): ExpoDeviceModule | null {
45
+ if (device === undefined) {
46
+ try {
47
+ device = require("expo-device") as ExpoDeviceModule;
48
+ } catch {
49
+ device = null;
50
+ }
51
+ }
52
+ return device;
53
+ }
54
+
55
+ export type ExpoPushPlatform = "ios" | "android";
56
+ export type ExpoPushRegisterArgs = { token: string; platform: ExpoPushPlatform };
57
+
58
+ /** A tapped push: the notification's request id and its `data` object. */
59
+ export type ExpoPushTap = {
60
+ identifier: string;
61
+ data: Record<string, unknown>;
62
+ response: ExpoNotifications.NotificationResponse;
63
+ };
64
+
65
+ export type GetExpoPushTokenOptions = {
66
+ /** Show the permission prompt when it was never answered. */
67
+ ask: boolean;
68
+ /** Android channel id, also used as its name. Default `"default"`. */
69
+ channelName?: string;
70
+ };
71
+
72
+ /**
73
+ * The Expo push token for this device, or `null` when push is not possible:
74
+ * a peer is missing, a simulator or emulator, no permission, no EAS project id
75
+ * (`expo.extra.eas.projectId`), or no FCM credentials in this Android build.
76
+ */
77
+ export async function getExpoPushToken({ ask, channelName = "default" }: GetExpoPushTokenOptions): Promise<string | null> {
78
+ const Notifications = loadNotifications();
79
+ const constantsModule = loadConstants();
80
+ const deviceModule = loadDevice();
81
+ if (!Notifications || !constantsModule || !deviceModule) return null;
82
+ if (!deviceModule.isDevice) return null;
83
+ const projectId: unknown = constantsModule.default.expoConfig?.extra?.eas?.projectId;
84
+ if (typeof projectId !== "string" || projectId === "") return null;
85
+ if (Platform.OS === "android") {
86
+ await Notifications.setNotificationChannelAsync(channelName, {
87
+ name: channelName,
88
+ importance: Notifications.AndroidImportance.HIGH,
89
+ });
90
+ }
91
+ const current = await Notifications.getPermissionsAsync();
92
+ const granted = current.granted || (ask && (await Notifications.requestPermissionsAsync()).granted);
93
+ if (!granted) return null;
94
+ try {
95
+ return (await Notifications.getExpoPushTokenAsync({ projectId })).data;
96
+ } catch {
97
+ // No google-services.json in this build: FCM cannot issue a token.
98
+ return null;
99
+ }
100
+ }
101
+
102
+ let foregroundHandlerSet = false;
103
+
104
+ /** Shows pushes as banners while the app is open. Runs once; the hook calls it. */
105
+ export function showPushesInForeground(): void {
106
+ const Notifications = loadNotifications();
107
+ if (!Notifications || foregroundHandlerSet) return;
108
+ foregroundHandlerSet = true;
109
+ Notifications.setNotificationHandler({
110
+ handleNotification: async () => ({
111
+ shouldShowBanner: true,
112
+ shouldShowList: true,
113
+ shouldPlaySound: true,
114
+ shouldSetBadge: false,
115
+ }),
116
+ });
117
+ }
118
+
119
+ export type UseExpoPushRegistrationOptions = {
120
+ /**
121
+ * Saves the token on your backend, for example `useMutation(api.push.register)`.
122
+ * Called once on mount, with the latest function.
123
+ */
124
+ register: (args: ExpoPushRegisterArgs) => Promise<unknown>;
125
+ /** Called when the user taps a push, including the tap that cold-started the app. Once per push. */
126
+ onNotificationTap?: (tap: ExpoPushTap) => void;
127
+ /** Android channel id, also used as its name. Default `"default"`. */
128
+ channelName?: string;
129
+ /** Show the permission prompt when it was never answered. Default `true`. */
130
+ ask?: boolean;
131
+ /** Show pushes as banners while the app is open. Default `true`. */
132
+ showInForeground?: boolean;
133
+ };
134
+
135
+ function toTap(response: ExpoNotifications.NotificationResponse | null | undefined): ExpoPushTap | null {
136
+ if (!response) return null;
137
+ const { identifier, content } = response.notification.request;
138
+ const data = content.data;
139
+ return { identifier, data: typeof data === "object" && data !== null ? data : {}, response };
140
+ }
141
+
142
+ /**
143
+ * Mount once under your auth gate. Registers this device's token through
144
+ * `register` and reports taps on pushes through `onNotificationTap`. Does
145
+ * nothing when a peer is missing. Navigation is yours: route from
146
+ * `tap.data` in the callback.
147
+ */
148
+ export function useExpoPushRegistration(options: UseExpoPushRegistrationOptions): void {
149
+ const { channelName, ask = true, showInForeground = true } = options;
150
+ const latest = useRef(options);
151
+ latest.current = options;
152
+
153
+ useEffect(() => {
154
+ if (showInForeground) showPushesInForeground();
155
+ let cancelled = false;
156
+ void getExpoPushToken({ ask, channelName }).then((token) => {
157
+ if (token && !cancelled) {
158
+ void latest.current.register({ token, platform: Platform.OS === "ios" ? "ios" : "android" });
159
+ }
160
+ });
161
+ return () => {
162
+ cancelled = true;
163
+ };
164
+ }, [ask, channelName, showInForeground]);
165
+
166
+ useEffect(() => {
167
+ const Notifications = loadNotifications();
168
+ if (!Notifications) return;
169
+ const seen = new Set<string>();
170
+ const deliver = (response: ExpoNotifications.NotificationResponse | null | undefined) => {
171
+ const tap = toTap(response);
172
+ if (!tap || seen.has(tap.identifier)) return;
173
+ seen.add(tap.identifier);
174
+ latest.current.onNotificationTap?.(tap);
175
+ };
176
+ let cancelled = false;
177
+ void Notifications.getLastNotificationResponseAsync().then((response) => {
178
+ if (!cancelled) deliver(response);
179
+ });
180
+ const subscription = Notifications.addNotificationResponseReceivedListener(deliver);
181
+ return () => {
182
+ cancelled = true;
183
+ subscription.remove();
184
+ };
185
+ }, []);
186
+ }