@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
package/dist/push.js
ADDED
|
@@ -0,0 +1,1069 @@
|
|
|
1
|
+
import { normalizeLink } from './chunk-VKTIHNAV.js';
|
|
2
|
+
import { createStore, delay } from './chunk-XE7LC7K6.js';
|
|
3
|
+
export { RICH_PUSH_IMAGE_FIELDS, isRichPushUrl, richPushMessage } from './chunk-ONAXQHNG.js';
|
|
4
|
+
import { useSession } from './chunk-NGYNYJPL.js';
|
|
5
|
+
import { createContext, useSyncExternalStore, useContext, useRef, useEffect, useMemo } from 'react';
|
|
6
|
+
import { View } from 'react-native';
|
|
7
|
+
import { renderSlot } from '@marktiderman/genesis-core/slots';
|
|
8
|
+
import { defineBlock } from '@marktiderman/genesis-features/blocks';
|
|
9
|
+
import { NativeSheet, NativeText, NativeButton, NativeTimePicker, NativeSwitch } from '@marktiderman/genesis-ui-native/components';
|
|
10
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
11
|
+
import { SettingsPage } from '@marktiderman/genesis-ui-native/layouts';
|
|
12
|
+
|
|
13
|
+
// src/push/modules.ts
|
|
14
|
+
function toError(value) {
|
|
15
|
+
if (value instanceof Error) return value;
|
|
16
|
+
const message = value?.message;
|
|
17
|
+
return new Error(typeof message === "string" ? message : typeof value === "string" ? value : "Unknown error");
|
|
18
|
+
}
|
|
19
|
+
function report(onError, error, source) {
|
|
20
|
+
try {
|
|
21
|
+
onError?.(toError(error), source);
|
|
22
|
+
} catch {
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// src/push/permission.ts
|
|
27
|
+
var IOS_PROVISIONAL = 3;
|
|
28
|
+
var IOS_EPHEMERAL = 4;
|
|
29
|
+
function statusOf(response) {
|
|
30
|
+
const ios = response.ios?.status;
|
|
31
|
+
if (ios === IOS_PROVISIONAL) return "provisional";
|
|
32
|
+
if (response.granted || response.status === "granted" || ios === IOS_EPHEMERAL) return "granted";
|
|
33
|
+
if (response.status === "undetermined" || response.canAskAgain) return "undetermined";
|
|
34
|
+
return "denied";
|
|
35
|
+
}
|
|
36
|
+
var canPush = (status) => status === "granted" || status === "provisional";
|
|
37
|
+
function createPermissionStore(options) {
|
|
38
|
+
const { notifications, onError } = options;
|
|
39
|
+
const store = createStore({ status: notifications ? "unknown" : "unavailable", requesting: false });
|
|
40
|
+
return {
|
|
41
|
+
...store,
|
|
42
|
+
async refresh() {
|
|
43
|
+
if (!notifications) return "unavailable";
|
|
44
|
+
try {
|
|
45
|
+
const status = statusOf(await notifications.getPermissionsAsync());
|
|
46
|
+
store.setState({ status });
|
|
47
|
+
return status;
|
|
48
|
+
} catch (error) {
|
|
49
|
+
report(onError, error, "permission");
|
|
50
|
+
return store.getState().status;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
async request() {
|
|
54
|
+
if (!notifications) return "unavailable";
|
|
55
|
+
if (store.getState().requesting) return store.getState().status;
|
|
56
|
+
store.setState({ requesting: true });
|
|
57
|
+
try {
|
|
58
|
+
await options.beforeRequest?.().catch((error) => report(onError, error, "channels"));
|
|
59
|
+
const status = statusOf(await notifications.requestPermissionsAsync(options.request ?? DEFAULT_REQUEST));
|
|
60
|
+
store.setState({ status });
|
|
61
|
+
return status;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
report(onError, error, "permission");
|
|
64
|
+
return store.getState().status;
|
|
65
|
+
} finally {
|
|
66
|
+
store.setState({ requesting: false });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
var DEFAULT_REQUEST = { ios: { allowAlert: true, allowBadge: true, allowSound: true } };
|
|
72
|
+
function createPromptStore(options) {
|
|
73
|
+
const { permission, storage, storageKey, now, onError } = options;
|
|
74
|
+
const cooldownMs = options.cooldownMs ?? 3 * 24 * 60 * 6e4;
|
|
75
|
+
const maxDeclines = options.maxDeclines ?? 3;
|
|
76
|
+
const launchGraceMs = options.launchGraceMs ?? 1e4;
|
|
77
|
+
const store = createStore({ visible: false, reason: null });
|
|
78
|
+
let memory = { count: 0, at: 0 };
|
|
79
|
+
const readDeclines = async () => {
|
|
80
|
+
if (!storage) return memory;
|
|
81
|
+
try {
|
|
82
|
+
const raw = await storage.getItem(storageKey);
|
|
83
|
+
const parsed = raw ? JSON.parse(raw) : null;
|
|
84
|
+
return { count: Number(parsed?.count) || 0, at: Number(parsed?.at) || 0 };
|
|
85
|
+
} catch (error) {
|
|
86
|
+
report(onError, error, "storage");
|
|
87
|
+
return memory;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
...store,
|
|
92
|
+
async offer(reason) {
|
|
93
|
+
if (store.getState().visible) return true;
|
|
94
|
+
const started = options.startedAt();
|
|
95
|
+
if (started === null || now() - started < launchGraceMs) return false;
|
|
96
|
+
if (!options.signedIn()) return false;
|
|
97
|
+
const status = permission.getState().status === "unknown" ? await permission.refresh() : permission.getState().status;
|
|
98
|
+
if (status !== "undetermined") return false;
|
|
99
|
+
const declines = await readDeclines();
|
|
100
|
+
if (declines.count >= maxDeclines || declines.count > 0 && now() - declines.at < cooldownMs) return false;
|
|
101
|
+
if (!options.signedIn()) return false;
|
|
102
|
+
store.setState({ visible: true, reason: reason ?? null });
|
|
103
|
+
return true;
|
|
104
|
+
},
|
|
105
|
+
async accept() {
|
|
106
|
+
store.setState({ visible: false });
|
|
107
|
+
return permission.request();
|
|
108
|
+
},
|
|
109
|
+
async decline() {
|
|
110
|
+
if (!store.getState().visible) return;
|
|
111
|
+
store.setState({ visible: false });
|
|
112
|
+
const declines = await readDeclines();
|
|
113
|
+
memory = { count: declines.count + 1, at: now() };
|
|
114
|
+
try {
|
|
115
|
+
await storage?.setItem(storageKey, JSON.stringify(memory));
|
|
116
|
+
} catch (error) {
|
|
117
|
+
report(onError, error, "storage");
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
hide() {
|
|
121
|
+
store.setState({ visible: false, reason: null });
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// src/push/backend.ts
|
|
127
|
+
var DEFAULT_PREFERENCES = {
|
|
128
|
+
categories: {},
|
|
129
|
+
quietHours: { enabled: false, start: "22:00", end: "07:00", timeZone: null },
|
|
130
|
+
hidePreviews: false
|
|
131
|
+
};
|
|
132
|
+
var PushBackendError = class extends Error {
|
|
133
|
+
constructor(operation, code) {
|
|
134
|
+
super(`${operation} failed${code ? ` (${code})` : ""}`);
|
|
135
|
+
this.operation = operation;
|
|
136
|
+
this.code = code;
|
|
137
|
+
this.name = "PushBackendError";
|
|
138
|
+
}
|
|
139
|
+
operation;
|
|
140
|
+
code;
|
|
141
|
+
};
|
|
142
|
+
var COLUMNS = "categories, quiet_hours_enabled, quiet_hours_start, quiet_hours_end, time_zone, hide_previews";
|
|
143
|
+
function clock(value, fallback) {
|
|
144
|
+
const match = typeof value === "string" ? /^(\d{2}):(\d{2})/.exec(value) : null;
|
|
145
|
+
return match ? `${match[1]}:${match[2]}` : fallback;
|
|
146
|
+
}
|
|
147
|
+
function fromRow(row) {
|
|
148
|
+
const categories = {};
|
|
149
|
+
const raw = row.categories;
|
|
150
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
151
|
+
for (const [id, on] of Object.entries(raw)) if (typeof on === "boolean") categories[id] = on;
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
categories,
|
|
155
|
+
quietHours: {
|
|
156
|
+
enabled: row.quiet_hours_enabled === true,
|
|
157
|
+
start: clock(row.quiet_hours_start, DEFAULT_PREFERENCES.quietHours.start),
|
|
158
|
+
end: clock(row.quiet_hours_end, DEFAULT_PREFERENCES.quietHours.end),
|
|
159
|
+
timeZone: typeof row.time_zone === "string" ? row.time_zone : null
|
|
160
|
+
},
|
|
161
|
+
hidePreviews: row.hide_previews === true
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function supabasePushBackend(client) {
|
|
165
|
+
const must = (operation, result) => {
|
|
166
|
+
if (result.error) throw new PushBackendError(operation, result.error.code ?? null);
|
|
167
|
+
return result.data;
|
|
168
|
+
};
|
|
169
|
+
return {
|
|
170
|
+
async register(token, platform, appVersion) {
|
|
171
|
+
must("genesis_push_register", await client.rpc("genesis_push_register", { p_token: token, p_platform: platform, p_app_version: appVersion }));
|
|
172
|
+
},
|
|
173
|
+
async forget(token) {
|
|
174
|
+
must("genesis_push_forget", await client.rpc("genesis_push_forget", { p_token: token }));
|
|
175
|
+
},
|
|
176
|
+
async loadPreferences(userId) {
|
|
177
|
+
const data = must("notification_preferences", await client.from("notification_preferences").select(COLUMNS).eq("user_id", userId).maybeSingle());
|
|
178
|
+
return data && typeof data === "object" ? fromRow(data) : null;
|
|
179
|
+
},
|
|
180
|
+
async savePreferences(userId, p) {
|
|
181
|
+
const row = {
|
|
182
|
+
user_id: userId,
|
|
183
|
+
categories: p.categories,
|
|
184
|
+
quiet_hours_enabled: p.quietHours.enabled,
|
|
185
|
+
quiet_hours_start: p.quietHours.start,
|
|
186
|
+
quiet_hours_end: p.quietHours.end,
|
|
187
|
+
time_zone: p.quietHours.timeZone,
|
|
188
|
+
hide_previews: p.hidePreviews
|
|
189
|
+
};
|
|
190
|
+
const data = must("notification_preferences", await client.from("notification_preferences").upsert(row, { onConflict: "user_id" }).select("user_id"));
|
|
191
|
+
if (!Array.isArray(data) || data.length !== 1) throw new PushBackendError("notification_preferences", "no_rows");
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// src/push/quiet-hours.ts
|
|
197
|
+
var CLOCK = /^([01]\d|2[0-3]):([0-5]\d)$/;
|
|
198
|
+
function parseClock(value) {
|
|
199
|
+
const match = CLOCK.exec(value);
|
|
200
|
+
return match ? Number(match[1]) * 60 + Number(match[2]) : null;
|
|
201
|
+
}
|
|
202
|
+
function formatClock(minutes) {
|
|
203
|
+
const m = (Math.round(minutes) % 1440 + 1440) % 1440;
|
|
204
|
+
return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
|
|
205
|
+
}
|
|
206
|
+
function clockOf(date) {
|
|
207
|
+
return formatClock(date.getHours() * 60 + date.getMinutes());
|
|
208
|
+
}
|
|
209
|
+
function dateAt(clock2, today = /* @__PURE__ */ new Date()) {
|
|
210
|
+
const minutes = parseClock(clock2) ?? 0;
|
|
211
|
+
const d = new Date(today);
|
|
212
|
+
d.setHours(Math.floor(minutes / 60), minutes % 60, 0, 0);
|
|
213
|
+
return d;
|
|
214
|
+
}
|
|
215
|
+
function deviceTimeZone() {
|
|
216
|
+
try {
|
|
217
|
+
const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
218
|
+
return typeof zone === "string" && zone.length > 0 ? zone : null;
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function minutesIn(timeZone, at) {
|
|
224
|
+
try {
|
|
225
|
+
const parts = new Intl.DateTimeFormat("en-US", { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(at);
|
|
226
|
+
const hour = Number(parts.find((p) => p.type === "hour")?.value);
|
|
227
|
+
const minute = Number(parts.find((p) => p.type === "minute")?.value);
|
|
228
|
+
return Number.isFinite(hour) && Number.isFinite(minute) ? hour % 24 * 60 + minute : null;
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function isQuietAt(quiet, at = /* @__PURE__ */ new Date()) {
|
|
234
|
+
if (!quiet.enabled || !quiet.timeZone) return false;
|
|
235
|
+
const start = parseClock(quiet.start);
|
|
236
|
+
const end = parseClock(quiet.end);
|
|
237
|
+
if (start === null || end === null || start === end) return false;
|
|
238
|
+
const now = minutesIn(quiet.timeZone, at);
|
|
239
|
+
if (now === null) return false;
|
|
240
|
+
return start < end ? now >= start && now < end : now >= start || now < end;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// src/push/preferences.ts
|
|
244
|
+
var CATEGORY_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
245
|
+
function validPreferences(p) {
|
|
246
|
+
const q = p.quietHours;
|
|
247
|
+
if (parseClock(q.start) === null || parseClock(q.end) === null) return false;
|
|
248
|
+
if (q.enabled && !q.timeZone) return false;
|
|
249
|
+
return Object.keys(p.categories).every((id) => CATEGORY_ID.test(id));
|
|
250
|
+
}
|
|
251
|
+
function createPreferencesStore(options) {
|
|
252
|
+
const { backend, categories, onError } = options;
|
|
253
|
+
for (const category of categories) {
|
|
254
|
+
if (!CATEGORY_ID.test(category.id)) throw new Error(`createPush: category id "${category.id}" must be lower case letters, digits, ".", "_" or "-"`);
|
|
255
|
+
}
|
|
256
|
+
const store = createStore({ status: "idle", preferences: DEFAULT_PREFERENCES, saving: false, error: null });
|
|
257
|
+
let userId = null;
|
|
258
|
+
let generation = 0;
|
|
259
|
+
let saves = Promise.resolve();
|
|
260
|
+
let confirmed = DEFAULT_PREFERENCES;
|
|
261
|
+
let pending = 0;
|
|
262
|
+
return {
|
|
263
|
+
...store,
|
|
264
|
+
async load(id) {
|
|
265
|
+
const mine = ++generation;
|
|
266
|
+
userId = id;
|
|
267
|
+
pending = 0;
|
|
268
|
+
store.setState({ status: "loading", saving: false, error: null });
|
|
269
|
+
try {
|
|
270
|
+
const loaded = await backend.loadPreferences(id) ?? DEFAULT_PREFERENCES;
|
|
271
|
+
if (mine !== generation) return;
|
|
272
|
+
confirmed = loaded;
|
|
273
|
+
store.setState({ status: "ready", preferences: loaded });
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (mine !== generation) return;
|
|
276
|
+
report(onError, error, "preferences");
|
|
277
|
+
store.setState({ status: "error", error: error instanceof Error ? error : new Error("Couldn't load notification settings") });
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
update(change) {
|
|
281
|
+
const id = userId;
|
|
282
|
+
const mine = generation;
|
|
283
|
+
if (!id || store.getState().status !== "ready") return Promise.resolve(false);
|
|
284
|
+
const next = change(store.getState().preferences);
|
|
285
|
+
if (!validPreferences(next)) return Promise.resolve(false);
|
|
286
|
+
pending++;
|
|
287
|
+
store.setState({ preferences: next, saving: true, error: null });
|
|
288
|
+
const save = saves.then(async () => {
|
|
289
|
+
if (mine !== generation) return false;
|
|
290
|
+
let ok = false;
|
|
291
|
+
try {
|
|
292
|
+
await backend.savePreferences(id, next);
|
|
293
|
+
ok = true;
|
|
294
|
+
if (mine === generation) confirmed = next;
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (mine === generation) {
|
|
297
|
+
report(onError, error, "preferences");
|
|
298
|
+
store.setState({ error: error instanceof Error ? error : new Error("Couldn't save notification settings") });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (mine !== generation) return false;
|
|
302
|
+
pending--;
|
|
303
|
+
if (pending === 0) store.setState({ preferences: confirmed, saving: false });
|
|
304
|
+
return ok;
|
|
305
|
+
});
|
|
306
|
+
saves = save.catch(() => false);
|
|
307
|
+
return save;
|
|
308
|
+
},
|
|
309
|
+
reset() {
|
|
310
|
+
generation++;
|
|
311
|
+
userId = null;
|
|
312
|
+
pending = 0;
|
|
313
|
+
confirmed = DEFAULT_PREFERENCES;
|
|
314
|
+
store.setState({ status: "idle", preferences: DEFAULT_PREFERENCES, saving: false, error: null });
|
|
315
|
+
},
|
|
316
|
+
isOn(categoryId) {
|
|
317
|
+
const chosen = store.getState().preferences.categories[categoryId];
|
|
318
|
+
if (typeof chosen === "boolean") return chosen;
|
|
319
|
+
return categories.find((c) => c.id === categoryId)?.defaultOn ?? true;
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// src/push/registration.ts
|
|
325
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
326
|
+
function makeUUID(maker) {
|
|
327
|
+
try {
|
|
328
|
+
const made = maker?.() ?? globalThis.crypto?.randomUUID?.();
|
|
329
|
+
if (typeof made === "string" && UUID.test(made)) return made.toLowerCase();
|
|
330
|
+
const bytes = globalThis.crypto?.getRandomValues?.(new Uint8Array(16));
|
|
331
|
+
if (!bytes) return void 0;
|
|
332
|
+
bytes[6] = bytes[6] & 15 | 64;
|
|
333
|
+
bytes[8] = bytes[8] & 63 | 128;
|
|
334
|
+
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
335
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
336
|
+
} catch {
|
|
337
|
+
return void 0;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
var REFRESH_MS = 60 * 6e4;
|
|
341
|
+
function createRegistration(options) {
|
|
342
|
+
const { notifications, backend, storage, storageKeyPrefix, onError, now } = options;
|
|
343
|
+
const savedKey = `${storageKeyPrefix}:device`;
|
|
344
|
+
const pendingKey = `${storageKeyPrefix}:forget`;
|
|
345
|
+
const installationKey = `${storageKeyPrefix}:installation`;
|
|
346
|
+
let installation = null;
|
|
347
|
+
const store = createStore({ status: "idle", problem: null });
|
|
348
|
+
let current = null;
|
|
349
|
+
let queue = Promise.resolve();
|
|
350
|
+
let warnedNoProject = false;
|
|
351
|
+
const run = (step) => {
|
|
352
|
+
const next = queue.then(step, step);
|
|
353
|
+
queue = next.catch(() => {
|
|
354
|
+
});
|
|
355
|
+
return next;
|
|
356
|
+
};
|
|
357
|
+
const read = async (key, fallback) => {
|
|
358
|
+
if (!storage) return fallback;
|
|
359
|
+
try {
|
|
360
|
+
const raw = await storage.getItem(key);
|
|
361
|
+
return raw ? JSON.parse(raw) : fallback;
|
|
362
|
+
} catch (error) {
|
|
363
|
+
report(onError, error, "storage");
|
|
364
|
+
return fallback;
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
const write = async (key, value) => {
|
|
368
|
+
try {
|
|
369
|
+
if (value === null) await storage?.removeItem?.(key);
|
|
370
|
+
else await storage?.setItem(key, JSON.stringify(value));
|
|
371
|
+
} catch (error) {
|
|
372
|
+
report(onError, error, "storage");
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
const installationId = async () => {
|
|
376
|
+
if (installation) return installation;
|
|
377
|
+
const saved = await read(installationKey, null);
|
|
378
|
+
if (typeof saved === "string" && UUID.test(saved)) return installation = saved;
|
|
379
|
+
const made = makeUUID(options.randomUUID);
|
|
380
|
+
if (!made) return void 0;
|
|
381
|
+
installation = made;
|
|
382
|
+
await write(installationKey, made);
|
|
383
|
+
return made;
|
|
384
|
+
};
|
|
385
|
+
const forgetToken = async (token) => {
|
|
386
|
+
try {
|
|
387
|
+
await backend.forget(token);
|
|
388
|
+
return true;
|
|
389
|
+
} catch (error) {
|
|
390
|
+
report(onError, error, "forget");
|
|
391
|
+
return false;
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
const queuePending = async (token) => {
|
|
395
|
+
const pending = await read(pendingKey, []);
|
|
396
|
+
if (!pending.includes(token)) await write(pendingKey, [...pending, token].slice(-10));
|
|
397
|
+
};
|
|
398
|
+
return {
|
|
399
|
+
...store,
|
|
400
|
+
token: () => current,
|
|
401
|
+
sync(userId, { force = false } = {}) {
|
|
402
|
+
return run(async () => {
|
|
403
|
+
if (!notifications || !options.isCurrent(userId)) return;
|
|
404
|
+
if (!options.projectId) {
|
|
405
|
+
store.setState({ status: "unavailable", problem: "no_project_id" });
|
|
406
|
+
if (!warnedNoProject) {
|
|
407
|
+
warnedNoProject = true;
|
|
408
|
+
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");
|
|
409
|
+
}
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
store.setState({ status: "registering", problem: null });
|
|
413
|
+
await options.ensureChannels().catch((error) => report(onError, error, "channels"));
|
|
414
|
+
let token;
|
|
415
|
+
try {
|
|
416
|
+
token = (await notifications.getExpoPushTokenAsync({ projectId: options.projectId })).data;
|
|
417
|
+
} catch (error) {
|
|
418
|
+
report(onError, error, "register");
|
|
419
|
+
store.setState({ status: "error", problem: "no_token" });
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (typeof token !== "string" || token.length === 0) {
|
|
423
|
+
store.setState({ status: "error", problem: "no_token" });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
current = token;
|
|
427
|
+
const saved = await read(savedKey, null);
|
|
428
|
+
if (saved && saved.userId !== userId && saved.token !== token && !await forgetToken(saved.token)) await queuePending(saved.token);
|
|
429
|
+
if (!force && saved && saved.token === token && saved.userId === userId && now() - saved.at < REFRESH_MS) {
|
|
430
|
+
store.setState({ status: "registered", problem: null });
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
if (!options.isCurrent(userId)) return;
|
|
434
|
+
try {
|
|
435
|
+
const platform = options.platform === "ios" ? "ios" : "android";
|
|
436
|
+
if (backend.usesInstallationId) await backend.register(token, platform, options.appVersion ?? null, await installationId());
|
|
437
|
+
else await backend.register(token, platform, options.appVersion ?? null);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
report(onError, error, "register");
|
|
440
|
+
store.setState({ status: "error", problem: "backend" });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (saved && saved.token !== token && saved.userId === userId && !await forgetToken(saved.token)) await queuePending(saved.token);
|
|
444
|
+
await write(savedKey, { token, userId, at: now() });
|
|
445
|
+
const pending = await read(pendingKey, []);
|
|
446
|
+
if (pending.includes(token)) await write(pendingKey, pending.filter((t) => t !== token).length ? pending.filter((t) => t !== token) : null);
|
|
447
|
+
store.setState({ status: "registered", problem: null });
|
|
448
|
+
});
|
|
449
|
+
},
|
|
450
|
+
forget() {
|
|
451
|
+
return run(async () => {
|
|
452
|
+
const saved = await read(savedKey, null);
|
|
453
|
+
const token = saved?.token ?? current;
|
|
454
|
+
await write(savedKey, null);
|
|
455
|
+
store.setState({ status: "idle", problem: null });
|
|
456
|
+
if (!token) return;
|
|
457
|
+
if (!await forgetToken(token)) await queuePending(token);
|
|
458
|
+
});
|
|
459
|
+
},
|
|
460
|
+
flush() {
|
|
461
|
+
return run(async () => {
|
|
462
|
+
const pending = await read(pendingKey, []);
|
|
463
|
+
if (pending.length === 0) return;
|
|
464
|
+
const left = [];
|
|
465
|
+
const registered = (await read(savedKey, null))?.token;
|
|
466
|
+
for (const token of pending) {
|
|
467
|
+
if (token === registered) continue;
|
|
468
|
+
if (!await forgetToken(token)) left.push(token);
|
|
469
|
+
}
|
|
470
|
+
await write(pendingKey, left.length ? left : null);
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// src/push/routing.ts
|
|
477
|
+
var ANY_PATH = { map: { "/*": "/*" } };
|
|
478
|
+
function notificationData(notification) {
|
|
479
|
+
const data = notification.request?.content?.data;
|
|
480
|
+
return data && typeof data === "object" && !Array.isArray(data) ? data : {};
|
|
481
|
+
}
|
|
482
|
+
function pathForResponse(response, links) {
|
|
483
|
+
const url = notificationData(response.notification).url;
|
|
484
|
+
if (typeof url !== "string" || url.length === 0 || url.length > 2048) return null;
|
|
485
|
+
if (links) return normalizeLink(url, links);
|
|
486
|
+
if (!url.startsWith("/") || url.startsWith("//") || url.includes("\\")) return null;
|
|
487
|
+
const path = normalizeLink(url, ANY_PATH);
|
|
488
|
+
return path === "/" && url.split(/[?#]/)[0] !== "/" ? null : path;
|
|
489
|
+
}
|
|
490
|
+
function notificationAction(response, defaultActionId = "expo.modules.notifications.actions.DEFAULT") {
|
|
491
|
+
const id = response.actionIdentifier;
|
|
492
|
+
if (!id || id === defaultActionId || id === "com.apple.UNNotificationDismissActionIdentifier") return null;
|
|
493
|
+
const data = notificationData(response.notification);
|
|
494
|
+
return {
|
|
495
|
+
actionId: id,
|
|
496
|
+
notificationId: typeof data.notification_id === "string" ? data.notification_id : null,
|
|
497
|
+
text: typeof response.userText === "string" ? response.userText : null,
|
|
498
|
+
url: typeof data.url === "string" ? data.url : null,
|
|
499
|
+
data
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
var PROMPT_CONTENT = { flexGrow: 1, justifyContent: "center", gap: 16, padding: 24 };
|
|
503
|
+
function PushPromptSheet({ reason, accept, decline, title, body, acceptLabel, declineLabel }) {
|
|
504
|
+
return /* @__PURE__ */ jsx(NativeSheet, { open: true, onOpenChange: (open) => open ? void 0 : decline(), testID: "genesis-push-prompt", children: /* @__PURE__ */ jsxs(View, { style: PROMPT_CONTENT, testID: "genesis-push-prompt-content", children: [
|
|
505
|
+
/* @__PURE__ */ jsx(NativeText, { preset: "h3", accessibilityRole: "header", children: title ?? "Turn on notifications?" }),
|
|
506
|
+
/* @__PURE__ */ jsx(NativeText, { preset: "body", className: "text-muted-foreground", testID: "genesis-push-prompt-body", children: 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.") }),
|
|
507
|
+
/* @__PURE__ */ jsx(NativeButton, { onPress: accept, testID: "genesis-push-prompt-accept", children: acceptLabel ?? "Turn on" }),
|
|
508
|
+
/* @__PURE__ */ jsx(NativeButton, { variant: "ghost", onPress: decline, testID: "genesis-push-prompt-decline", children: declineLabel ?? "Not now" })
|
|
509
|
+
] }) });
|
|
510
|
+
}
|
|
511
|
+
var PUSH_BLOCK_ID = "push";
|
|
512
|
+
var PUSH_STATUS_LABELS = {
|
|
513
|
+
unknown: "Checking\u2026",
|
|
514
|
+
undetermined: "Off",
|
|
515
|
+
granted: "On",
|
|
516
|
+
provisional: "Delivered quietly",
|
|
517
|
+
denied: "Off in Settings",
|
|
518
|
+
unavailable: "Not available on this device"
|
|
519
|
+
};
|
|
520
|
+
var IMPORTANCE = { min: 3, low: 4, default: 5, high: 6, max: 7 };
|
|
521
|
+
var VISIBILITY_PRIVATE = 2;
|
|
522
|
+
function createPush(options) {
|
|
523
|
+
const { notifications, platform, storage, onError, now = Date.now } = options;
|
|
524
|
+
const prefix = options.storageKeyPrefix ?? "genesis:push";
|
|
525
|
+
const categories = options.categories ?? [];
|
|
526
|
+
let userId = null;
|
|
527
|
+
let startedAt = null;
|
|
528
|
+
let channelsMade = null;
|
|
529
|
+
const suppressors = /* @__PURE__ */ new Set();
|
|
530
|
+
const ensureChannels = () => {
|
|
531
|
+
if (!notifications || platform !== "android") return Promise.resolve();
|
|
532
|
+
channelsMade ??= (async () => {
|
|
533
|
+
const all = [
|
|
534
|
+
{ id: "default", name: "Notifications", importance: "default", description: void 0 },
|
|
535
|
+
...categories.map((c) => ({ id: c.id, name: c.title, importance: c.importance ?? "default", description: c.description })),
|
|
536
|
+
...(options.channels ?? []).map((c) => ({ ...c, importance: c.importance ?? "default" }))
|
|
537
|
+
];
|
|
538
|
+
for (const channel of all) {
|
|
539
|
+
await notifications.setNotificationChannelAsync(channel.id, {
|
|
540
|
+
name: channel.name,
|
|
541
|
+
importance: IMPORTANCE[channel.importance],
|
|
542
|
+
description: channel.description ?? null,
|
|
543
|
+
lockscreenVisibility: VISIBILITY_PRIVATE,
|
|
544
|
+
showBadge: true
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
})().catch((error) => {
|
|
548
|
+
channelsMade = null;
|
|
549
|
+
throw error;
|
|
550
|
+
});
|
|
551
|
+
return channelsMade;
|
|
552
|
+
};
|
|
553
|
+
let categoriesMade = false;
|
|
554
|
+
const registerDeviceCategories = async () => {
|
|
555
|
+
const set = notifications?.setNotificationCategoryAsync;
|
|
556
|
+
if (categoriesMade || !notifications || !set || !options.deviceCategories?.length) return;
|
|
557
|
+
categoriesMade = true;
|
|
558
|
+
for (const c of options.deviceCategories) {
|
|
559
|
+
try {
|
|
560
|
+
await set.call(
|
|
561
|
+
notifications,
|
|
562
|
+
c.identifier,
|
|
563
|
+
c.actions.map((a) => ({
|
|
564
|
+
identifier: a.id,
|
|
565
|
+
buttonTitle: a.title,
|
|
566
|
+
...a.textInput ? { textInput: { placeholder: a.textInput.placeholder ?? "", submitButtonTitle: a.textInput.submitTitle ?? a.title } } : {},
|
|
567
|
+
options: { opensAppToForeground: a.opensApp ?? false, isDestructive: a.destructive ?? false, isAuthenticationRequired: a.requiresAuth ?? false }
|
|
568
|
+
})),
|
|
569
|
+
c.previewPlaceholder ? { previewPlaceholder: c.previewPlaceholder } : void 0
|
|
570
|
+
);
|
|
571
|
+
} catch (error) {
|
|
572
|
+
categoriesMade = false;
|
|
573
|
+
report(onError, error, "categories");
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
const permission = createPermissionStore({ notifications, request: options.permissions, beforeRequest: ensureChannels, onError });
|
|
578
|
+
const registration = createRegistration({
|
|
579
|
+
notifications,
|
|
580
|
+
backend: options.backend,
|
|
581
|
+
projectId: options.projectId,
|
|
582
|
+
platform,
|
|
583
|
+
appVersion: options.appVersion,
|
|
584
|
+
storage,
|
|
585
|
+
storageKeyPrefix: prefix,
|
|
586
|
+
ensureChannels,
|
|
587
|
+
isCurrent: (id) => userId === id,
|
|
588
|
+
now,
|
|
589
|
+
randomUUID: options.randomUUID,
|
|
590
|
+
onError
|
|
591
|
+
});
|
|
592
|
+
const preferences = createPreferencesStore({ backend: options.backend, categories, onError });
|
|
593
|
+
const prompt = createPromptStore({
|
|
594
|
+
...options.prompt,
|
|
595
|
+
permission,
|
|
596
|
+
storage,
|
|
597
|
+
storageKey: `${prefix}:prompt`,
|
|
598
|
+
signedIn: () => userId !== null,
|
|
599
|
+
startedAt: () => startedAt,
|
|
600
|
+
now,
|
|
601
|
+
onError
|
|
602
|
+
});
|
|
603
|
+
const badge = {
|
|
604
|
+
async set(count) {
|
|
605
|
+
try {
|
|
606
|
+
await notifications?.setBadgeCountAsync(Math.max(0, Math.floor(count)));
|
|
607
|
+
} catch (error) {
|
|
608
|
+
report(onError, error, "badge");
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
clear: () => badge.set(0)
|
|
612
|
+
};
|
|
613
|
+
let lastStatus = permission.getState().status;
|
|
614
|
+
permission.subscribe(() => {
|
|
615
|
+
const status = permission.getState().status;
|
|
616
|
+
if (status !== lastStatus && lastStatus !== "unknown" && canPush(status) && userId) void registration.sync(userId);
|
|
617
|
+
lastStatus = status;
|
|
618
|
+
});
|
|
619
|
+
const row = options.settingsRow;
|
|
620
|
+
const labels = { ...PUSH_STATUS_LABELS, ...options.statusLabels };
|
|
621
|
+
const declaration = defineBlock({
|
|
622
|
+
id: PUSH_BLOCK_ID,
|
|
623
|
+
platforms: ["native"],
|
|
624
|
+
contributes: {
|
|
625
|
+
settings: row === false ? [] : [
|
|
626
|
+
{
|
|
627
|
+
id: "notifications",
|
|
628
|
+
section: row?.section ?? "notifications",
|
|
629
|
+
title: row?.title ?? "Notifications",
|
|
630
|
+
href: row?.href ?? "/settings/notifications",
|
|
631
|
+
testID: "genesis-push-settings",
|
|
632
|
+
// Bound to this block, so it needs no provider above the Settings screen.
|
|
633
|
+
useSubtitle: () => labels[useSyncExternalStore(permission.subscribe, permission.getState, permission.getState).status]
|
|
634
|
+
}
|
|
635
|
+
],
|
|
636
|
+
routes: [{ path: row === false ? "/settings/notifications" : row?.href ?? "/settings/notifications", description: "Notification settings: categories, quiet hours, previews", signedIn: true }],
|
|
637
|
+
migrations: [
|
|
638
|
+
{ package: "@marktiderman/genesis-supabase", file: "migrations/0005_genesis_push.sql", test: "migrations/tests/0005_genesis_push.test.sql" }
|
|
639
|
+
],
|
|
640
|
+
privacy: {
|
|
641
|
+
collects: [{ type: "device ID", purposes: ["app functionality"], linked: true, tracking: false }],
|
|
642
|
+
notes: [
|
|
643
|
+
"Keeps this device's push token against the signed-in person, to send them notifications; forgotten on sign-out and account deletion.",
|
|
644
|
+
"Keeps notification preferences (categories, quiet hours and time zone, previews) per person."
|
|
645
|
+
]
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
const bootStep = {
|
|
650
|
+
id: "genesis:push",
|
|
651
|
+
// Bounded and never rejects (refresh reports its own errors), so it can't fail or hold the boot.
|
|
652
|
+
run: () => Promise.race([permission.refresh(), delay(1e3)]).then(() => void 0),
|
|
653
|
+
timeoutMs: 3e3
|
|
654
|
+
};
|
|
655
|
+
const block = {
|
|
656
|
+
...declaration,
|
|
657
|
+
bootSteps: [bootStep],
|
|
658
|
+
options,
|
|
659
|
+
permission,
|
|
660
|
+
registration,
|
|
661
|
+
preferences,
|
|
662
|
+
prompt,
|
|
663
|
+
categories,
|
|
664
|
+
badge,
|
|
665
|
+
ensureChannels,
|
|
666
|
+
setUser(next) {
|
|
667
|
+
if (next === userId) return;
|
|
668
|
+
const previous = userId;
|
|
669
|
+
userId = next;
|
|
670
|
+
if (previous !== null) {
|
|
671
|
+
void registration.forget();
|
|
672
|
+
void badge.clear();
|
|
673
|
+
preferences.reset();
|
|
674
|
+
prompt.hide();
|
|
675
|
+
}
|
|
676
|
+
if (next !== null) {
|
|
677
|
+
void preferences.load(next);
|
|
678
|
+
void permission.refresh().then((status) => {
|
|
679
|
+
if (canPush(status) && userId === next) void registration.sync(next);
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
},
|
|
683
|
+
suppress(match) {
|
|
684
|
+
suppressors.add(match);
|
|
685
|
+
return () => {
|
|
686
|
+
suppressors.delete(match);
|
|
687
|
+
};
|
|
688
|
+
},
|
|
689
|
+
start() {
|
|
690
|
+
startedAt = now();
|
|
691
|
+
if (!notifications) return () => {
|
|
692
|
+
};
|
|
693
|
+
notifications.setNotificationHandler({
|
|
694
|
+
handleNotification: async (notification) => {
|
|
695
|
+
let show = true;
|
|
696
|
+
try {
|
|
697
|
+
const data = notificationData(notification);
|
|
698
|
+
show = !userId ? false : ![...suppressors].some((match) => match(data, notification)) && (options.foreground?.(notification) ?? true);
|
|
699
|
+
} catch (error) {
|
|
700
|
+
report(onError, error, "routing");
|
|
701
|
+
}
|
|
702
|
+
return { shouldShowBanner: show, shouldShowList: show, shouldPlaySound: show, shouldSetBadge: show };
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
void registration.flush();
|
|
706
|
+
void permission.refresh();
|
|
707
|
+
void registerDeviceCategories();
|
|
708
|
+
let deviceToken;
|
|
709
|
+
const tokenSubscription = notifications.addPushTokenListener((token) => {
|
|
710
|
+
const next = token?.data;
|
|
711
|
+
const seen = deviceToken;
|
|
712
|
+
deviceToken = next;
|
|
713
|
+
if (!userId || next === seen) return;
|
|
714
|
+
void registration.sync(userId, { force: seen !== void 0 });
|
|
715
|
+
});
|
|
716
|
+
const appSubscription = options.appState?.addEventListener("change", (state) => {
|
|
717
|
+
if (state !== "active") return;
|
|
718
|
+
void registration.flush();
|
|
719
|
+
void permission.refresh().then((status) => {
|
|
720
|
+
if (canPush(status) && userId) void registration.sync(userId);
|
|
721
|
+
});
|
|
722
|
+
if (options.clearBadgeOnForeground && userId) void badge.clear();
|
|
723
|
+
});
|
|
724
|
+
return () => {
|
|
725
|
+
startedAt = null;
|
|
726
|
+
notifications.setNotificationHandler(null);
|
|
727
|
+
tokenSubscription.remove();
|
|
728
|
+
appSubscription?.remove();
|
|
729
|
+
};
|
|
730
|
+
},
|
|
731
|
+
Root: function PushBlockRoot({ slots, slotProps, children }) {
|
|
732
|
+
return /* @__PURE__ */ jsx(PushRoot, { block, slots, slotProps, children });
|
|
733
|
+
}
|
|
734
|
+
};
|
|
735
|
+
return block;
|
|
736
|
+
}
|
|
737
|
+
var PushContext = createContext(null);
|
|
738
|
+
function PushProvider({ block, children }) {
|
|
739
|
+
return /* @__PURE__ */ jsx(PushContext.Provider, { value: block, children });
|
|
740
|
+
}
|
|
741
|
+
function usePush() {
|
|
742
|
+
const block = useContext(PushContext);
|
|
743
|
+
if (!block) throw new Error("usePush: no push block. Put `createPush(\u2026)` in GenesisNativeApp's `blocks`, or wrap in <PushProvider>.");
|
|
744
|
+
return block;
|
|
745
|
+
}
|
|
746
|
+
function useOptionalPush() {
|
|
747
|
+
return useContext(PushContext);
|
|
748
|
+
}
|
|
749
|
+
function usePushPermissionState() {
|
|
750
|
+
const { permission } = usePush();
|
|
751
|
+
return useSyncExternalStore(permission.subscribe, permission.getState, permission.getState);
|
|
752
|
+
}
|
|
753
|
+
function usePushRegistration() {
|
|
754
|
+
const { registration } = usePush();
|
|
755
|
+
return useSyncExternalStore(registration.subscribe, registration.getState, registration.getState);
|
|
756
|
+
}
|
|
757
|
+
function useNotificationPreferences() {
|
|
758
|
+
const { preferences } = usePush();
|
|
759
|
+
const state = useSyncExternalStore(preferences.subscribe, preferences.getState, preferences.getState);
|
|
760
|
+
return { ...state, update: preferences.update };
|
|
761
|
+
}
|
|
762
|
+
function usePushPrompt() {
|
|
763
|
+
const { prompt } = usePush();
|
|
764
|
+
const state = useSyncExternalStore(prompt.subscribe, prompt.getState, prompt.getState);
|
|
765
|
+
return { ...state, offer: prompt.offer, accept: prompt.accept, decline: prompt.decline };
|
|
766
|
+
}
|
|
767
|
+
function useBadge() {
|
|
768
|
+
return usePush().badge;
|
|
769
|
+
}
|
|
770
|
+
function useSuppressNotifications(match) {
|
|
771
|
+
const block = useOptionalPush();
|
|
772
|
+
const latest = useRef(match);
|
|
773
|
+
latest.current = match;
|
|
774
|
+
const active = match !== null;
|
|
775
|
+
useEffect(() => block && active ? block.suppress((data) => latest.current?.(data) ?? false) : void 0, [block, active]);
|
|
776
|
+
}
|
|
777
|
+
function PushRoot({ block, hold, onOpen, slots, slotProps, children }) {
|
|
778
|
+
const { status, userId } = useSession();
|
|
779
|
+
useEffect(() => block.start(), [block]);
|
|
780
|
+
useEffect(() => {
|
|
781
|
+
if (status === "loading") return;
|
|
782
|
+
block.setUser(status === "signedIn" ? userId : null);
|
|
783
|
+
}, [block, status, userId]);
|
|
784
|
+
useTapRouting(block, status, hold, onOpen);
|
|
785
|
+
return /* @__PURE__ */ jsx(PushProvider, { block, children: /* @__PURE__ */ jsxs(View, { style: { flex: 1 }, children: [
|
|
786
|
+
children,
|
|
787
|
+
/* @__PURE__ */ jsx(PushPromptPart, { slots, slotProps })
|
|
788
|
+
] }) });
|
|
789
|
+
}
|
|
790
|
+
function PushPromptPart({ slots, slotProps }) {
|
|
791
|
+
const prompt = usePushPrompt();
|
|
792
|
+
if (!prompt.visible) return null;
|
|
793
|
+
return renderSlot(
|
|
794
|
+
PushPromptSheet,
|
|
795
|
+
slots?.pushPrompt,
|
|
796
|
+
{ reason: prompt.reason, accept: () => void prompt.accept(), decline: () => void prompt.decline() },
|
|
797
|
+
slotProps?.pushPrompt
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
function useTapRouting(block, status, hold, onOpen) {
|
|
801
|
+
const auth = useRef(status);
|
|
802
|
+
auth.current = status;
|
|
803
|
+
const latest = useRef({ hold, onOpen });
|
|
804
|
+
latest.current = { hold, onOpen };
|
|
805
|
+
const waiting = useRef(null);
|
|
806
|
+
const follow = (path) => {
|
|
807
|
+
const open = block.options.onOpen ?? latest.current.onOpen;
|
|
808
|
+
if (auth.current === "signedIn") {
|
|
809
|
+
if (!open) {
|
|
810
|
+
report(block.options.onError, new Error("push: a tapped notification has nowhere to go; pass `onOpen` to createPush (router.push)."), "setup");
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
setTimeout(() => {
|
|
814
|
+
try {
|
|
815
|
+
open(path);
|
|
816
|
+
} catch (error) {
|
|
817
|
+
report(block.options.onError, error, "routing");
|
|
818
|
+
}
|
|
819
|
+
}, 0);
|
|
820
|
+
} else if (auth.current === "signedOut") {
|
|
821
|
+
const hold2 = block.options.hold ?? latest.current.hold;
|
|
822
|
+
if (hold2) hold2.hold(path);
|
|
823
|
+
else report(block.options.onError, new Error("push: a notification tapped while signed out was dropped; pass `hold` (your linkHold) to createPush."), "setup");
|
|
824
|
+
} else {
|
|
825
|
+
waiting.current = path;
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
useEffect(() => {
|
|
829
|
+
if (status === "loading" || waiting.current === null) return;
|
|
830
|
+
const path = waiting.current;
|
|
831
|
+
waiting.current = null;
|
|
832
|
+
follow(path);
|
|
833
|
+
}, [status]);
|
|
834
|
+
useEffect(() => {
|
|
835
|
+
const notifications = block.options.notifications;
|
|
836
|
+
if (!notifications) return;
|
|
837
|
+
const seen = /* @__PURE__ */ new Set();
|
|
838
|
+
const handle = (response) => {
|
|
839
|
+
if (!response) return;
|
|
840
|
+
try {
|
|
841
|
+
const id = response.notification.request.identifier;
|
|
842
|
+
if (seen.has(id)) return;
|
|
843
|
+
seen.add(id);
|
|
844
|
+
if (response.actionIdentifier === notifications.DEFAULT_ACTION_IDENTIFIER) {
|
|
845
|
+
const path = pathForResponse(response, block.options.links);
|
|
846
|
+
if (path) follow(path);
|
|
847
|
+
}
|
|
848
|
+
block.options.onResponse?.(response);
|
|
849
|
+
} catch (error) {
|
|
850
|
+
report(block.options.onError, error, "routing");
|
|
851
|
+
}
|
|
852
|
+
};
|
|
853
|
+
try {
|
|
854
|
+
handle(notifications.getLastNotificationResponse());
|
|
855
|
+
notifications.clearLastNotificationResponse();
|
|
856
|
+
} catch (error) {
|
|
857
|
+
report(block.options.onError, error, "routing");
|
|
858
|
+
}
|
|
859
|
+
const subscription = notifications.addNotificationResponseReceivedListener(handle);
|
|
860
|
+
return () => subscription.remove();
|
|
861
|
+
}, [block]);
|
|
862
|
+
}
|
|
863
|
+
var notificationSettingsCopy = {
|
|
864
|
+
permissionSection: "Notifications",
|
|
865
|
+
allow: "Allow notifications",
|
|
866
|
+
status: PUSH_STATUS_LABELS,
|
|
867
|
+
turnOn: "Turn on",
|
|
868
|
+
openSettings: "Open Settings",
|
|
869
|
+
noProjectId: "This build can't get notifications: it has no EAS project id.",
|
|
870
|
+
noToken: "This phone didn't give the app a push token. Try again on a device with a network connection.",
|
|
871
|
+
registerFailed: "Couldn't turn notifications on for this phone. They'll be tried again next time you open the app.",
|
|
872
|
+
categoriesSection: "Notify me about",
|
|
873
|
+
categoriesFootnote: "Applies to every phone you're signed in on.",
|
|
874
|
+
quietHoursSection: "Quiet hours",
|
|
875
|
+
quietHours: "Quiet hours",
|
|
876
|
+
quietHoursOnNow: "On now",
|
|
877
|
+
from: "From",
|
|
878
|
+
to: "To",
|
|
879
|
+
timeZone: "Time zone",
|
|
880
|
+
useDeviceTimeZone: "Tap to use this phone's",
|
|
881
|
+
noTimeZone: "This phone didn't say its time zone, so quiet hours can't be placed in your day.",
|
|
882
|
+
quietHoursFootnote: "Notifications that arrive during quiet hours wait until they end. Urgent ones still come through.",
|
|
883
|
+
privacySection: "Privacy",
|
|
884
|
+
showPreviews: "Show previews",
|
|
885
|
+
previewsFootnote: "When off, notifications say only that there's something new; what they say stays on the server, off your lock screen.",
|
|
886
|
+
saveFailed: "Couldn't save that change. Check your connection and try again.",
|
|
887
|
+
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."
|
|
888
|
+
};
|
|
889
|
+
var DEFAULT_COPY = notificationSettingsCopy;
|
|
890
|
+
function usePushPermission(copy = DEFAULT_COPY) {
|
|
891
|
+
const block = usePush();
|
|
892
|
+
const { status, requesting } = usePushPermissionState();
|
|
893
|
+
const { problem } = usePushRegistration();
|
|
894
|
+
const open = block.options.openSettings;
|
|
895
|
+
return {
|
|
896
|
+
status,
|
|
897
|
+
requesting,
|
|
898
|
+
request: () => void block.permission.request(),
|
|
899
|
+
openSettings: open ? () => {
|
|
900
|
+
Promise.resolve().then(open).catch((error) => block.options.onError?.(error instanceof Error ? error : new Error("openSettings failed"), "permission"));
|
|
901
|
+
} : null,
|
|
902
|
+
problem: problem === "no_project_id" ? copy.noProjectId : problem === "no_token" ? copy.noToken : problem === "backend" ? copy.registerFailed : null,
|
|
903
|
+
copy
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
function useNotificationCategories(copy = DEFAULT_COPY) {
|
|
907
|
+
const block = usePush();
|
|
908
|
+
const { preferences, saving, update, status } = useNotificationPreferences();
|
|
909
|
+
const categories = useMemo(
|
|
910
|
+
() => block.categories.map((c) => ({
|
|
911
|
+
id: c.id,
|
|
912
|
+
title: c.title,
|
|
913
|
+
description: c.description,
|
|
914
|
+
on: typeof preferences.categories[c.id] === "boolean" ? preferences.categories[c.id] : c.defaultOn ?? true
|
|
915
|
+
})),
|
|
916
|
+
[block.categories, preferences.categories]
|
|
917
|
+
);
|
|
918
|
+
return {
|
|
919
|
+
categories,
|
|
920
|
+
setOn: (id, on) => void update((p) => ({ ...p, categories: { ...p.categories, [id]: on } })),
|
|
921
|
+
saving,
|
|
922
|
+
disabled: status !== "ready",
|
|
923
|
+
copy
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
function useQuietHours(copy = DEFAULT_COPY) {
|
|
927
|
+
const { preferences, saving, update, status } = useNotificationPreferences();
|
|
928
|
+
const q = preferences.quietHours;
|
|
929
|
+
const device = deviceTimeZone();
|
|
930
|
+
const minute = useMinute();
|
|
931
|
+
const setQuiet = (change) => void update((p) => ({ ...p, quietHours: { ...p.quietHours, ...change } }));
|
|
932
|
+
return {
|
|
933
|
+
enabled: q.enabled,
|
|
934
|
+
start: q.start,
|
|
935
|
+
end: q.end,
|
|
936
|
+
timeZone: q.timeZone,
|
|
937
|
+
deviceTimeZone: device && device !== q.timeZone ? device : null,
|
|
938
|
+
activeNow: isQuietAt(q, new Date(minute)),
|
|
939
|
+
// Turning quiet hours on fills in this phone's zone if none was saved.
|
|
940
|
+
setEnabled: (on) => setQuiet(on ? { enabled: true, timeZone: q.timeZone ?? device } : { enabled: false }),
|
|
941
|
+
setStart: (start) => setQuiet({ start }),
|
|
942
|
+
setEnd: (end) => setQuiet({ end }),
|
|
943
|
+
adoptDeviceTimeZone: () => device ? setQuiet({ timeZone: device }) : void 0,
|
|
944
|
+
saving,
|
|
945
|
+
disabled: status !== "ready",
|
|
946
|
+
copy
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
function usePrivacyPreviews(copy = DEFAULT_COPY) {
|
|
950
|
+
const { preferences, saving, update, status } = useNotificationPreferences();
|
|
951
|
+
return {
|
|
952
|
+
hidePreviews: preferences.hidePreviews,
|
|
953
|
+
setHidePreviews: (hidePreviews) => void update((p) => ({ ...p, hidePreviews })),
|
|
954
|
+
saving,
|
|
955
|
+
disabled: status !== "ready",
|
|
956
|
+
copy
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
var minuteStore = {
|
|
960
|
+
subscribe(listener) {
|
|
961
|
+
const timer = setInterval(listener, 3e4);
|
|
962
|
+
return () => clearInterval(timer);
|
|
963
|
+
},
|
|
964
|
+
get: () => Math.floor(Date.now() / 6e4) * 6e4
|
|
965
|
+
};
|
|
966
|
+
function useMinute() {
|
|
967
|
+
return useSyncExternalStore(minuteStore.subscribe, minuteStore.get, minuteStore.get);
|
|
968
|
+
}
|
|
969
|
+
function toggle(id, label, checked, onChange, disabled = false) {
|
|
970
|
+
return /* @__PURE__ */ jsx(
|
|
971
|
+
NativeSwitch,
|
|
972
|
+
{
|
|
973
|
+
testID: `notification-settings-${id}-switch`,
|
|
974
|
+
accessibilityLabel: label,
|
|
975
|
+
checked,
|
|
976
|
+
onCheckedChange: onChange,
|
|
977
|
+
disabled
|
|
978
|
+
}
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
function permissionRows(p) {
|
|
982
|
+
const { status, copy } = p;
|
|
983
|
+
const row = { id: "allow", title: copy.allow, subtitle: copy.status[status], testID: "notification-settings-allow" };
|
|
984
|
+
if (status === "undetermined") {
|
|
985
|
+
return [{ ...row, showChevron: true, onPress: p.requesting ? void 0 : p.request, disabled: p.requesting, subtitle: `${copy.status.undetermined} \xB7 ${copy.turnOn}` }];
|
|
986
|
+
}
|
|
987
|
+
if (status === "denied" && p.openSettings) {
|
|
988
|
+
return [{ ...row, showChevron: true, onPress: p.openSettings, subtitle: `${copy.status.denied} \xB7 ${copy.openSettings}` }];
|
|
989
|
+
}
|
|
990
|
+
return [row];
|
|
991
|
+
}
|
|
992
|
+
function categoryRows(p) {
|
|
993
|
+
return p.categories.map((c) => ({
|
|
994
|
+
id: `category-${c.id}`,
|
|
995
|
+
title: c.title,
|
|
996
|
+
subtitle: c.description,
|
|
997
|
+
testID: `notification-settings-category-${c.id}`,
|
|
998
|
+
trailing: toggle(`category-${c.id}`, c.title, c.on, (on) => p.setOn(c.id, on), p.disabled)
|
|
999
|
+
}));
|
|
1000
|
+
}
|
|
1001
|
+
function quietHoursRows(p) {
|
|
1002
|
+
const { copy } = p;
|
|
1003
|
+
const rows = [
|
|
1004
|
+
{
|
|
1005
|
+
id: "quiet-hours",
|
|
1006
|
+
title: copy.quietHours,
|
|
1007
|
+
subtitle: p.enabled && p.activeNow ? copy.quietHoursOnNow : void 0,
|
|
1008
|
+
testID: "notification-settings-quiet-hours",
|
|
1009
|
+
trailing: toggle("quiet-hours", copy.quietHours, p.enabled, p.setEnabled, p.disabled || !p.enabled && !p.timeZone && !p.deviceTimeZone)
|
|
1010
|
+
}
|
|
1011
|
+
];
|
|
1012
|
+
if (!p.enabled) return rows;
|
|
1013
|
+
const picker = (id, label, value, set) => /* @__PURE__ */ jsx(NativeTimePicker, { testID: `notification-settings-${id}-time`, value: dateAt(value), onChange: (d) => set(clockOf(d)), formatLabel: () => value, disabled: p.disabled });
|
|
1014
|
+
rows.push(
|
|
1015
|
+
{ id: "quiet-from", title: copy.from, testID: "notification-settings-quiet-from", trailing: picker("quiet-from", copy.from, p.start, p.setStart) },
|
|
1016
|
+
{ id: "quiet-to", title: copy.to, testID: "notification-settings-quiet-to", trailing: picker("quiet-to", copy.to, p.end, p.setEnd) },
|
|
1017
|
+
{
|
|
1018
|
+
id: "time-zone",
|
|
1019
|
+
title: copy.timeZone,
|
|
1020
|
+
subtitle: p.timeZone ? p.deviceTimeZone ? `${p.timeZone} \xB7 ${copy.useDeviceTimeZone} (${p.deviceTimeZone})` : p.timeZone : copy.noTimeZone,
|
|
1021
|
+
testID: "notification-settings-time-zone",
|
|
1022
|
+
...p.deviceTimeZone && !p.disabled ? { onPress: p.adoptDeviceTimeZone, showChevron: true } : {}
|
|
1023
|
+
}
|
|
1024
|
+
);
|
|
1025
|
+
return rows;
|
|
1026
|
+
}
|
|
1027
|
+
function privacyRows(p) {
|
|
1028
|
+
return [
|
|
1029
|
+
{
|
|
1030
|
+
id: "previews",
|
|
1031
|
+
title: p.copy.showPreviews,
|
|
1032
|
+
testID: "notification-settings-previews",
|
|
1033
|
+
trailing: toggle("previews", p.copy.showPreviews, !p.hidePreviews, (show) => p.setHidePreviews(!show), p.disabled)
|
|
1034
|
+
}
|
|
1035
|
+
];
|
|
1036
|
+
}
|
|
1037
|
+
function section(id, title, footnote, props, rows, slot, extra) {
|
|
1038
|
+
if (slot === null) return null;
|
|
1039
|
+
const merged = { ...props, ...extra };
|
|
1040
|
+
if (slot) {
|
|
1041
|
+
const Part = slot;
|
|
1042
|
+
return { id, title, footnote, rows: [], children: /* @__PURE__ */ jsx(Part, { ...merged }) };
|
|
1043
|
+
}
|
|
1044
|
+
return { id, title, footnote, rows: rows(merged) };
|
|
1045
|
+
}
|
|
1046
|
+
function NotificationSettings({ copy: overrides, slots = {}, slotProps = {}, page, children, testID = "notification-settings" }) {
|
|
1047
|
+
const copy = useMemo(() => ({ ...DEFAULT_COPY, ...overrides, status: { ...DEFAULT_COPY.status, ...overrides?.status } }), [overrides]);
|
|
1048
|
+
const block = usePush();
|
|
1049
|
+
const permission = usePushPermission(copy);
|
|
1050
|
+
const categories = useNotificationCategories(copy);
|
|
1051
|
+
const quietHours = useQuietHours(copy);
|
|
1052
|
+
const privacy = usePrivacyPreviews(copy);
|
|
1053
|
+
const { error, status } = useNotificationPreferences();
|
|
1054
|
+
const { userId } = useSession();
|
|
1055
|
+
useEffect(() => {
|
|
1056
|
+
if (block.preferences.getState().status === "error" && userId) void block.preferences.load(userId);
|
|
1057
|
+
}, [block, userId]);
|
|
1058
|
+
const sections = [
|
|
1059
|
+
section("permission", copy.permissionSection, permission.problem ?? (status === "error" ? copy.loadFailed : error ? copy.saveFailed : void 0), permission, permissionRows, slots.permission, slotProps.permission),
|
|
1060
|
+
block.categories.length > 0 ? section("categories", copy.categoriesSection, copy.categoriesFootnote, categories, categoryRows, slots.categories, slotProps.categories) : null,
|
|
1061
|
+
section("quiet-hours", copy.quietHoursSection, copy.quietHoursFootnote, quietHours, quietHoursRows, slots.quietHours, slotProps.quietHours),
|
|
1062
|
+
section("privacy", copy.privacySection, copy.previewsFootnote, privacy, privacyRows, slots.privacy, slotProps.privacy)
|
|
1063
|
+
].filter((s) => s !== null);
|
|
1064
|
+
return /* @__PURE__ */ jsx(SettingsPage, { testID, ...page, sections, children });
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
export { DEFAULT_PREFERENCES, NotificationSettings, PushBackendError, PushPromptSheet, PushProvider, PushRoot, canPush, categoryRows, clockOf, createPermissionStore, createPreferencesStore, createPromptStore, createPush, createRegistration, dateAt, deviceTimeZone, formatClock, isQuietAt, minutesIn, notificationAction, notificationData, notificationSettingsCopy, parseClock, pathForResponse, permissionRows, privacyRows, quietHoursRows, statusOf, supabasePushBackend, useBadge, useNotificationCategories, useNotificationPreferences, useOptionalPush, usePrivacyPreviews, usePush, usePushPermission, usePushPermissionState, usePushPrompt, usePushRegistration, useQuietHours, useSuppressNotifications, validPreferences };
|
|
1068
|
+
//# sourceMappingURL=push.js.map
|
|
1069
|
+
//# sourceMappingURL=push.js.map
|