@marktiderman/genesis-native 0.1.0 → 0.3.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 +354 -10
- package/dist/block-C5-noMhB.d.ts +223 -0
- package/dist/blocks-a1YQ1Elf.d.ts +1540 -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-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.d.ts +169 -0
- package/dist/push.js +996 -0
- package/dist/push.js.map +1 -0
- package/dist/{query-DhH3lLFa.d.ts → query-DUmVAfsF.d.ts} +11 -6
- package/dist/updates.d.ts +8 -1
- package/dist/updates.js +44 -69
- package/dist/updates.js.map +1 -1
- package/package.json +222 -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/push/backend.ts +129 -0
- package/src/push/block.tsx +545 -0
- package/src/push/index.ts +102 -0
- package/src/push/modules.ts +111 -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 +189 -0
- package/src/push/routing.ts +27 -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,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This device's Expo push token, kept against whoever is signed in.
|
|
3
|
+
*
|
|
4
|
+
* sync(userId) permission granted → (Android) channels → token → backend.register,
|
|
5
|
+
* again when the token changes and at most hourly otherwise (last seen)
|
|
6
|
+
* forget() backend.forget(token) on sign-out, an account switch or deletion;
|
|
7
|
+
* a forget that fails offline is kept and retried at the next start
|
|
8
|
+
*
|
|
9
|
+
* The token is remembered on the phone (storage) with the user it was
|
|
10
|
+
* registered for, so it can be forgotten after the session has ended. Every
|
|
11
|
+
* step runs in order on one queue, so a sign-out never races a registration.
|
|
12
|
+
*/
|
|
13
|
+
import { createStore, type Store } from "../updates/store";
|
|
14
|
+
import type { PushBackend } from "./backend";
|
|
15
|
+
import { report, type NotificationsModuleLike, type PushOnError, type PushStorageLike } from "./modules";
|
|
16
|
+
|
|
17
|
+
export type RegistrationStatus = "idle" | "registering" | "registered" | "unavailable" | "error";
|
|
18
|
+
|
|
19
|
+
export interface RegistrationState {
|
|
20
|
+
status: RegistrationStatus;
|
|
21
|
+
/**
|
|
22
|
+
* Why it isn't registered: `no_project_id` (the build has no EAS project id, so there is no
|
|
23
|
+
* Expo push token), `no_token` (the OS gave none: a simulator without push, or no network),
|
|
24
|
+
* `backend` (the server refused or couldn't be reached).
|
|
25
|
+
*/
|
|
26
|
+
problem: "no_project_id" | "no_token" | "backend" | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Registration extends Store<RegistrationState> {
|
|
30
|
+
sync(userId: string, options?: { force?: boolean }): Promise<void>;
|
|
31
|
+
forget(): Promise<void>;
|
|
32
|
+
/** Retry forgets that failed earlier (offline sign-out). */
|
|
33
|
+
flush(): Promise<void>;
|
|
34
|
+
/** This device's token, once known (the person's own; never shown in logs). */
|
|
35
|
+
token(): string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Saved {
|
|
39
|
+
token: string;
|
|
40
|
+
userId: string;
|
|
41
|
+
at: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Registered again at most this often while nothing changed (last seen, and a token another login took comes back). */
|
|
45
|
+
const REFRESH_MS = 60 * 60_000;
|
|
46
|
+
|
|
47
|
+
export function createRegistration(options: {
|
|
48
|
+
notifications?: NotificationsModuleLike;
|
|
49
|
+
backend: PushBackend;
|
|
50
|
+
projectId?: string | null;
|
|
51
|
+
platform: string;
|
|
52
|
+
appVersion?: string | null;
|
|
53
|
+
storage?: PushStorageLike;
|
|
54
|
+
storageKeyPrefix: string;
|
|
55
|
+
/** Creates the Android channels; runs before the token is asked for. */
|
|
56
|
+
ensureChannels: () => Promise<void>;
|
|
57
|
+
/** Is this user still the one signed in? A sync for someone who has signed out is dropped. */
|
|
58
|
+
isCurrent: (userId: string) => boolean;
|
|
59
|
+
now: () => number;
|
|
60
|
+
onError?: PushOnError;
|
|
61
|
+
}): Registration {
|
|
62
|
+
const { notifications, backend, storage, storageKeyPrefix, onError, now } = options;
|
|
63
|
+
const savedKey = `${storageKeyPrefix}:device`;
|
|
64
|
+
const pendingKey = `${storageKeyPrefix}:forget`;
|
|
65
|
+
const store = createStore<RegistrationState>({ status: "idle", problem: null });
|
|
66
|
+
let current: string | null = null;
|
|
67
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
68
|
+
let warnedNoProject = false;
|
|
69
|
+
|
|
70
|
+
const run = <T>(step: () => Promise<T>): Promise<T> => {
|
|
71
|
+
const next = queue.then(step, step);
|
|
72
|
+
queue = next.catch(() => {});
|
|
73
|
+
return next;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const read = async <T>(key: string, fallback: T): Promise<T> => {
|
|
77
|
+
if (!storage) return fallback;
|
|
78
|
+
try {
|
|
79
|
+
const raw = await storage.getItem(key);
|
|
80
|
+
return raw ? (JSON.parse(raw) as T) : fallback;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
report(onError, error, "storage");
|
|
83
|
+
return fallback;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
const write = async (key: string, value: unknown) => {
|
|
87
|
+
try {
|
|
88
|
+
if (value === null) await storage?.removeItem?.(key);
|
|
89
|
+
else await storage?.setItem(key, JSON.stringify(value));
|
|
90
|
+
} catch (error) {
|
|
91
|
+
report(onError, error, "storage");
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const forgetToken = async (token: string): Promise<boolean> => {
|
|
96
|
+
try {
|
|
97
|
+
await backend.forget(token);
|
|
98
|
+
return true;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
report(onError, error, "forget");
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const queuePending = async (token: string) => {
|
|
106
|
+
const pending = await read<string[]>(pendingKey, []);
|
|
107
|
+
if (!pending.includes(token)) await write(pendingKey, [...pending, token].slice(-10));
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
...store,
|
|
112
|
+
token: () => current,
|
|
113
|
+
sync(userId, { force = false } = {}) {
|
|
114
|
+
return run(async () => {
|
|
115
|
+
if (!notifications || !options.isCurrent(userId)) return;
|
|
116
|
+
if (!options.projectId) {
|
|
117
|
+
store.setState({ status: "unavailable", problem: "no_project_id" });
|
|
118
|
+
if (!warnedNoProject) {
|
|
119
|
+
warnedNoProject = true;
|
|
120
|
+
// Loud once: without an EAS project id there is no Expo push token, so no push reaches this build.
|
|
121
|
+
report(onError, new Error("push: no EAS project id (projectId), so this build gets no push token. Run `eas init` and pass Constants.expoConfig.extra.eas.projectId."), "setup");
|
|
122
|
+
}
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
store.setState({ status: "registering", problem: null });
|
|
126
|
+
await options.ensureChannels().catch((error) => report(onError, error, "channels"));
|
|
127
|
+
let token: string;
|
|
128
|
+
try {
|
|
129
|
+
token = (await notifications.getExpoPushTokenAsync({ projectId: options.projectId })).data;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
report(onError, error, "register");
|
|
132
|
+
store.setState({ status: "error", problem: "no_token" });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
136
|
+
store.setState({ status: "error", problem: "no_token" });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
current = token;
|
|
140
|
+
const saved = await read<Saved | null>(savedKey, null);
|
|
141
|
+
// A different token kept for someone else on this phone: forget it. (The same token the server moves itself.)
|
|
142
|
+
if (saved && saved.userId !== userId && saved.token !== token && !(await forgetToken(saved.token))) await queuePending(saved.token);
|
|
143
|
+
if (!force && saved && saved.token === token && saved.userId === userId && now() - saved.at < REFRESH_MS) {
|
|
144
|
+
store.setState({ status: "registered", problem: null });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (!options.isCurrent(userId)) return;
|
|
148
|
+
try {
|
|
149
|
+
await backend.register(token, options.platform === "ios" ? "ios" : "android", options.appVersion ?? null);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
report(onError, error, "register");
|
|
152
|
+
store.setState({ status: "error", problem: "backend" });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// An older token this phone had (the OS rotated it) goes; the new one stays.
|
|
156
|
+
if (saved && saved.token !== token && saved.userId === userId && !(await forgetToken(saved.token))) await queuePending(saved.token);
|
|
157
|
+
await write(savedKey, { token, userId, at: now() } satisfies Saved);
|
|
158
|
+
// A forget of this same token still waiting from an offline sign-out would undo this registration.
|
|
159
|
+
const pending = await read<string[]>(pendingKey, []);
|
|
160
|
+
if (pending.includes(token)) await write(pendingKey, pending.filter((t) => t !== token).length ? pending.filter((t) => t !== token) : null);
|
|
161
|
+
store.setState({ status: "registered", problem: null });
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
forget() {
|
|
165
|
+
return run(async () => {
|
|
166
|
+
const saved = await read<Saved | null>(savedKey, null);
|
|
167
|
+
const token = saved?.token ?? current;
|
|
168
|
+
await write(savedKey, null);
|
|
169
|
+
store.setState({ status: "idle", problem: null });
|
|
170
|
+
if (!token) return;
|
|
171
|
+
if (!(await forgetToken(token))) await queuePending(token);
|
|
172
|
+
});
|
|
173
|
+
},
|
|
174
|
+
flush() {
|
|
175
|
+
return run(async () => {
|
|
176
|
+
const pending = await read<string[]>(pendingKey, []);
|
|
177
|
+
if (pending.length === 0) return;
|
|
178
|
+
const left: string[] = [];
|
|
179
|
+
const registered = (await read<Saved | null>(savedKey, null))?.token;
|
|
180
|
+
for (const token of pending) {
|
|
181
|
+
// Registered again since (signed back in): keep it.
|
|
182
|
+
if (token === registered) continue;
|
|
183
|
+
if (!(await forgetToken(token))) left.push(token);
|
|
184
|
+
}
|
|
185
|
+
await write(pendingKey, left.length ? left : null);
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a tapped notification goes. The sender puts an app path in
|
|
3
|
+
* `data.url` (`/messages/42`); it goes through the same `normalizeLink` as
|
|
4
|
+
* every deep link, so a path the app doesn't know opens `/` and an ignored
|
|
5
|
+
* one goes nowhere. Without link options, only a plain app path is followed.
|
|
6
|
+
*/
|
|
7
|
+
import { normalizeLink, type LinkOptions } from "../links";
|
|
8
|
+
import type { NotificationLike, NotificationResponseLike } from "./modules";
|
|
9
|
+
|
|
10
|
+
const ANY_PATH: LinkOptions = { map: { "/*": "/*" } };
|
|
11
|
+
|
|
12
|
+
/** The data a notification carries (`content.data`), or `{}`. */
|
|
13
|
+
export function notificationData(notification: NotificationLike): Record<string, unknown> {
|
|
14
|
+
const data = notification.request?.content?.data;
|
|
15
|
+
return data && typeof data === "object" && !Array.isArray(data) ? data : {};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The app path a tap should open, or null. */
|
|
19
|
+
export function pathForResponse(response: NotificationResponseLike, links?: LinkOptions): string | null {
|
|
20
|
+
const url = notificationData(response.notification).url;
|
|
21
|
+
if (typeof url !== "string" || url.length === 0 || url.length > 2048) return null;
|
|
22
|
+
if (links) return normalizeLink(url, links);
|
|
23
|
+
// No link map: an app path only (never another app's URL), with "." and ".." refused as normalizeLink does.
|
|
24
|
+
if (!url.startsWith("/") || url.startsWith("//") || url.includes("\\")) return null;
|
|
25
|
+
const path = normalizeLink(url, ANY_PATH);
|
|
26
|
+
return path === "/" && url.split(/[?#]/)[0] !== "/" ? null : path;
|
|
27
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The soft pre-prompt: the default of the `pushPrompt` slot, built from
|
|
3
|
+
* `@marktiderman/genesis-ui-native`. It says what push is for before the
|
|
4
|
+
* system asks, so a person who isn't ready says "Not now" here, where the
|
|
5
|
+
* app can ask again later, rather than "Don't Allow" there, where it can't.
|
|
6
|
+
*/
|
|
7
|
+
import { View } from "react-native";
|
|
8
|
+
import { NativeButton, NativeSheet, NativeText } from "@marktiderman/genesis-ui-native/components";
|
|
9
|
+
|
|
10
|
+
export interface PushPromptProps {
|
|
11
|
+
/** What `offer(reason)` said push is for here ("to hear when someone replies"), or null. */
|
|
12
|
+
reason: string | null;
|
|
13
|
+
/** "Turn on": shows the system prompt. */
|
|
14
|
+
accept: () => void;
|
|
15
|
+
/** "Not now": asks again after the cooldown, never more than `maxDeclines` times. */
|
|
16
|
+
decline: () => void;
|
|
17
|
+
title?: string;
|
|
18
|
+
body?: string;
|
|
19
|
+
acceptLabel?: string;
|
|
20
|
+
declineLabel?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// The sheet is iOS's full-height page sheet: grow to fill it and centre the few lines in it, rather
|
|
24
|
+
// than leave them at the top of an empty sheet. On Android the panel sizes to its content.
|
|
25
|
+
const PROMPT_CONTENT = { flexGrow: 1, justifyContent: "center", gap: 16, padding: 24 } as const;
|
|
26
|
+
|
|
27
|
+
export function PushPromptSheet({ reason, accept, decline, title, body, acceptLabel, declineLabel }: PushPromptProps) {
|
|
28
|
+
return (
|
|
29
|
+
<NativeSheet open onOpenChange={(open) => (open ? undefined : decline())} testID="genesis-push-prompt">
|
|
30
|
+
<View style={PROMPT_CONTENT} testID="genesis-push-prompt-content">
|
|
31
|
+
<NativeText preset="h3" accessibilityRole="header">
|
|
32
|
+
{title ?? "Turn on notifications?"}
|
|
33
|
+
</NativeText>
|
|
34
|
+
<NativeText preset="body" className="text-muted-foreground" testID="genesis-push-prompt-body">
|
|
35
|
+
{body ?? (reason ? `Get a notification ${reason}. You can change this any time in Settings.` : "Get a notification when something needs you. You can change this any time in Settings.")}
|
|
36
|
+
</NativeText>
|
|
37
|
+
<NativeButton onPress={accept} testID="genesis-push-prompt-accept">
|
|
38
|
+
{acceptLabel ?? "Turn on"}
|
|
39
|
+
</NativeButton>
|
|
40
|
+
<NativeButton variant="ghost" onPress={decline} testID="genesis-push-prompt-decline">
|
|
41
|
+
{declineLabel ?? "Not now"}
|
|
42
|
+
</NativeButton>
|
|
43
|
+
</View>
|
|
44
|
+
</NativeSheet>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
@@ -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
|
+
}
|