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 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)`. Save the user's preference yourself, before `enable()`: a denied dialog then leaves the preference on with `isEnabled` false, and the token registers on the next mount once permission is granted in device settings. |
496
- | `syncToken()` | Re-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. |
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).
@@ -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';
@@ -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 hasPrompted() {
78
+ async function read(key) {
78
79
  try {
79
- return await storage.getItem(storageKey) === "true";
80
+ return await storage.getItem(key);
80
81
  } catch {
81
- return false;
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 syncToken() {
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
- return registerDevice(null);
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: enabled && permission?.granted === true,
239
+ isEnabled: preference && permission?.granted === true,
201
240
  isLoading,
202
241
  prompt: withLoading(push.prompt),
203
- enable: withLoading(push.enable),
204
- disable: withLoading(push.disable),
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
  }
@@ -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
- /** Subscribes to notifications. Returns the unsubscribe function. */
54
- listen({ onReceived, onResponse }: NotificationListeners): () => void;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-release",
3
- "version": "2.2.1",
3
+ "version": "2.3.1",
4
4
  "description": "Fingerprint-driven Expo EAS release: OTA update or native store build, plus app runtime helpers",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
@@ -15,5 +15,6 @@ export {
15
15
  type NotificationListeners,
16
16
  type PushNotifications,
17
17
  type PushNotificationsOptions,
18
+ type UsePushNotificationsOptions,
18
19
  usePushNotifications
19
20
  } from './push'
@@ -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 hasPrompted() {
84
+ async function read(key: string) {
82
85
  try {
83
- return (await storage.getItem(storageKey)) === 'true'
86
+ return await storage.getItem(key)
84
87
  } catch {
85
- return false
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 ? { projectId } : undefined
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
- /** Registers the current token. Resolves whether one was registered. */
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
- return registerDevice(null)
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
- /** Subscribes to notifications. Returns the unsubscribe function. */
182
- listen({ onReceived, onResponse }: NotificationListeners) {
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 }: { enabled?: boolean } = {}
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: enabled && permission?.granted === true,
307
+ isEnabled: preference && permission?.granted === true,
237
308
  isLoading,
238
309
  prompt: withLoading(push.prompt),
239
- enable: withLoading(push.enable),
240
- disable: withLoading(push.disable),
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
  }