@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.
- package/README.md +470 -10
- package/dist/block-Onqx7NiK.d.ts +223 -0
- package/dist/blocks-C2RDpvaN.d.ts +1595 -0
- package/dist/chunk-65P7RU4V.js +49 -0
- package/dist/chunk-65P7RU4V.js.map +1 -0
- package/dist/chunk-6BT4PTGH.js +37 -0
- package/dist/chunk-6BT4PTGH.js.map +1 -0
- package/dist/chunk-75YP72G6.js +374 -0
- package/dist/chunk-75YP72G6.js.map +1 -0
- package/dist/chunk-NGYNYJPL.js +90 -0
- package/dist/chunk-NGYNYJPL.js.map +1 -0
- package/dist/chunk-ONAXQHNG.js +23 -0
- package/dist/chunk-ONAXQHNG.js.map +1 -0
- package/dist/chunk-VKTIHNAV.js +95 -0
- package/dist/chunk-VKTIHNAV.js.map +1 -0
- package/dist/{chunk-MMTPWUWI.js → chunk-VTJDIKFC.js} +9 -6
- package/dist/chunk-VTJDIKFC.js.map +1 -0
- package/dist/chunk-XE7LC7K6.js +29 -0
- package/dist/chunk-XE7LC7K6.js.map +1 -0
- package/dist/dev-warnings.d.ts +34 -0
- package/dist/dev-warnings.js +15 -0
- package/dist/dev-warnings.js.map +1 -0
- package/dist/devtools.d.ts +144 -0
- package/dist/devtools.js +390 -0
- package/dist/devtools.js.map +1 -0
- package/dist/index.d.ts +12 -368
- package/dist/index.js +88 -191
- package/dist/index.js.map +1 -1
- package/dist/observability.d.ts +170 -0
- package/dist/observability.js +123 -0
- package/dist/observability.js.map +1 -0
- package/dist/persist.d.ts +1 -1
- package/dist/persist.js +1 -1
- package/dist/push-payload.d.ts +43 -0
- package/dist/push-payload.js +3 -0
- package/dist/push-payload.js.map +1 -0
- package/dist/push.d.ts +187 -0
- package/dist/push.js +1069 -0
- package/dist/push.js.map +1 -0
- package/dist/{query-DhH3lLFa.d.ts → query-DUmVAfsF.d.ts} +11 -6
- package/dist/rich-push-plugin.d.ts +74 -0
- package/dist/rich-push-plugin.js +209 -0
- package/dist/rich-push-plugin.js.map +1 -0
- package/dist/updates.d.ts +8 -1
- package/dist/updates.js +44 -69
- package/dist/updates.js.map +1 -1
- package/ios/notification-service/NotificationService.swift +309 -0
- package/package.json +238 -5
- package/src/app.tsx +491 -0
- package/src/auth.tsx +176 -0
- package/src/blocks.tsx +144 -0
- package/src/boot.tsx +188 -0
- package/src/dev-warnings.ts +43 -0
- package/src/devtools/block.tsx +488 -0
- package/src/devtools/gestures.ts +107 -0
- package/src/devtools/index.ts +22 -0
- package/src/devtools/info.ts +45 -0
- package/src/devtools/slots.ts +21 -0
- package/src/errors.tsx +195 -0
- package/src/held-link.ts +90 -0
- package/src/hydration-gate.ts +88 -0
- package/src/index.ts +83 -0
- package/src/links.ts +140 -0
- package/src/nativewind.d.ts +41 -0
- package/src/observability/block.tsx +300 -0
- package/src/observability/events.ts +89 -0
- package/src/observability/index.ts +44 -0
- package/src/observability/scrub.ts +166 -0
- package/src/observability/sentry.ts +222 -0
- package/src/observability/sink.ts +147 -0
- package/src/persist.ts +83 -0
- package/src/plugin/rich-push.ts +312 -0
- package/src/push/backend.ts +137 -0
- package/src/push/block.tsx +604 -0
- package/src/push/index.ts +105 -0
- package/src/push/modules.ts +123 -0
- package/src/push/payload.ts +61 -0
- package/src/push/permission.ts +191 -0
- package/src/push/preferences.ts +136 -0
- package/src/push/quiet-hours.ts +71 -0
- package/src/push/registration.ts +225 -0
- package/src/push/routing.ts +57 -0
- package/src/push/screens.tsx +46 -0
- package/src/push/settings.tsx +378 -0
- package/src/query.ts +100 -0
- package/src/updates/block.tsx +442 -0
- package/src/updates/channels.ts +131 -0
- package/src/updates/checker.ts +190 -0
- package/src/updates/index.ts +87 -0
- package/src/updates/modules.ts +64 -0
- package/src/updates/policy.ts +314 -0
- package/src/updates/screens.tsx +159 -0
- package/src/updates/signature.ts +80 -0
- package/src/updates/store.ts +33 -0
- package/src/updates/version.ts +39 -0
- package/src/updates/whats-new.ts +122 -0
- package/dist/block-Dh5ny06W.d.ts +0 -535
- 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
|
+
}
|