expo-release 2.2.1 → 2.3.1
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/README.md +48 -7
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +65 -20
- package/dist/runtime/push.d.ts +19 -7
- package/package.json +1 -1
- package/src/runtime/index.ts +1 -0
- package/src/runtime/push.ts +98 -21
package/README.md
CHANGED
|
@@ -492,12 +492,30 @@ export const push = createPushNotifications({
|
|
|
492
492
|
| Member | Use |
|
|
493
493
|
|--------|-----|
|
|
494
494
|
| `prompt()` | After sign-in. Asks once per install (soft-ask, then OS dialog), registers the token on a grant. Never throws; concurrent calls share one dialog. |
|
|
495
|
-
| `enable()` / `disable()` | Settings toggle. `enable` requests permission and registers; `disable` calls `registerDevice(null)`.
|
|
496
|
-
| `syncToken()` |
|
|
497
|
-
| `listen({ onReceived, onResponse })` | Subscribes and returns the unsubscribe function. `onResponse` also receives the tap that launched the app. |
|
|
495
|
+
| `enable()` / `disable()` | Settings toggle. `enable` requests permission and registers; `disable` calls `registerDevice(null)`. Use `disable()` to turn push off (also on sign-out) so the token is not re-sent on the next launch. |
|
|
496
|
+
| `syncToken()` | Registers the current token when permission is granted. |
|
|
497
|
+
| `listen({ onReceived, onResponse })` | Subscribes and returns the unsubscribe function. `onResponse` also receives the tap that launched the app. Also keeps the token current: on start and whenever Expo rotates it, a token that differs from the last one sent is registered again. This runs only after a token was sent and `disable()` has not run since. Call it once at the root of the app. |
|
|
498
498
|
| `getPermissionStatus()`, `getPushToken()`, `requestPermissions()`, `hasPrompted()` | Lower-level steps. |
|
|
499
499
|
|
|
500
|
-
`usePushNotifications(push, { enabled })` gives a settings screen `permission`, `isEnabled`, `isLoading` and wrapped `prompt`/`enable`/`disable`. `enabled` is the saved preference; while it is on, the token is re-registered on mount.
|
|
500
|
+
`usePushNotifications(push, { enabled, persist })` gives a settings screen `permission`, `isEnabled`, `isLoading` and wrapped `prompt`/`enable`/`disable`. `enabled` is the saved preference; while it is on, the token is re-registered on mount.
|
|
501
|
+
|
|
502
|
+
`persist` saves the preference, for example on the user's account. `enable()` and `disable()` call it first and show the new value right away. If it rejects, the hook restores `enabled`, skips the push step and rethrows, so catch it for your error toast. Saving before `enable()` means a denied dialog leaves the preference on with `isEnabled` false; the token registers on the next mount once permission is granted in device settings. Without `persist`, save the preference yourself before calling `enable()`.
|
|
503
|
+
|
|
504
|
+
```tsx
|
|
505
|
+
const { isEnabled, isLoading, enable, disable } = usePushNotifications(push, {
|
|
506
|
+
enabled: user.pushEnabled,
|
|
507
|
+
persist: (pushEnabled) => api.user.update({ pushEnabled })
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
const onToggle = async (on: boolean) => {
|
|
511
|
+
try {
|
|
512
|
+
if (!on) return await disable()
|
|
513
|
+
if (!(await enable())) toast('Allow notifications in device settings')
|
|
514
|
+
} catch {
|
|
515
|
+
toast('Could not save your notification setting')
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
```
|
|
501
519
|
|
|
502
520
|
### Error boundary
|
|
503
521
|
|
|
@@ -522,9 +540,9 @@ Composite actions for Bun monorepos, used as `HansKristoffer/expo-release/.githu
|
|
|
522
540
|
|
|
523
541
|
| Action | Does |
|
|
524
542
|
|--------|------|
|
|
525
|
-
| `setup-workspace` | Bun (version from `package.json` `packageManager` by default), optional Node, restore the dependency cache keyed on `bun.lock`, always `bun install --frozen-lockfile`, save on a miss with `save-cache: 'true'`. `cache-paths` and `cache-key-files` are inputs. |
|
|
526
|
-
| `playwright-browsers` | Restores browsers for the installed Playwright version; installs and saves on a miss. |
|
|
527
|
-
| `pr-preview` | Railway preview environment per PR with its own PlanetScale Postgres branch and a Cloudflare-managed host. Creates on open/sync, deletes on close. |
|
|
543
|
+
| `setup-workspace` | Bun (version from `package.json` `packageManager` by default), optional Node, restore the dependency cache keyed on `bun.lock`, always `bun install --frozen-lockfile`, save on a miss with `save-cache: 'true'`. `cache-paths` and `cache-key-files` are inputs. On a self-hosted runner it keeps `cache-paths` instead (see below). |
|
|
544
|
+
| `playwright-browsers` | Restores browsers for the installed Playwright version; installs and saves on a miss. A self-hosted runner skips the cache and keeps `~/.cache/ms-playwright`. |
|
|
545
|
+
| `pr-preview` | Railway preview environment per PR with its own PlanetScale Postgres branch and a Cloudflare-managed host. Creates on open/sync, deletes on close. The Railway CLI goes into the job's `$RUNNER_TEMP`. |
|
|
528
546
|
| `ai-changelog` | Plain-language changelog of a pushed range with Claude; outputs `message` (chat) and `markdown`, optionally POSTs the message to a webhook. |
|
|
529
547
|
|
|
530
548
|
### Warm the caches on main
|
|
@@ -562,6 +580,29 @@ jobs:
|
|
|
562
580
|
|
|
563
581
|
Use the same `cache-paths` in every job, since they are part of the cache version.
|
|
564
582
|
|
|
583
|
+
### Self-hosted runners
|
|
584
|
+
|
|
585
|
+
A self-hosted runner keeps its workspace between jobs, so these actions keep what a hosted VM would restore. Check out with `clean: false`; `setup-workspace` then removes every untracked file except `cache-paths` and `keep-paths`, skips the cache, and skips `bun install` when the files named by `install-key-paths` and the Bun version are unchanged since that runner's last install. Add the files a postinstall reads:
|
|
586
|
+
|
|
587
|
+
```yaml
|
|
588
|
+
- uses: actions/checkout@v5
|
|
589
|
+
with:
|
|
590
|
+
clean: false
|
|
591
|
+
- uses: HansKristoffer/expo-release/.github/actions/setup-workspace@v2
|
|
592
|
+
with:
|
|
593
|
+
cache-paths: |
|
|
594
|
+
node_modules
|
|
595
|
+
packages/*/node_modules
|
|
596
|
+
apps/*/node_modules
|
|
597
|
+
packages/prisma/generated
|
|
598
|
+
keep-paths: .buncargo/typecheck-timings.json
|
|
599
|
+
install-key-paths: |
|
|
600
|
+
bun.lock
|
|
601
|
+
*package.json
|
|
602
|
+
patches
|
|
603
|
+
packages/prisma/schema
|
|
604
|
+
```
|
|
605
|
+
|
|
565
606
|
### PR preview environments
|
|
566
607
|
|
|
567
608
|
The preview duplicates `railway-source-environment` (default `staging`) into `pr-<number>`, points git-connected services at the PR branch, sets `DATABASE_URL` and `DATABASE_URL_DIRECT` to a fresh PlanetScale branch, attaches `pr-<number>.<zone>` with Cloudflare DNS, then sets `BASE_URL`, which triggers the one deployment (run migrations and seeds in your pre-deploy step).
|
package/dist/runtime/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { ErrorBoundary, type ErrorBoundaryFallbackProps, type ErrorBoundaryProps } from './error-boundary';
|
|
2
2
|
export { checkForOTAUpdate, type ReloadApp, type UseOTAUpdatesOptions, useOTAUpdates } from './ota';
|
|
3
|
-
export { createPushNotifications, type KeyValueStorage, type NotificationListeners, type PushNotifications, type PushNotificationsOptions, usePushNotifications } from './push';
|
|
3
|
+
export { createPushNotifications, type KeyValueStorage, type NotificationListeners, type PushNotifications, type PushNotificationsOptions, type UsePushNotificationsOptions, usePushNotifications } from './push';
|
package/dist/runtime/index.js
CHANGED
|
@@ -57,7 +57,8 @@ function createPushNotifications(options) {
|
|
|
57
57
|
onUnavailable,
|
|
58
58
|
onError = (message, error) => console.error(message, error),
|
|
59
59
|
projectId,
|
|
60
|
-
storageKey = "@notifications_permission_asked"
|
|
60
|
+
storageKey = "@notifications_permission_asked",
|
|
61
|
+
tokenStorageKey = "@notifications_push_token"
|
|
61
62
|
} = options;
|
|
62
63
|
Notifications.setNotificationHandler({
|
|
63
64
|
handleNotification: async () => options.foregroundBehavior ?? {
|
|
@@ -74,13 +75,23 @@ function createPushNotifications(options) {
|
|
|
74
75
|
}).catch((error) => onError("Failed to create the notification channel", error));
|
|
75
76
|
let pendingPrompt = null;
|
|
76
77
|
const getPermissionStatus = () => Notifications.getPermissionsAsync();
|
|
77
|
-
async function
|
|
78
|
+
async function read(key) {
|
|
78
79
|
try {
|
|
79
|
-
return await storage.getItem(
|
|
80
|
+
return await storage.getItem(key);
|
|
80
81
|
} catch {
|
|
81
|
-
return
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function rememberToken(token) {
|
|
86
|
+
try {
|
|
87
|
+
await storage.setItem(tokenStorageKey, token);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
onError("Failed to remember the push token", error);
|
|
82
90
|
}
|
|
83
91
|
}
|
|
92
|
+
async function hasPrompted() {
|
|
93
|
+
return await read(storageKey) === "true";
|
|
94
|
+
}
|
|
84
95
|
async function requestPermissions() {
|
|
85
96
|
if (!Device.isDevice) {
|
|
86
97
|
onUnavailable?.("not-a-device");
|
|
@@ -96,30 +107,40 @@ function createPushNotifications(options) {
|
|
|
96
107
|
return false;
|
|
97
108
|
}
|
|
98
109
|
}
|
|
99
|
-
async function getPushToken() {
|
|
110
|
+
async function getPushToken(devicePushToken) {
|
|
100
111
|
if (!Device.isDevice)
|
|
101
112
|
return null;
|
|
102
113
|
try {
|
|
103
|
-
const token = await Notifications.getExpoPushTokenAsync(projectId ? { projectId } : undefined);
|
|
114
|
+
const token = await Notifications.getExpoPushTokenAsync(projectId || devicePushToken ? { projectId, devicePushToken } : undefined);
|
|
104
115
|
return token.data;
|
|
105
116
|
} catch (error) {
|
|
106
117
|
onError("Failed to get the Expo push token", error);
|
|
107
118
|
return null;
|
|
108
119
|
}
|
|
109
120
|
}
|
|
110
|
-
async function
|
|
111
|
-
if (!(await getPermissionStatus()).granted)
|
|
112
|
-
return false;
|
|
113
|
-
const token = await getPushToken();
|
|
114
|
-
if (!token)
|
|
115
|
-
return false;
|
|
121
|
+
async function sendToken(token) {
|
|
116
122
|
try {
|
|
117
123
|
await registerDevice(token);
|
|
118
|
-
return true;
|
|
119
124
|
} catch (error) {
|
|
120
125
|
onError("Failed to register the push token", error);
|
|
121
126
|
return false;
|
|
122
127
|
}
|
|
128
|
+
await rememberToken(token);
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
async function syncToken() {
|
|
132
|
+
if (!(await getPermissionStatus()).granted)
|
|
133
|
+
return false;
|
|
134
|
+
const token = await getPushToken();
|
|
135
|
+
return token ? sendToken(token) : false;
|
|
136
|
+
}
|
|
137
|
+
async function refreshToken(devicePushToken) {
|
|
138
|
+
const sent = await read(tokenStorageKey);
|
|
139
|
+
if (!sent || !(await getPermissionStatus()).granted)
|
|
140
|
+
return;
|
|
141
|
+
const token = await getPushToken(devicePushToken);
|
|
142
|
+
if (token && token !== sent)
|
|
143
|
+
await sendToken(token);
|
|
123
144
|
}
|
|
124
145
|
async function runPrompt() {
|
|
125
146
|
if (await hasPrompted())
|
|
@@ -157,11 +178,15 @@ function createPushNotifications(options) {
|
|
|
157
178
|
await syncToken();
|
|
158
179
|
return true;
|
|
159
180
|
},
|
|
160
|
-
disable() {
|
|
161
|
-
|
|
181
|
+
async disable() {
|
|
182
|
+
await registerDevice(null);
|
|
183
|
+
await rememberToken("");
|
|
162
184
|
},
|
|
163
|
-
listen({ onReceived, onResponse }) {
|
|
185
|
+
listen({ onReceived, onResponse } = {}) {
|
|
186
|
+
const refresh = (devicePushToken) => refreshToken(devicePushToken).catch((error) => onError("Failed to refresh the push token", error));
|
|
187
|
+
refresh();
|
|
164
188
|
const subscriptions = [
|
|
189
|
+
Notifications.addPushTokenListener(refresh),
|
|
165
190
|
onReceived && Notifications.addNotificationReceivedListener(onReceived),
|
|
166
191
|
onResponse && Notifications.addNotificationResponseReceivedListener(onResponse)
|
|
167
192
|
];
|
|
@@ -175,17 +200,31 @@ function createPushNotifications(options) {
|
|
|
175
200
|
}
|
|
176
201
|
};
|
|
177
202
|
}
|
|
178
|
-
function usePushNotifications(push, { enabled = false } = {}) {
|
|
203
|
+
function usePushNotifications(push, { enabled = false, persist } = {}) {
|
|
179
204
|
const [permission, setPermission] = useState(null);
|
|
180
205
|
const [isLoading, setIsLoading] = useState(false);
|
|
206
|
+
const [optimistic, setOptimistic] = useState(null);
|
|
207
|
+
const preference = optimistic ?? enabled;
|
|
181
208
|
const refresh = useCallback(async () => {
|
|
182
209
|
setPermission(await push.getPermissionStatus());
|
|
183
210
|
}, [push]);
|
|
184
211
|
useEffect2(() => {
|
|
212
|
+
setOptimistic(null);
|
|
185
213
|
refresh();
|
|
186
214
|
if (enabled)
|
|
187
215
|
push.syncToken();
|
|
188
216
|
}, [push, enabled, refresh]);
|
|
217
|
+
const save = useCallback(async (next) => {
|
|
218
|
+
if (!persist)
|
|
219
|
+
return;
|
|
220
|
+
setOptimistic(next);
|
|
221
|
+
try {
|
|
222
|
+
await persist(next);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
setOptimistic(null);
|
|
225
|
+
throw error;
|
|
226
|
+
}
|
|
227
|
+
}, [persist]);
|
|
189
228
|
const withLoading = useCallback((action) => async () => {
|
|
190
229
|
setIsLoading(true);
|
|
191
230
|
try {
|
|
@@ -197,11 +236,17 @@ function usePushNotifications(push, { enabled = false } = {}) {
|
|
|
197
236
|
}, [refresh]);
|
|
198
237
|
return {
|
|
199
238
|
permission,
|
|
200
|
-
isEnabled:
|
|
239
|
+
isEnabled: preference && permission?.granted === true,
|
|
201
240
|
isLoading,
|
|
202
241
|
prompt: withLoading(push.prompt),
|
|
203
|
-
enable: withLoading(
|
|
204
|
-
|
|
242
|
+
enable: withLoading(async () => {
|
|
243
|
+
await save(true);
|
|
244
|
+
return push.enable();
|
|
245
|
+
}),
|
|
246
|
+
disable: withLoading(async () => {
|
|
247
|
+
await save(false);
|
|
248
|
+
await push.disable();
|
|
249
|
+
}),
|
|
205
250
|
refresh
|
|
206
251
|
};
|
|
207
252
|
}
|
package/dist/runtime/push.d.ts
CHANGED
|
@@ -5,7 +5,7 @@ export interface KeyValueStorage {
|
|
|
5
5
|
setItem(key: string, value: string): Promise<void>;
|
|
6
6
|
}
|
|
7
7
|
export interface PushNotificationsOptions {
|
|
8
|
-
/** Remembers that the one-time prompt ran. */
|
|
8
|
+
/** Remembers that the one-time prompt ran and the last token sent. */
|
|
9
9
|
storage: KeyValueStorage;
|
|
10
10
|
/** Saves this device's Expo push token on your backend; `null` removes it. */
|
|
11
11
|
registerDevice: (token: string | null) => Promise<void>;
|
|
@@ -24,6 +24,8 @@ export interface PushNotificationsOptions {
|
|
|
24
24
|
/** How notifications show while the app is in the foreground. */
|
|
25
25
|
foregroundBehavior?: Notifications.NotificationBehavior;
|
|
26
26
|
storageKey?: string;
|
|
27
|
+
/** Holds the last token passed to `registerDevice`. */
|
|
28
|
+
tokenStorageKey?: string;
|
|
27
29
|
}
|
|
28
30
|
export interface NotificationListeners {
|
|
29
31
|
onReceived?: (notification: Notifications.Notification) => void;
|
|
@@ -39,7 +41,7 @@ export declare function createPushNotifications(options: PushNotificationsOption
|
|
|
39
41
|
getPermissionStatus: () => Promise<Notifications.NotificationPermissionsStatus>;
|
|
40
42
|
hasPrompted: () => Promise<boolean>;
|
|
41
43
|
requestPermissions: () => Promise<boolean>;
|
|
42
|
-
getPushToken: () => Promise<string | null>;
|
|
44
|
+
getPushToken: (devicePushToken?: Notifications.DevicePushToken) => Promise<string | null>;
|
|
43
45
|
syncToken: () => Promise<boolean>;
|
|
44
46
|
/**
|
|
45
47
|
* Asks for push once per install (after sign-in, say), then registers the
|
|
@@ -50,16 +52,26 @@ export declare function createPushNotifications(options: PushNotificationsOption
|
|
|
50
52
|
enable(): Promise<boolean>;
|
|
51
53
|
/** Removes this device's token from your backend. */
|
|
52
54
|
disable(): Promise<void>;
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Subscribes to notifications and keeps the registered token current: on
|
|
57
|
+
* start and whenever Expo rotates it. Returns the unsubscribe function.
|
|
58
|
+
*/
|
|
59
|
+
listen({ onReceived, onResponse }?: NotificationListeners): () => void;
|
|
55
60
|
};
|
|
61
|
+
export interface UsePushNotificationsOptions {
|
|
62
|
+
/** The user's saved preference, for example from your API. */
|
|
63
|
+
enabled?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Saves the preference. `enable()` and `disable()` call it first and show
|
|
66
|
+
* the new value at once; when it rejects, they roll back and rethrow.
|
|
67
|
+
*/
|
|
68
|
+
persist?: (enabled: boolean) => Promise<unknown>;
|
|
69
|
+
}
|
|
56
70
|
/**
|
|
57
71
|
* Push state for a settings screen. `enabled` is the user's saved preference;
|
|
58
72
|
* while it is on, the token is re-registered on mount.
|
|
59
73
|
*/
|
|
60
|
-
export declare function usePushNotifications(push: PushNotifications, { enabled }?: {
|
|
61
|
-
enabled?: boolean;
|
|
62
|
-
}): {
|
|
74
|
+
export declare function usePushNotifications(push: PushNotifications, { enabled, persist }?: UsePushNotificationsOptions): {
|
|
63
75
|
permission: Notifications.NotificationPermissionsStatus | null;
|
|
64
76
|
isEnabled: boolean;
|
|
65
77
|
isLoading: boolean;
|
package/package.json
CHANGED
package/src/runtime/index.ts
CHANGED
package/src/runtime/push.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface KeyValueStorage {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export interface PushNotificationsOptions {
|
|
12
|
-
/** Remembers that the one-time prompt ran. */
|
|
12
|
+
/** Remembers that the one-time prompt ran and the last token sent. */
|
|
13
13
|
storage: KeyValueStorage
|
|
14
14
|
/** Saves this device's Expo push token on your backend; `null` removes it. */
|
|
15
15
|
registerDevice: (token: string | null) => Promise<void>
|
|
@@ -28,6 +28,8 @@ export interface PushNotificationsOptions {
|
|
|
28
28
|
/** How notifications show while the app is in the foreground. */
|
|
29
29
|
foregroundBehavior?: Notifications.NotificationBehavior
|
|
30
30
|
storageKey?: string
|
|
31
|
+
/** Holds the last token passed to `registerDevice`. */
|
|
32
|
+
tokenStorageKey?: string
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export interface NotificationListeners {
|
|
@@ -50,7 +52,8 @@ export function createPushNotifications(options: PushNotificationsOptions) {
|
|
|
50
52
|
onUnavailable,
|
|
51
53
|
onError = (message, error) => console.error(message, error),
|
|
52
54
|
projectId,
|
|
53
|
-
storageKey = '@notifications_permission_asked'
|
|
55
|
+
storageKey = '@notifications_permission_asked',
|
|
56
|
+
tokenStorageKey = '@notifications_push_token'
|
|
54
57
|
} = options
|
|
55
58
|
|
|
56
59
|
Notifications.setNotificationHandler({
|
|
@@ -78,14 +81,26 @@ export function createPushNotifications(options: PushNotificationsOptions) {
|
|
|
78
81
|
|
|
79
82
|
const getPermissionStatus = () => Notifications.getPermissionsAsync()
|
|
80
83
|
|
|
81
|
-
async function
|
|
84
|
+
async function read(key: string) {
|
|
82
85
|
try {
|
|
83
|
-
return
|
|
86
|
+
return await storage.getItem(key)
|
|
84
87
|
} catch {
|
|
85
|
-
return
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function rememberToken(token: string) {
|
|
93
|
+
try {
|
|
94
|
+
await storage.setItem(tokenStorageKey, token)
|
|
95
|
+
} catch (error) {
|
|
96
|
+
onError('Failed to remember the push token', error)
|
|
86
97
|
}
|
|
87
98
|
}
|
|
88
99
|
|
|
100
|
+
async function hasPrompted() {
|
|
101
|
+
return (await read(storageKey)) === 'true'
|
|
102
|
+
}
|
|
103
|
+
|
|
89
104
|
/** Shows the OS dialog when it still can. Resolves whether push is allowed. */
|
|
90
105
|
async function requestPermissions() {
|
|
91
106
|
if (!Device.isDevice) {
|
|
@@ -102,11 +117,13 @@ export function createPushNotifications(options: PushNotificationsOptions) {
|
|
|
102
117
|
}
|
|
103
118
|
}
|
|
104
119
|
|
|
105
|
-
async function getPushToken() {
|
|
120
|
+
async function getPushToken(devicePushToken?: Notifications.DevicePushToken) {
|
|
106
121
|
if (!Device.isDevice) return null
|
|
107
122
|
try {
|
|
108
123
|
const token = await Notifications.getExpoPushTokenAsync(
|
|
109
|
-
projectId
|
|
124
|
+
projectId || devicePushToken
|
|
125
|
+
? { projectId, devicePushToken }
|
|
126
|
+
: undefined
|
|
110
127
|
)
|
|
111
128
|
return token.data
|
|
112
129
|
} catch (error) {
|
|
@@ -115,18 +132,33 @@ export function createPushNotifications(options: PushNotificationsOptions) {
|
|
|
115
132
|
}
|
|
116
133
|
}
|
|
117
134
|
|
|
118
|
-
|
|
119
|
-
async function syncToken() {
|
|
120
|
-
if (!(await getPermissionStatus()).granted) return false
|
|
121
|
-
const token = await getPushToken()
|
|
122
|
-
if (!token) return false
|
|
135
|
+
async function sendToken(token: string) {
|
|
123
136
|
try {
|
|
124
137
|
await registerDevice(token)
|
|
125
|
-
return true
|
|
126
138
|
} catch (error) {
|
|
127
139
|
onError('Failed to register the push token', error)
|
|
128
140
|
return false
|
|
129
141
|
}
|
|
142
|
+
await rememberToken(token)
|
|
143
|
+
return true
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Registers the current token. Resolves whether one was registered. */
|
|
147
|
+
async function syncToken() {
|
|
148
|
+
if (!(await getPermissionStatus()).granted) return false
|
|
149
|
+
const token = await getPushToken()
|
|
150
|
+
return token ? sendToken(token) : false
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Re-registers a token that changed since it was last sent. Runs only while
|
|
155
|
+
* push is on for this device: a token was sent and `disable()` has not run.
|
|
156
|
+
*/
|
|
157
|
+
async function refreshToken(devicePushToken?: Notifications.DevicePushToken) {
|
|
158
|
+
const sent = await read(tokenStorageKey)
|
|
159
|
+
if (!sent || !(await getPermissionStatus()).granted) return
|
|
160
|
+
const token = await getPushToken(devicePushToken)
|
|
161
|
+
if (token && token !== sent) await sendToken(token)
|
|
130
162
|
}
|
|
131
163
|
|
|
132
164
|
async function runPrompt() {
|
|
@@ -174,13 +206,24 @@ export function createPushNotifications(options: PushNotificationsOptions) {
|
|
|
174
206
|
},
|
|
175
207
|
|
|
176
208
|
/** Removes this device's token from your backend. */
|
|
177
|
-
disable() {
|
|
178
|
-
|
|
209
|
+
async disable() {
|
|
210
|
+
await registerDevice(null)
|
|
211
|
+
// KeyValueStorage has no removeItem; an empty token means none was sent.
|
|
212
|
+
await rememberToken('')
|
|
179
213
|
},
|
|
180
214
|
|
|
181
|
-
/**
|
|
182
|
-
|
|
215
|
+
/**
|
|
216
|
+
* Subscribes to notifications and keeps the registered token current: on
|
|
217
|
+
* start and whenever Expo rotates it. Returns the unsubscribe function.
|
|
218
|
+
*/
|
|
219
|
+
listen({ onReceived, onResponse }: NotificationListeners = {}) {
|
|
220
|
+
const refresh = (devicePushToken?: Notifications.DevicePushToken) =>
|
|
221
|
+
refreshToken(devicePushToken).catch((error: unknown) =>
|
|
222
|
+
onError('Failed to refresh the push token', error)
|
|
223
|
+
)
|
|
224
|
+
void refresh()
|
|
183
225
|
const subscriptions = [
|
|
226
|
+
Notifications.addPushTokenListener(refresh),
|
|
184
227
|
onReceived && Notifications.addNotificationReceivedListener(onReceived),
|
|
185
228
|
onResponse &&
|
|
186
229
|
Notifications.addNotificationResponseReceivedListener(onResponse)
|
|
@@ -196,27 +239,55 @@ export function createPushNotifications(options: PushNotificationsOptions) {
|
|
|
196
239
|
}
|
|
197
240
|
}
|
|
198
241
|
|
|
242
|
+
export interface UsePushNotificationsOptions {
|
|
243
|
+
/** The user's saved preference, for example from your API. */
|
|
244
|
+
enabled?: boolean
|
|
245
|
+
/**
|
|
246
|
+
* Saves the preference. `enable()` and `disable()` call it first and show
|
|
247
|
+
* the new value at once; when it rejects, they roll back and rethrow.
|
|
248
|
+
*/
|
|
249
|
+
persist?: (enabled: boolean) => Promise<unknown>
|
|
250
|
+
}
|
|
251
|
+
|
|
199
252
|
/**
|
|
200
253
|
* Push state for a settings screen. `enabled` is the user's saved preference;
|
|
201
254
|
* while it is on, the token is re-registered on mount.
|
|
202
255
|
*/
|
|
203
256
|
export function usePushNotifications(
|
|
204
257
|
push: PushNotifications,
|
|
205
|
-
{ enabled = false }:
|
|
258
|
+
{ enabled = false, persist }: UsePushNotificationsOptions = {}
|
|
206
259
|
) {
|
|
207
260
|
const [permission, setPermission] =
|
|
208
261
|
useState<Notifications.NotificationPermissionsStatus | null>(null)
|
|
209
262
|
const [isLoading, setIsLoading] = useState(false)
|
|
263
|
+
// Set by enable/disable until `enabled` catches up with the saved value.
|
|
264
|
+
const [optimistic, setOptimistic] = useState<boolean | null>(null)
|
|
265
|
+
const preference = optimistic ?? enabled
|
|
210
266
|
|
|
211
267
|
const refresh = useCallback(async () => {
|
|
212
268
|
setPermission(await push.getPermissionStatus())
|
|
213
269
|
}, [push])
|
|
214
270
|
|
|
215
271
|
useEffect(() => {
|
|
272
|
+
setOptimistic(null)
|
|
216
273
|
void refresh()
|
|
217
274
|
if (enabled) void push.syncToken()
|
|
218
275
|
}, [push, enabled, refresh])
|
|
219
276
|
|
|
277
|
+
const save = useCallback(
|
|
278
|
+
async (next: boolean) => {
|
|
279
|
+
if (!persist) return
|
|
280
|
+
setOptimistic(next)
|
|
281
|
+
try {
|
|
282
|
+
await persist(next)
|
|
283
|
+
} catch (error) {
|
|
284
|
+
setOptimistic(null)
|
|
285
|
+
throw error
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
[persist]
|
|
289
|
+
)
|
|
290
|
+
|
|
220
291
|
const withLoading = useCallback(
|
|
221
292
|
<T>(action: () => Promise<T>) =>
|
|
222
293
|
async () => {
|
|
@@ -233,11 +304,17 @@ export function usePushNotifications(
|
|
|
233
304
|
|
|
234
305
|
return {
|
|
235
306
|
permission,
|
|
236
|
-
isEnabled:
|
|
307
|
+
isEnabled: preference && permission?.granted === true,
|
|
237
308
|
isLoading,
|
|
238
309
|
prompt: withLoading(push.prompt),
|
|
239
|
-
enable: withLoading(
|
|
240
|
-
|
|
310
|
+
enable: withLoading(async () => {
|
|
311
|
+
await save(true)
|
|
312
|
+
return push.enable()
|
|
313
|
+
}),
|
|
314
|
+
disable: withLoading(async () => {
|
|
315
|
+
await save(false)
|
|
316
|
+
await push.disable()
|
|
317
|
+
}),
|
|
241
318
|
refresh
|
|
242
319
|
}
|
|
243
320
|
}
|