@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,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the push block reads from `expo-notifications` (SDK 58) and the app's
|
|
3
|
+
* backend, as the shapes it needs. Pass the real module
|
|
4
|
+
* (`import * as Notifications from "expo-notifications"`); tests pass fakes.
|
|
5
|
+
* Every call here was checked against expo-notifications 58.1's own type
|
|
6
|
+
* declarations.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** `{ remove() }`, as every expo-notifications listener returns. */
|
|
10
|
+
export interface SubscriptionLike {
|
|
11
|
+
remove(): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** `NotificationPermissionsStatus`: Expo's `PermissionResponse` plus the iOS detail. */
|
|
15
|
+
export interface PermissionStatusLike {
|
|
16
|
+
status: "granted" | "denied" | "undetermined" | string;
|
|
17
|
+
granted: boolean;
|
|
18
|
+
canAskAgain: boolean;
|
|
19
|
+
/** `IosAuthorizationStatus`: 0 not determined, 1 denied, 2 authorized, 3 provisional, 4 ephemeral. */
|
|
20
|
+
ios?: { status: number } | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `NotificationPermissionsRequest`: iOS options; Android asks for posting notifications. */
|
|
24
|
+
export interface PermissionRequestLike {
|
|
25
|
+
ios?: { allowAlert?: boolean; allowBadge?: boolean; allowSound?: boolean; allowProvisional?: boolean };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The parts of a received notification the block reads. */
|
|
29
|
+
export interface NotificationLike {
|
|
30
|
+
request: {
|
|
31
|
+
identifier: string;
|
|
32
|
+
content: { data?: Record<string, unknown> | null; title?: string | null };
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A tap (or an action) on a notification. */
|
|
37
|
+
export interface NotificationResponseLike {
|
|
38
|
+
notification: NotificationLike;
|
|
39
|
+
actionIdentifier: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `NotificationBehavior` without the deprecated `shouldShowAlert`. */
|
|
43
|
+
export interface NotificationBehaviorLike {
|
|
44
|
+
shouldShowBanner: boolean;
|
|
45
|
+
shouldShowList: boolean;
|
|
46
|
+
shouldPlaySound: boolean;
|
|
47
|
+
shouldSetBadge: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** `NotificationChannelInput`, the fields the block sets. Importance and visibility are Expo's enum numbers. */
|
|
51
|
+
export interface ChannelInputLike {
|
|
52
|
+
name: string;
|
|
53
|
+
importance: number;
|
|
54
|
+
description?: string | null;
|
|
55
|
+
/** `AndroidNotificationVisibility`: 1 public, 2 private (content hidden on a secure lock screen), 3 secret. */
|
|
56
|
+
lockscreenVisibility?: number;
|
|
57
|
+
showBadge?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `expo-notifications` (SDK 58). */
|
|
61
|
+
export interface NotificationsModuleLike {
|
|
62
|
+
getPermissionsAsync(): Promise<PermissionStatusLike>;
|
|
63
|
+
requestPermissionsAsync(request?: PermissionRequestLike): Promise<PermissionStatusLike>;
|
|
64
|
+
/** Pass the EAS project id: without one there is no Expo push token. */
|
|
65
|
+
getExpoPushTokenAsync(options: { projectId: string }): Promise<{ data: string }>;
|
|
66
|
+
/** Fires when the OS hands the app a new device token; the Expo token is fetched again then. */
|
|
67
|
+
addPushTokenListener(listener: (token: unknown) => void): SubscriptionLike;
|
|
68
|
+
/** Android only (a no-op elsewhere). Must run before the permission prompt and the token on Android 13+. */
|
|
69
|
+
setNotificationChannelAsync(channelId: string, channel: ChannelInputLike): Promise<unknown>;
|
|
70
|
+
setNotificationHandler(handler: { handleNotification: (notification: NotificationLike) => Promise<NotificationBehaviorLike> } | null): void;
|
|
71
|
+
addNotificationResponseReceivedListener(listener: (response: NotificationResponseLike) => void): SubscriptionLike;
|
|
72
|
+
/** The tap that launched the app (synchronous since SDK 53). */
|
|
73
|
+
getLastNotificationResponse(): NotificationResponseLike | null;
|
|
74
|
+
clearLastNotificationResponse(): void;
|
|
75
|
+
setBadgeCountAsync(count: number): Promise<boolean>;
|
|
76
|
+
getBadgeCountAsync(): Promise<number>;
|
|
77
|
+
/** `"expo.modules.notifications.actions.DEFAULT"`: the person tapped the notification itself. */
|
|
78
|
+
DEFAULT_ACTION_IDENTIFIER: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** AsyncStorage, MMKV's async adapter, or a stand-in. */
|
|
82
|
+
export interface PushStorageLike {
|
|
83
|
+
getItem(key: string): Promise<string | null>;
|
|
84
|
+
setItem(key: string, value: string): Promise<void>;
|
|
85
|
+
removeItem?(key: string): Promise<void>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** React Native's `AppState`. */
|
|
89
|
+
export interface PushAppStateLike {
|
|
90
|
+
addEventListener(type: "change", listener: (state: string) => void): { remove(): void };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Where a reported error came from. */
|
|
94
|
+
export type PushErrorSource = "permission" | "register" | "forget" | "preferences" | "badge" | "channels" | "routing" | "storage" | "setup";
|
|
95
|
+
|
|
96
|
+
export type PushOnError = (error: Error, source: PushErrorSource) => void;
|
|
97
|
+
|
|
98
|
+
export function toError(value: unknown): Error {
|
|
99
|
+
if (value instanceof Error) return value;
|
|
100
|
+
const message = (value as { message?: unknown } | null)?.message;
|
|
101
|
+
return new Error(typeof message === "string" ? message : typeof value === "string" ? value : "Unknown error");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Calls the reporter, never letting a failing one break the block. */
|
|
105
|
+
export function report(onError: PushOnError | undefined, error: unknown, source: PushErrorSource) {
|
|
106
|
+
try {
|
|
107
|
+
onError?.(toError(error), source);
|
|
108
|
+
} catch {
|
|
109
|
+
// ignore a failing reporter
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification permission, and the soft pre-prompt that comes before the
|
|
3
|
+
* system's.
|
|
4
|
+
*
|
|
5
|
+
* The system prompt can be shown once: a person who says no there has to find
|
|
6
|
+
* the app in Settings to change it. So the block never asks at launch. The
|
|
7
|
+
* app calls `prompt.offer()` at the moment push is worth it (they sent their
|
|
8
|
+
* first message, set a reminder) and the block shows its own sheet first,
|
|
9
|
+
* saying why; only "Turn on" in that sheet (a tap) brings up the system
|
|
10
|
+
* prompt. "Not now" waits `cooldownMs` before the sheet can show again, and
|
|
11
|
+
* after `maxDeclines` it never offers again (Settings still can).
|
|
12
|
+
*/
|
|
13
|
+
import { createStore, type Store } from "../updates/store";
|
|
14
|
+
import { report, type NotificationsModuleLike, type PermissionRequestLike, type PermissionStatusLike, type PushOnError, type PushStorageLike } from "./modules";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `unknown` before the first read; `undetermined` the system can still ask;
|
|
18
|
+
* `granted`; `provisional` (iOS delivers quietly to Notification Center);
|
|
19
|
+
* `denied` only Settings can change; `unavailable` no notifications module.
|
|
20
|
+
*/
|
|
21
|
+
export type PushPermissionStatus = "unknown" | "undetermined" | "granted" | "provisional" | "denied" | "unavailable";
|
|
22
|
+
|
|
23
|
+
export interface PermissionState {
|
|
24
|
+
status: PushPermissionStatus;
|
|
25
|
+
/** The system prompt is up. */
|
|
26
|
+
requesting: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface PermissionStore extends Store<PermissionState> {
|
|
30
|
+
/** Read the status without asking (no prompt). */
|
|
31
|
+
refresh(): Promise<PushPermissionStatus>;
|
|
32
|
+
/** Show the system prompt (only after a tap); resolves to the new status. */
|
|
33
|
+
request(): Promise<PushPermissionStatus>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const IOS_PROVISIONAL = 3;
|
|
37
|
+
const IOS_EPHEMERAL = 4;
|
|
38
|
+
|
|
39
|
+
/** Expo's answer as one status. Android "denied but can ask again" is still askable. */
|
|
40
|
+
export function statusOf(response: PermissionStatusLike): PushPermissionStatus {
|
|
41
|
+
const ios = response.ios?.status;
|
|
42
|
+
if (ios === IOS_PROVISIONAL) return "provisional";
|
|
43
|
+
if (response.granted || response.status === "granted" || ios === IOS_EPHEMERAL) return "granted";
|
|
44
|
+
if (response.status === "undetermined" || response.canAskAgain) return "undetermined";
|
|
45
|
+
return "denied";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const canPush = (status: PushPermissionStatus) => status === "granted" || status === "provisional";
|
|
49
|
+
|
|
50
|
+
export function createPermissionStore(options: {
|
|
51
|
+
notifications?: NotificationsModuleLike;
|
|
52
|
+
request?: PermissionRequestLike;
|
|
53
|
+
/** Runs before the system prompt (Android 13 shows it only once a channel exists). */
|
|
54
|
+
beforeRequest?: () => Promise<void>;
|
|
55
|
+
onError?: PushOnError;
|
|
56
|
+
}): PermissionStore {
|
|
57
|
+
const { notifications, onError } = options;
|
|
58
|
+
const store = createStore<PermissionState>({ status: notifications ? "unknown" : "unavailable", requesting: false });
|
|
59
|
+
return {
|
|
60
|
+
...store,
|
|
61
|
+
async refresh() {
|
|
62
|
+
if (!notifications) return "unavailable";
|
|
63
|
+
try {
|
|
64
|
+
const status = statusOf(await notifications.getPermissionsAsync());
|
|
65
|
+
store.setState({ status });
|
|
66
|
+
return status;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
report(onError, error, "permission");
|
|
69
|
+
return store.getState().status;
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
async request() {
|
|
73
|
+
if (!notifications) return "unavailable";
|
|
74
|
+
if (store.getState().requesting) return store.getState().status;
|
|
75
|
+
store.setState({ requesting: true });
|
|
76
|
+
try {
|
|
77
|
+
await options.beforeRequest?.().catch((error) => report(onError, error, "channels"));
|
|
78
|
+
const status = statusOf(await notifications.requestPermissionsAsync(options.request ?? DEFAULT_REQUEST));
|
|
79
|
+
store.setState({ status });
|
|
80
|
+
return status;
|
|
81
|
+
} catch (error) {
|
|
82
|
+
report(onError, error, "permission");
|
|
83
|
+
return store.getState().status;
|
|
84
|
+
} finally {
|
|
85
|
+
store.setState({ requesting: false });
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const DEFAULT_REQUEST: PermissionRequestLike = { ios: { allowAlert: true, allowBadge: true, allowSound: true } };
|
|
92
|
+
|
|
93
|
+
export interface PromptOptions {
|
|
94
|
+
/** After "Not now", how long before the sheet may show again. Default 3 days. */
|
|
95
|
+
cooldownMs?: number;
|
|
96
|
+
/** After this many "Not now"s it never offers again. Default 3. */
|
|
97
|
+
maxDeclines?: number;
|
|
98
|
+
/** `offer()` is ignored this long after the block starts, so no screen can ask at launch. Default 10 s. */
|
|
99
|
+
launchGraceMs?: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface PromptState {
|
|
103
|
+
visible: boolean;
|
|
104
|
+
/** What `offer(reason)` said push is for here, for the sheet's copy. */
|
|
105
|
+
reason: string | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface PromptStore extends Store<PromptState> {
|
|
109
|
+
/**
|
|
110
|
+
* Show the soft prompt if it is a good moment: signed in, the system can
|
|
111
|
+
* still ask, not at launch, not too soon after "Not now". Resolves to
|
|
112
|
+
* whether it showed.
|
|
113
|
+
*/
|
|
114
|
+
offer(reason?: string): Promise<boolean>;
|
|
115
|
+
/** "Turn on": hides the sheet and shows the system prompt. */
|
|
116
|
+
accept(): Promise<PushPermissionStatus>;
|
|
117
|
+
/** "Not now": hides the sheet and remembers it. */
|
|
118
|
+
decline(): Promise<void>;
|
|
119
|
+
hide(): void;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface Declines {
|
|
123
|
+
count: number;
|
|
124
|
+
at: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function createPromptStore(options: PromptOptions & {
|
|
128
|
+
permission: PermissionStore;
|
|
129
|
+
storage?: PushStorageLike;
|
|
130
|
+
storageKey: string;
|
|
131
|
+
signedIn: () => boolean;
|
|
132
|
+
/** When the block started (ms), or null before. */
|
|
133
|
+
startedAt: () => number | null;
|
|
134
|
+
now: () => number;
|
|
135
|
+
onError?: PushOnError;
|
|
136
|
+
}): PromptStore {
|
|
137
|
+
const { permission, storage, storageKey, now, onError } = options;
|
|
138
|
+
const cooldownMs = options.cooldownMs ?? 3 * 24 * 60 * 60_000;
|
|
139
|
+
const maxDeclines = options.maxDeclines ?? 3;
|
|
140
|
+
const launchGraceMs = options.launchGraceMs ?? 10_000;
|
|
141
|
+
const store = createStore<PromptState>({ visible: false, reason: null });
|
|
142
|
+
let memory: Declines = { count: 0, at: 0 };
|
|
143
|
+
|
|
144
|
+
const readDeclines = async (): Promise<Declines> => {
|
|
145
|
+
if (!storage) return memory;
|
|
146
|
+
try {
|
|
147
|
+
const raw = await storage.getItem(storageKey);
|
|
148
|
+
const parsed = raw ? (JSON.parse(raw) as Partial<Declines>) : null;
|
|
149
|
+
return { count: Number(parsed?.count) || 0, at: Number(parsed?.at) || 0 };
|
|
150
|
+
} catch (error) {
|
|
151
|
+
report(onError, error, "storage");
|
|
152
|
+
return memory;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
...store,
|
|
158
|
+
async offer(reason) {
|
|
159
|
+
if (store.getState().visible) return true;
|
|
160
|
+
const started = options.startedAt();
|
|
161
|
+
if (started === null || now() - started < launchGraceMs) return false;
|
|
162
|
+
if (!options.signedIn()) return false;
|
|
163
|
+
const status = permission.getState().status === "unknown" ? await permission.refresh() : permission.getState().status;
|
|
164
|
+
if (status !== "undetermined") return false;
|
|
165
|
+
const declines = await readDeclines();
|
|
166
|
+
if (declines.count >= maxDeclines || (declines.count > 0 && now() - declines.at < cooldownMs)) return false;
|
|
167
|
+
if (!options.signedIn()) return false;
|
|
168
|
+
store.setState({ visible: true, reason: reason ?? null });
|
|
169
|
+
return true;
|
|
170
|
+
},
|
|
171
|
+
async accept() {
|
|
172
|
+
store.setState({ visible: false });
|
|
173
|
+
return permission.request();
|
|
174
|
+
},
|
|
175
|
+
async decline() {
|
|
176
|
+
// Only from the sheet: a dismissal that fires as it closes after "Turn on" isn't a "Not now".
|
|
177
|
+
if (!store.getState().visible) return;
|
|
178
|
+
store.setState({ visible: false });
|
|
179
|
+
const declines = await readDeclines();
|
|
180
|
+
memory = { count: declines.count + 1, at: now() };
|
|
181
|
+
try {
|
|
182
|
+
await storage?.setItem(storageKey, JSON.stringify(memory));
|
|
183
|
+
} catch (error) {
|
|
184
|
+
report(onError, error, "storage");
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
hide() {
|
|
188
|
+
store.setState({ visible: false, reason: null });
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signed-in person's notification preferences: categories, quiet hours
|
|
3
|
+
* and privacy previews. Loaded when they sign in, saved on every change
|
|
4
|
+
* (shown at once, put back if the save fails), cleared when they sign out.
|
|
5
|
+
* The server reads the saved row; nothing here decides what is delivered.
|
|
6
|
+
*/
|
|
7
|
+
import { createStore, type Store } from "../updates/store";
|
|
8
|
+
import { DEFAULT_PREFERENCES, type NotificationPreferences, type PushBackend, type QuietHours } from "./backend";
|
|
9
|
+
import { report, type PushOnError } from "./modules";
|
|
10
|
+
import { parseClock } from "./quiet-hours";
|
|
11
|
+
|
|
12
|
+
/** Android importance for a category's channel (Expo's `AndroidImportance`). */
|
|
13
|
+
export type ChannelImportance = "min" | "low" | "default" | "high" | "max";
|
|
14
|
+
|
|
15
|
+
/** One kind of notification the app sends, which a person can turn off. */
|
|
16
|
+
export interface NotificationCategory {
|
|
17
|
+
/** Lower case letters, digits, ".", "_" or "-" (`messages`). Also its Android channel id, and the sender's `category`. */
|
|
18
|
+
id: string;
|
|
19
|
+
title: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
/** On for someone who never chose. Default true; pass the same `defaultOn` to the sender. */
|
|
22
|
+
defaultOn?: boolean;
|
|
23
|
+
/** Android channel importance. Default `"default"`. */
|
|
24
|
+
importance?: ChannelImportance;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type PreferencesStatus = "idle" | "loading" | "ready" | "error";
|
|
28
|
+
|
|
29
|
+
export interface PreferencesState {
|
|
30
|
+
status: PreferencesStatus;
|
|
31
|
+
preferences: NotificationPreferences;
|
|
32
|
+
saving: boolean;
|
|
33
|
+
/** The last load or save that failed (the change was put back). */
|
|
34
|
+
error: Error | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PreferencesStore extends Store<PreferencesState> {
|
|
38
|
+
load(userId: string): Promise<void>;
|
|
39
|
+
/** Apply a change now and save it; on failure it is put back and `error` set. */
|
|
40
|
+
update(change: (current: NotificationPreferences) => NotificationPreferences): Promise<boolean>;
|
|
41
|
+
reset(): void;
|
|
42
|
+
isOn(categoryId: string): boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const CATEGORY_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
46
|
+
|
|
47
|
+
/** Checks a change before it is shown or saved, so the server never has to refuse it. */
|
|
48
|
+
export function validPreferences(p: NotificationPreferences): boolean {
|
|
49
|
+
const q: QuietHours = p.quietHours;
|
|
50
|
+
if (parseClock(q.start) === null || parseClock(q.end) === null) return false;
|
|
51
|
+
if (q.enabled && !q.timeZone) return false;
|
|
52
|
+
return Object.keys(p.categories).every((id) => CATEGORY_ID.test(id));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createPreferencesStore(options: {
|
|
56
|
+
backend: PushBackend;
|
|
57
|
+
categories: readonly NotificationCategory[];
|
|
58
|
+
onError?: PushOnError;
|
|
59
|
+
}): PreferencesStore {
|
|
60
|
+
const { backend, categories, onError } = options;
|
|
61
|
+
for (const category of categories) {
|
|
62
|
+
if (!CATEGORY_ID.test(category.id)) throw new Error(`createPush: category id "${category.id}" must be lower case letters, digits, ".", "_" or "-"`);
|
|
63
|
+
}
|
|
64
|
+
const store = createStore<PreferencesState>({ status: "idle", preferences: DEFAULT_PREFERENCES, saving: false, error: null });
|
|
65
|
+
let userId: string | null = null;
|
|
66
|
+
// Each load or save belongs to one sign-in; an answer for an earlier one is dropped.
|
|
67
|
+
let generation = 0;
|
|
68
|
+
let saves: Promise<unknown> = Promise.resolve();
|
|
69
|
+
let confirmed: NotificationPreferences = DEFAULT_PREFERENCES;
|
|
70
|
+
let pending = 0;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
...store,
|
|
74
|
+
async load(id) {
|
|
75
|
+
const mine = ++generation;
|
|
76
|
+
userId = id;
|
|
77
|
+
pending = 0;
|
|
78
|
+
store.setState({ status: "loading", saving: false, error: null });
|
|
79
|
+
try {
|
|
80
|
+
const loaded = (await backend.loadPreferences(id)) ?? DEFAULT_PREFERENCES;
|
|
81
|
+
if (mine !== generation) return;
|
|
82
|
+
confirmed = loaded;
|
|
83
|
+
store.setState({ status: "ready", preferences: loaded });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (mine !== generation) return;
|
|
86
|
+
report(onError, error, "preferences");
|
|
87
|
+
store.setState({ status: "error", error: error instanceof Error ? error : new Error("Couldn't load notification settings") });
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
update(change) {
|
|
91
|
+
const id = userId;
|
|
92
|
+
const mine = generation;
|
|
93
|
+
// Only on top of the server's copy: a change made while it loads (or after a failed load)
|
|
94
|
+
// would save the defaults over it, turning a hidden preview back on.
|
|
95
|
+
if (!id || store.getState().status !== "ready") return Promise.resolve(false);
|
|
96
|
+
const next = change(store.getState().preferences);
|
|
97
|
+
if (!validPreferences(next)) return Promise.resolve(false);
|
|
98
|
+
pending++;
|
|
99
|
+
store.setState({ preferences: next, saving: true, error: null });
|
|
100
|
+
// Saves go one at a time, in order; each carries every change before it.
|
|
101
|
+
const save = saves.then(async () => {
|
|
102
|
+
if (mine !== generation) return false;
|
|
103
|
+
let ok = false;
|
|
104
|
+
try {
|
|
105
|
+
await backend.savePreferences(id, next);
|
|
106
|
+
ok = true;
|
|
107
|
+
if (mine === generation) confirmed = next;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (mine === generation) {
|
|
110
|
+
report(onError, error, "preferences");
|
|
111
|
+
store.setState({ error: error instanceof Error ? error : new Error("Couldn't save notification settings") });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (mine !== generation) return false;
|
|
115
|
+
pending--;
|
|
116
|
+
// The last save settles what shows: the server's copy (a failed change is put back).
|
|
117
|
+
if (pending === 0) store.setState({ preferences: confirmed, saving: false });
|
|
118
|
+
return ok;
|
|
119
|
+
});
|
|
120
|
+
saves = save.catch(() => false);
|
|
121
|
+
return save;
|
|
122
|
+
},
|
|
123
|
+
reset() {
|
|
124
|
+
generation++;
|
|
125
|
+
userId = null;
|
|
126
|
+
pending = 0;
|
|
127
|
+
confirmed = DEFAULT_PREFERENCES;
|
|
128
|
+
store.setState({ status: "idle", preferences: DEFAULT_PREFERENCES, saving: false, error: null });
|
|
129
|
+
},
|
|
130
|
+
isOn(categoryId) {
|
|
131
|
+
const chosen = store.getState().preferences.categories[categoryId];
|
|
132
|
+
if (typeof chosen === "boolean") return chosen;
|
|
133
|
+
return categories.find((c) => c.id === categoryId)?.defaultOn ?? true;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiet hours on the phone: only for showing them ("on now, until 07:00").
|
|
3
|
+
* Whether a push waits is decided on the server (migration 0005's
|
|
4
|
+
* genesis_push_status), in the person's saved time zone, so a phone with a
|
|
5
|
+
* wrong clock or in another zone can't change it.
|
|
6
|
+
*/
|
|
7
|
+
import type { QuietHours } from "./backend";
|
|
8
|
+
|
|
9
|
+
const CLOCK = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
10
|
+
|
|
11
|
+
/** Minutes since midnight for `"HH:MM"`, or null. */
|
|
12
|
+
export function parseClock(value: string): number | null {
|
|
13
|
+
const match = CLOCK.exec(value);
|
|
14
|
+
return match ? Number(match[1]) * 60 + Number(match[2]) : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** `"HH:MM"` for minutes since midnight (wrapping a day). */
|
|
18
|
+
export function formatClock(minutes: number): string {
|
|
19
|
+
const m = ((Math.round(minutes) % 1440) + 1440) % 1440;
|
|
20
|
+
return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `"HH:MM"` for a `Date`'s local time (what a time picker hands back). */
|
|
24
|
+
export function clockOf(date: Date): string {
|
|
25
|
+
return formatClock(date.getHours() * 60 + date.getMinutes());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Today at `"HH:MM"` local, for a time picker's value. */
|
|
29
|
+
export function dateAt(clock: string, today = new Date()): Date {
|
|
30
|
+
const minutes = parseClock(clock) ?? 0;
|
|
31
|
+
const d = new Date(today);
|
|
32
|
+
d.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
|
|
33
|
+
return d;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** This phone's IANA time zone, or null when the JavaScript engine can't say. */
|
|
37
|
+
export function deviceTimeZone(): string | null {
|
|
38
|
+
try {
|
|
39
|
+
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
40
|
+
return typeof zone === "string" && zone.length > 0 ? zone : null;
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Minutes since midnight at `at` in `timeZone`, or null for a zone the engine doesn't know. */
|
|
47
|
+
export function minutesIn(timeZone: string, at: Date): number | null {
|
|
48
|
+
try {
|
|
49
|
+
const parts = new Intl.DateTimeFormat("en-US", { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(at);
|
|
50
|
+
const hour = Number(parts.find((p) => p.type === "hour")?.value);
|
|
51
|
+
const minute = Number(parts.find((p) => p.type === "minute")?.value);
|
|
52
|
+
return Number.isFinite(hour) && Number.isFinite(minute) ? (hour % 24) * 60 + minute : null;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Are quiet hours on at `at`? The same rule as the server: the start is
|
|
60
|
+
* inclusive, the end exclusive, a start after the end runs past midnight, a
|
|
61
|
+
* start equal to the end is no quiet hours, and an unknown zone is never quiet.
|
|
62
|
+
*/
|
|
63
|
+
export function isQuietAt(quiet: QuietHours, at: Date = new Date()): boolean {
|
|
64
|
+
if (!quiet.enabled || !quiet.timeZone) return false;
|
|
65
|
+
const start = parseClock(quiet.start);
|
|
66
|
+
const end = parseClock(quiet.end);
|
|
67
|
+
if (start === null || end === null || start === end) return false;
|
|
68
|
+
const now = minutesIn(quiet.timeZone, at);
|
|
69
|
+
if (now === null) return false;
|
|
70
|
+
return start < end ? now >= start && now < end : now >= start || now < end;
|
|
71
|
+
}
|