@marktiderman/genesis-native 0.2.0 → 0.4.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.
Files changed (98) hide show
  1. package/README.md +470 -10
  2. package/dist/block-Onqx7NiK.d.ts +223 -0
  3. package/dist/blocks-C2RDpvaN.d.ts +1595 -0
  4. package/dist/chunk-65P7RU4V.js +49 -0
  5. package/dist/chunk-65P7RU4V.js.map +1 -0
  6. package/dist/chunk-6BT4PTGH.js +37 -0
  7. package/dist/chunk-6BT4PTGH.js.map +1 -0
  8. package/dist/chunk-75YP72G6.js +374 -0
  9. package/dist/chunk-75YP72G6.js.map +1 -0
  10. package/dist/chunk-NGYNYJPL.js +90 -0
  11. package/dist/chunk-NGYNYJPL.js.map +1 -0
  12. package/dist/chunk-ONAXQHNG.js +23 -0
  13. package/dist/chunk-ONAXQHNG.js.map +1 -0
  14. package/dist/chunk-VKTIHNAV.js +95 -0
  15. package/dist/chunk-VKTIHNAV.js.map +1 -0
  16. package/dist/{chunk-MMTPWUWI.js → chunk-VTJDIKFC.js} +9 -6
  17. package/dist/chunk-VTJDIKFC.js.map +1 -0
  18. package/dist/chunk-XE7LC7K6.js +29 -0
  19. package/dist/chunk-XE7LC7K6.js.map +1 -0
  20. package/dist/dev-warnings.d.ts +34 -0
  21. package/dist/dev-warnings.js +15 -0
  22. package/dist/dev-warnings.js.map +1 -0
  23. package/dist/devtools.d.ts +144 -0
  24. package/dist/devtools.js +390 -0
  25. package/dist/devtools.js.map +1 -0
  26. package/dist/index.d.ts +12 -368
  27. package/dist/index.js +88 -191
  28. package/dist/index.js.map +1 -1
  29. package/dist/observability.d.ts +170 -0
  30. package/dist/observability.js +123 -0
  31. package/dist/observability.js.map +1 -0
  32. package/dist/persist.d.ts +1 -1
  33. package/dist/persist.js +1 -1
  34. package/dist/push-payload.d.ts +43 -0
  35. package/dist/push-payload.js +3 -0
  36. package/dist/push-payload.js.map +1 -0
  37. package/dist/push.d.ts +187 -0
  38. package/dist/push.js +1069 -0
  39. package/dist/push.js.map +1 -0
  40. package/dist/{query-DhH3lLFa.d.ts → query-DUmVAfsF.d.ts} +11 -6
  41. package/dist/rich-push-plugin.d.ts +74 -0
  42. package/dist/rich-push-plugin.js +209 -0
  43. package/dist/rich-push-plugin.js.map +1 -0
  44. package/dist/updates.d.ts +8 -1
  45. package/dist/updates.js +44 -69
  46. package/dist/updates.js.map +1 -1
  47. package/ios/notification-service/NotificationService.swift +309 -0
  48. package/package.json +238 -5
  49. package/src/app.tsx +491 -0
  50. package/src/auth.tsx +176 -0
  51. package/src/blocks.tsx +144 -0
  52. package/src/boot.tsx +188 -0
  53. package/src/dev-warnings.ts +43 -0
  54. package/src/devtools/block.tsx +488 -0
  55. package/src/devtools/gestures.ts +107 -0
  56. package/src/devtools/index.ts +22 -0
  57. package/src/devtools/info.ts +45 -0
  58. package/src/devtools/slots.ts +21 -0
  59. package/src/errors.tsx +195 -0
  60. package/src/held-link.ts +90 -0
  61. package/src/hydration-gate.ts +88 -0
  62. package/src/index.ts +83 -0
  63. package/src/links.ts +140 -0
  64. package/src/nativewind.d.ts +41 -0
  65. package/src/observability/block.tsx +300 -0
  66. package/src/observability/events.ts +89 -0
  67. package/src/observability/index.ts +44 -0
  68. package/src/observability/scrub.ts +166 -0
  69. package/src/observability/sentry.ts +222 -0
  70. package/src/observability/sink.ts +147 -0
  71. package/src/persist.ts +83 -0
  72. package/src/plugin/rich-push.ts +312 -0
  73. package/src/push/backend.ts +137 -0
  74. package/src/push/block.tsx +604 -0
  75. package/src/push/index.ts +105 -0
  76. package/src/push/modules.ts +123 -0
  77. package/src/push/payload.ts +61 -0
  78. package/src/push/permission.ts +191 -0
  79. package/src/push/preferences.ts +136 -0
  80. package/src/push/quiet-hours.ts +71 -0
  81. package/src/push/registration.ts +225 -0
  82. package/src/push/routing.ts +57 -0
  83. package/src/push/screens.tsx +46 -0
  84. package/src/push/settings.tsx +378 -0
  85. package/src/query.ts +100 -0
  86. package/src/updates/block.tsx +442 -0
  87. package/src/updates/channels.ts +131 -0
  88. package/src/updates/checker.ts +190 -0
  89. package/src/updates/index.ts +87 -0
  90. package/src/updates/modules.ts +64 -0
  91. package/src/updates/policy.ts +314 -0
  92. package/src/updates/screens.tsx +159 -0
  93. package/src/updates/signature.ts +80 -0
  94. package/src/updates/store.ts +33 -0
  95. package/src/updates/version.ts +39 -0
  96. package/src/updates/whats-new.ts +122 -0
  97. package/dist/block-Dh5ny06W.d.ts +0 -535
  98. package/dist/chunk-MMTPWUWI.js.map +0 -1
@@ -0,0 +1,378 @@
1
+ /**
2
+ * `<NotificationSettings>`: the notification preferences screen, a Genesis
3
+ * `SettingsPage` with four parts:
4
+ *
5
+ * permission whether this phone may show notifications; Turn on / Open Settings
6
+ * categories a switch per category the app defined
7
+ * quietHours a switch, from and to times, and the time zone they're read in
8
+ * privacy show previews (off: the sender sends neutral text instead)
9
+ *
10
+ * Each part is a slot (`slots.quietHours = MyQuietHours` replaces it inside
11
+ * the same section, `null` drops the section) and a hook
12
+ * (`useQuietHours()` …) for a screen of your own. Saving is immediate; a save
13
+ * that fails is put back and said.
14
+ */
15
+ import { useEffect, useMemo, useSyncExternalStore, type ComponentType, type ReactNode } from "react";
16
+ import type { SlotPropsFor, Slots } from "@marktiderman/genesis-core/slots";
17
+ import { NativeSwitch, NativeTimePicker } from "@marktiderman/genesis-ui-native/components";
18
+ import { SettingsPage, type SettingsPageProps, type SettingsPageRow, type SettingsPageSection } from "@marktiderman/genesis-ui-native/layouts";
19
+ import { useSession } from "../auth";
20
+ import { PUSH_STATUS_LABELS, usePush, useNotificationPreferences, usePushPermissionState, usePushRegistration } from "./block";
21
+ import type { PushPermissionStatus } from "./permission";
22
+ import { clockOf, dateAt, deviceTimeZone, isQuietAt } from "./quiet-hours";
23
+
24
+ export interface NotificationSettingsCopy {
25
+ permissionSection: string;
26
+ allow: string;
27
+ status: Record<PushPermissionStatus, string>;
28
+ turnOn: string;
29
+ openSettings: string;
30
+ noProjectId: string;
31
+ noToken: string;
32
+ registerFailed: string;
33
+ categoriesSection: string;
34
+ categoriesFootnote: string;
35
+ quietHoursSection: string;
36
+ quietHours: string;
37
+ quietHoursOnNow: string;
38
+ from: string;
39
+ to: string;
40
+ timeZone: string;
41
+ useDeviceTimeZone: string;
42
+ noTimeZone: string;
43
+ quietHoursFootnote: string;
44
+ privacySection: string;
45
+ showPreviews: string;
46
+ previewsFootnote: string;
47
+ saveFailed: string;
48
+ loadFailed: string;
49
+ }
50
+
51
+ export const notificationSettingsCopy: NotificationSettingsCopy = {
52
+ permissionSection: "Notifications",
53
+ allow: "Allow notifications",
54
+ status: PUSH_STATUS_LABELS,
55
+ turnOn: "Turn on",
56
+ openSettings: "Open Settings",
57
+ noProjectId: "This build can't get notifications: it has no EAS project id.",
58
+ noToken: "This phone didn't give the app a push token. Try again on a device with a network connection.",
59
+ registerFailed: "Couldn't turn notifications on for this phone. They'll be tried again next time you open the app.",
60
+ categoriesSection: "Notify me about",
61
+ categoriesFootnote: "Applies to every phone you're signed in on.",
62
+ quietHoursSection: "Quiet hours",
63
+ quietHours: "Quiet hours",
64
+ quietHoursOnNow: "On now",
65
+ from: "From",
66
+ to: "To",
67
+ timeZone: "Time zone",
68
+ useDeviceTimeZone: "Tap to use this phone's",
69
+ noTimeZone: "This phone didn't say its time zone, so quiet hours can't be placed in your day.",
70
+ quietHoursFootnote: "Notifications that arrive during quiet hours wait until they end. Urgent ones still come through.",
71
+ privacySection: "Privacy",
72
+ showPreviews: "Show previews",
73
+ previewsFootnote: "When off, notifications say only that there's something new; what they say stays on the server, off your lock screen.",
74
+ saveFailed: "Couldn't save that change. Check your connection and try again.",
75
+ loadFailed: "Couldn't load your notification settings, so they can't be changed yet. Check your connection; they load again when you come back here.",
76
+ };
77
+
78
+ /** The permission part's props: what `usePushPermission()` returns. */
79
+ export interface PermissionPartProps {
80
+ status: PushPermissionStatus;
81
+ requesting: boolean;
82
+ /** Shows the system prompt (a tap is the person's intent, so no soft prompt first). */
83
+ request: () => void;
84
+ /** Opens the app's page in Settings, once the system won't ask again. */
85
+ openSettings: (() => void) | null;
86
+ /** Why this phone isn't registered, in the copy's words, or null. */
87
+ problem: string | null;
88
+ copy: NotificationSettingsCopy;
89
+ }
90
+
91
+ export interface CategoryRow {
92
+ id: string;
93
+ title: string;
94
+ description?: string;
95
+ on: boolean;
96
+ }
97
+
98
+ export interface CategoriesPartProps {
99
+ /** The saved settings aren't loaded (yet): nothing can change, so nothing overwrites them. */
100
+ disabled: boolean;
101
+ categories: CategoryRow[];
102
+ setOn: (id: string, on: boolean) => void;
103
+ saving: boolean;
104
+ copy: NotificationSettingsCopy;
105
+ }
106
+
107
+ export interface QuietHoursPartProps {
108
+ /** The saved settings aren't loaded (yet): nothing can change, so nothing overwrites them. */
109
+ disabled: boolean;
110
+ enabled: boolean;
111
+ start: string;
112
+ end: string;
113
+ timeZone: string | null;
114
+ /** This phone's zone, when it differs from the saved one (it travelled). */
115
+ deviceTimeZone: string | null;
116
+ /** In quiet hours right now (shown only; the server decides). */
117
+ activeNow: boolean;
118
+ setEnabled: (on: boolean) => void;
119
+ setStart: (clock: string) => void;
120
+ setEnd: (clock: string) => void;
121
+ adoptDeviceTimeZone: () => void;
122
+ saving: boolean;
123
+ copy: NotificationSettingsCopy;
124
+ }
125
+
126
+ export interface PrivacyPartProps {
127
+ /** The saved settings aren't loaded (yet): nothing can change, so nothing overwrites them. */
128
+ disabled: boolean;
129
+ hidePreviews: boolean;
130
+ setHidePreviews: (hide: boolean) => void;
131
+ saving: boolean;
132
+ copy: NotificationSettingsCopy;
133
+ }
134
+
135
+ // A type, not an interface, so it fits the slots contract's Record constraint.
136
+ export type NotificationSettingsSlots = {
137
+ permission: PermissionPartProps;
138
+ categories: CategoriesPartProps;
139
+ quietHours: QuietHoursPartProps;
140
+ privacy: PrivacyPartProps;
141
+ };
142
+
143
+ const DEFAULT_COPY = notificationSettingsCopy;
144
+
145
+ /** The permission part (rung 4). */
146
+ export function usePushPermission(copy: NotificationSettingsCopy = DEFAULT_COPY): PermissionPartProps {
147
+ const block = usePush();
148
+ const { status, requesting } = usePushPermissionState();
149
+ const { problem } = usePushRegistration();
150
+ const open = block.options.openSettings;
151
+ return {
152
+ status,
153
+ requesting,
154
+ request: () => void block.permission.request(),
155
+ openSettings: open
156
+ ? () => {
157
+ Promise.resolve()
158
+ .then(open)
159
+ .catch((error: unknown) => block.options.onError?.(error instanceof Error ? error : new Error("openSettings failed"), "permission"));
160
+ }
161
+ : null,
162
+ problem: problem === "no_project_id" ? copy.noProjectId : problem === "no_token" ? copy.noToken : problem === "backend" ? copy.registerFailed : null,
163
+ copy,
164
+ };
165
+ }
166
+
167
+ /** The categories part (rung 4). */
168
+ export function useNotificationCategories(copy: NotificationSettingsCopy = DEFAULT_COPY): CategoriesPartProps {
169
+ const block = usePush();
170
+ const { preferences, saving, update, status } = useNotificationPreferences();
171
+ const categories = useMemo(
172
+ () =>
173
+ block.categories.map((c) => ({
174
+ id: c.id,
175
+ title: c.title,
176
+ description: c.description,
177
+ on: typeof preferences.categories[c.id] === "boolean" ? preferences.categories[c.id]! : (c.defaultOn ?? true),
178
+ })),
179
+ [block.categories, preferences.categories],
180
+ );
181
+ return {
182
+ categories,
183
+ setOn: (id, on) => void update((p) => ({ ...p, categories: { ...p.categories, [id]: on } })),
184
+ saving,
185
+ disabled: status !== "ready",
186
+ copy,
187
+ };
188
+ }
189
+
190
+ /** The quiet-hours part (rung 4). */
191
+ export function useQuietHours(copy: NotificationSettingsCopy = DEFAULT_COPY): QuietHoursPartProps {
192
+ const { preferences, saving, update, status } = useNotificationPreferences();
193
+ const q = preferences.quietHours;
194
+ const device = deviceTimeZone();
195
+ // Re-read each minute only while shown; the server is what decides.
196
+ const minute = useMinute();
197
+ const setQuiet = (change: Partial<typeof q>) => void update((p) => ({ ...p, quietHours: { ...p.quietHours, ...change } }));
198
+ return {
199
+ enabled: q.enabled,
200
+ start: q.start,
201
+ end: q.end,
202
+ timeZone: q.timeZone,
203
+ deviceTimeZone: device && device !== q.timeZone ? device : null,
204
+ activeNow: isQuietAt(q, new Date(minute)),
205
+ // Turning quiet hours on fills in this phone's zone if none was saved.
206
+ setEnabled: (on) => setQuiet(on ? { enabled: true, timeZone: q.timeZone ?? device } : { enabled: false }),
207
+ setStart: (start) => setQuiet({ start }),
208
+ setEnd: (end) => setQuiet({ end }),
209
+ adoptDeviceTimeZone: () => (device ? setQuiet({ timeZone: device }) : undefined),
210
+ saving,
211
+ disabled: status !== "ready",
212
+ copy,
213
+ };
214
+ }
215
+
216
+ /** The privacy part (rung 4). */
217
+ export function usePrivacyPreviews(copy: NotificationSettingsCopy = DEFAULT_COPY): PrivacyPartProps {
218
+ const { preferences, saving, update, status } = useNotificationPreferences();
219
+ return {
220
+ hidePreviews: preferences.hidePreviews,
221
+ setHidePreviews: (hidePreviews) => void update((p) => ({ ...p, hidePreviews })),
222
+ saving,
223
+ disabled: status !== "ready",
224
+ copy,
225
+ };
226
+ }
227
+
228
+ const minuteStore = {
229
+ subscribe(listener: () => void) {
230
+ const timer = setInterval(listener, 30_000);
231
+ return () => clearInterval(timer);
232
+ },
233
+ get: () => Math.floor(Date.now() / 60_000) * 60_000,
234
+ };
235
+ function useMinute(): number {
236
+ return useSyncExternalStore(minuteStore.subscribe, minuteStore.get, minuteStore.get);
237
+ }
238
+
239
+ function toggle(id: string, label: string, checked: boolean, onChange: (on: boolean) => void, disabled = false): ReactNode {
240
+ return (
241
+ <NativeSwitch
242
+ testID={`notification-settings-${id}-switch`}
243
+ accessibilityLabel={label}
244
+ checked={checked}
245
+ onCheckedChange={onChange}
246
+ disabled={disabled}
247
+ />
248
+ );
249
+ }
250
+
251
+ /** The permission part's default rows. */
252
+ export function permissionRows(p: PermissionPartProps): SettingsPageRow[] {
253
+ const { status, copy } = p;
254
+ const row: SettingsPageRow = { id: "allow", title: copy.allow, subtitle: copy.status[status], testID: "notification-settings-allow" };
255
+ if (status === "undetermined") {
256
+ return [{ ...row, showChevron: true, onPress: p.requesting ? undefined : p.request, disabled: p.requesting, subtitle: `${copy.status.undetermined} · ${copy.turnOn}` }];
257
+ }
258
+ if (status === "denied" && p.openSettings) {
259
+ return [{ ...row, showChevron: true, onPress: p.openSettings, subtitle: `${copy.status.denied} · ${copy.openSettings}` }];
260
+ }
261
+ return [row];
262
+ }
263
+
264
+ /** The categories part's default rows. */
265
+ export function categoryRows(p: CategoriesPartProps): SettingsPageRow[] {
266
+ return p.categories.map((c) => ({
267
+ id: `category-${c.id}`,
268
+ title: c.title,
269
+ subtitle: c.description,
270
+ testID: `notification-settings-category-${c.id}`,
271
+ trailing: toggle(`category-${c.id}`, c.title, c.on, (on) => p.setOn(c.id, on), p.disabled),
272
+ }));
273
+ }
274
+
275
+ /** The quiet-hours part's default rows. */
276
+ export function quietHoursRows(p: QuietHoursPartProps): SettingsPageRow[] {
277
+ const { copy } = p;
278
+ const rows: SettingsPageRow[] = [
279
+ {
280
+ id: "quiet-hours",
281
+ title: copy.quietHours,
282
+ subtitle: p.enabled && p.activeNow ? copy.quietHoursOnNow : undefined,
283
+ testID: "notification-settings-quiet-hours",
284
+ trailing: toggle("quiet-hours", copy.quietHours, p.enabled, p.setEnabled, p.disabled || (!p.enabled && !p.timeZone && !p.deviceTimeZone)),
285
+ },
286
+ ];
287
+ if (!p.enabled) return rows;
288
+ const picker = (id: string, label: string, value: string, set: (clock: string) => void) => (
289
+ <NativeTimePicker testID={`notification-settings-${id}-time`} value={dateAt(value)} onChange={(d) => set(clockOf(d))} formatLabel={() => value} disabled={p.disabled} />
290
+ );
291
+ rows.push(
292
+ { id: "quiet-from", title: copy.from, testID: "notification-settings-quiet-from", trailing: picker("quiet-from", copy.from, p.start, p.setStart) },
293
+ { id: "quiet-to", title: copy.to, testID: "notification-settings-quiet-to", trailing: picker("quiet-to", copy.to, p.end, p.setEnd) },
294
+ {
295
+ id: "time-zone",
296
+ title: copy.timeZone,
297
+ subtitle: p.timeZone ? (p.deviceTimeZone ? `${p.timeZone} · ${copy.useDeviceTimeZone} (${p.deviceTimeZone})` : p.timeZone) : copy.noTimeZone,
298
+ testID: "notification-settings-time-zone",
299
+ ...(p.deviceTimeZone && !p.disabled ? { onPress: p.adoptDeviceTimeZone, showChevron: true } : {}),
300
+ },
301
+ );
302
+ return rows;
303
+ }
304
+
305
+ /** The privacy part's default rows. */
306
+ export function privacyRows(p: PrivacyPartProps): SettingsPageRow[] {
307
+ return [
308
+ {
309
+ id: "previews",
310
+ title: p.copy.showPreviews,
311
+ testID: "notification-settings-previews",
312
+ trailing: toggle("previews", p.copy.showPreviews, !p.hidePreviews, (show) => p.setHidePreviews(!show), p.disabled),
313
+ },
314
+ ];
315
+ }
316
+
317
+ export interface NotificationSettingsProps {
318
+ /** Your words: any of `notificationSettingsCopy`'s, replaced. */
319
+ copy?: Partial<NotificationSettingsCopy>;
320
+ /** Replace a part (rendered in its section) or drop its section (`null`). */
321
+ slots?: Slots<NotificationSettingsSlots>;
322
+ /** More props for a part, merged over what the screen passes. */
323
+ slotProps?: SlotPropsFor<NotificationSettingsSlots>;
324
+ /** Passed to SettingsPage (its own `slots` and `slotProps` reach rows and headers). */
325
+ page?: Omit<SettingsPageProps, "sections" | "children">;
326
+ /** Above the first section. */
327
+ children?: ReactNode;
328
+ testID?: string;
329
+ }
330
+
331
+ function section<P extends object>(
332
+ id: string,
333
+ title: string,
334
+ footnote: string | undefined,
335
+ props: P,
336
+ rows: (props: P) => SettingsPageRow[],
337
+ slot: ComponentType<P> | null | undefined,
338
+ extra: Partial<P> | undefined,
339
+ ): SettingsPageSection | null {
340
+ if (slot === null) return null;
341
+ const merged = { ...props, ...extra } as P;
342
+ if (slot) {
343
+ const Part = slot;
344
+ return { id, title, footnote, rows: [], children: <Part {...merged} /> };
345
+ }
346
+ return { id, title, footnote, rows: rows(merged) };
347
+ }
348
+
349
+ /** The notification preferences screen. Needs the push block (in GenesisNativeApp's `blocks`, or a `PushProvider`). */
350
+ export function NotificationSettings({ copy: overrides, slots = {}, slotProps = {}, page, children, testID = "notification-settings" }: NotificationSettingsProps) {
351
+ const copy = useMemo(() => ({ ...DEFAULT_COPY, ...overrides, status: { ...DEFAULT_COPY.status, ...overrides?.status } }), [overrides]);
352
+ const block = usePush();
353
+ const permission = usePushPermission(copy);
354
+ const categories = useNotificationCategories(copy);
355
+ const quietHours = useQuietHours(copy);
356
+ const privacy = usePrivacyPreviews(copy);
357
+ const { error, status } = useNotificationPreferences();
358
+ const { userId } = useSession();
359
+ // A load that failed is tried again whenever the screen opens (the block also loads on sign-in).
360
+ useEffect(() => {
361
+ if (block.preferences.getState().status === "error" && userId) void block.preferences.load(userId);
362
+ }, [block, userId]);
363
+
364
+ const sections = [
365
+ section("permission", copy.permissionSection, permission.problem ?? (status === "error" ? copy.loadFailed : error ? copy.saveFailed : undefined), permission, permissionRows, slots.permission, slotProps.permission),
366
+ block.categories.length > 0
367
+ ? section("categories", copy.categoriesSection, copy.categoriesFootnote, categories, categoryRows, slots.categories, slotProps.categories)
368
+ : null,
369
+ section("quiet-hours", copy.quietHoursSection, copy.quietHoursFootnote, quietHours, quietHoursRows, slots.quietHours, slotProps.quietHours),
370
+ section("privacy", copy.privacySection, copy.previewsFootnote, privacy, privacyRows, slots.privacy, slotProps.privacy),
371
+ ].filter((s): s is SettingsPageSection => s !== null);
372
+
373
+ return (
374
+ <SettingsPage testID={testID} {...page} sections={sections}>
375
+ {children}
376
+ </SettingsPage>
377
+ );
378
+ }
package/src/query.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Query: a TanStack Query client with offline-safe defaults, and the contract
3
+ * for saving it to the phone. The persister itself is in
4
+ * `@marktiderman/genesis-native/persist`, so an app that saves nothing doesn't
5
+ * install TanStack's persister packages.
6
+ */
7
+ import { QueryClient, focusManager, onlineManager, type QueryClientConfig } from "@tanstack/react-query";
8
+
9
+ /** The part of AsyncStorage (or any async key-value store) Genesis uses. */
10
+ export interface AsyncStorageLike {
11
+ getItem(key: string): Promise<string | null>;
12
+ setItem(key: string, value: string): Promise<void>;
13
+ removeItem(key: string): Promise<void>;
14
+ }
15
+
16
+ /** One day in milliseconds: the default max age of the saved query cache. */
17
+ export const DAY_MS = 24 * 60 * 60 * 1000;
18
+
19
+ /**
20
+ * A QueryClient for a phone. `networkMode: "offlineFirst"` runs a query or
21
+ * mutation once whatever the network says, then pauses its retries while the
22
+ * device is offline (`onlineManager`, which `connectQueryLifecycle` feeds from
23
+ * NetInfo), continues them when it is back, and refetches stale queries on
24
+ * reconnect. So offline, a query with saved data keeps showing it
25
+ * (`fetchStatus: "paused"`); one with nothing saved stays `pending` and paused
26
+ * until the network returns (check `isPaused`, not only `isPending`, before
27
+ * showing a spinner); and a mutation, which TanStack doesn't retry by default,
28
+ * fails at once instead of waiting. `gcTime` matches the persisted max age, or
29
+ * restored queries would be collected before they are used. Your `config` wins.
30
+ */
31
+ export function createNativeQueryClient(config: QueryClientConfig = {}): QueryClient {
32
+ const { queries, mutations, ...rest } = config.defaultOptions ?? {};
33
+ return new QueryClient({
34
+ ...config,
35
+ defaultOptions: {
36
+ ...rest,
37
+ queries: { networkMode: "offlineFirst", gcTime: DAY_MS, staleTime: 60_000, ...queries },
38
+ mutations: { networkMode: "offlineFirst", ...mutations },
39
+ },
40
+ });
41
+ }
42
+
43
+ /**
44
+ * The saved query cache. A `scope` (the signed-in user's id, `""` for nobody)
45
+ * is part of what is saved: a cache saved for one user is discarded, on disk
46
+ * too, when another scope restores it.
47
+ */
48
+ export interface QueryPersistence {
49
+ /** Load the saved cache into the client, if it was saved for `scope`. */
50
+ restore(scope: string): Promise<void>;
51
+ /** Save on every cache change, for `scope`; returns the unsubscribe. */
52
+ subscribe(scope: string): () => void;
53
+ /**
54
+ * Delete the saved cache. Clear the QueryClient first: the subscription
55
+ * then saves the empty cache, so a save already in flight can't leave the
56
+ * old queries on disk.
57
+ */
58
+ clear(): Promise<void>;
59
+ }
60
+
61
+ /** React Native's `AppState`, or a stand-in. */
62
+ export interface AppStateLike {
63
+ addEventListener(type: "change", listener: (state: string) => void): { remove(): void };
64
+ }
65
+
66
+ /** `@react-native-community/netinfo`'s default export, or a stand-in. */
67
+ export interface NetInfoLike {
68
+ addEventListener(listener: (state: { isConnected: boolean | null }) => void): () => void;
69
+ }
70
+
71
+ export interface QueryLifecycleOptions {
72
+ appState?: AppStateLike;
73
+ netInfo?: NetInfoLike;
74
+ }
75
+
76
+ /**
77
+ * Tell TanStack Query when the app comes to the front (refetch stale
78
+ * queries) and when the network comes and goes. Returns the cleanup.
79
+ */
80
+ export function connectQueryLifecycle({ appState, netInfo }: QueryLifecycleOptions): () => void {
81
+ if (appState) {
82
+ focusManager.setEventListener((setFocused) => {
83
+ const subscription = appState.addEventListener("change", (state) => setFocused(state === "active"));
84
+ return () => subscription.remove();
85
+ });
86
+ }
87
+ if (netInfo) {
88
+ onlineManager.setEventListener((setOnline) => netInfo.addEventListener((state) => setOnline(state.isConnected !== false)));
89
+ }
90
+ return () => {
91
+ // Replacing the listener runs the cleanup of the one set above.
92
+ if (appState) focusManager.setEventListener(() => () => {});
93
+ if (netInfo) {
94
+ onlineManager.setEventListener(() => () => {});
95
+ // Nothing reports the network any more: don't leave paused queries waiting on the last
96
+ // "offline" NetInfo said (offlineFirst pauses retries while offline).
97
+ onlineManager.setOnline(true);
98
+ }
99
+ };
100
+ }