@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.
Files changed (87) hide show
  1. package/README.md +354 -10
  2. package/dist/block-C5-noMhB.d.ts +223 -0
  3. package/dist/blocks-a1YQ1Elf.d.ts +1540 -0
  4. package/dist/chunk-65P7RU4V.js +49 -0
  5. package/dist/chunk-65P7RU4V.js.map +1 -0
  6. package/dist/chunk-6BT4PTGH.js +37 -0
  7. package/dist/chunk-6BT4PTGH.js.map +1 -0
  8. package/dist/chunk-75YP72G6.js +374 -0
  9. package/dist/chunk-75YP72G6.js.map +1 -0
  10. package/dist/chunk-NGYNYJPL.js +90 -0
  11. package/dist/chunk-NGYNYJPL.js.map +1 -0
  12. package/dist/chunk-VKTIHNAV.js +95 -0
  13. package/dist/chunk-VKTIHNAV.js.map +1 -0
  14. package/dist/{chunk-MMTPWUWI.js → chunk-VTJDIKFC.js} +9 -6
  15. package/dist/chunk-VTJDIKFC.js.map +1 -0
  16. package/dist/chunk-XE7LC7K6.js +29 -0
  17. package/dist/chunk-XE7LC7K6.js.map +1 -0
  18. package/dist/dev-warnings.d.ts +34 -0
  19. package/dist/dev-warnings.js +15 -0
  20. package/dist/dev-warnings.js.map +1 -0
  21. package/dist/devtools.d.ts +144 -0
  22. package/dist/devtools.js +390 -0
  23. package/dist/devtools.js.map +1 -0
  24. package/dist/index.d.ts +12 -368
  25. package/dist/index.js +88 -191
  26. package/dist/index.js.map +1 -1
  27. package/dist/observability.d.ts +170 -0
  28. package/dist/observability.js +123 -0
  29. package/dist/observability.js.map +1 -0
  30. package/dist/persist.d.ts +1 -1
  31. package/dist/persist.js +1 -1
  32. package/dist/push.d.ts +169 -0
  33. package/dist/push.js +996 -0
  34. package/dist/push.js.map +1 -0
  35. package/dist/{query-DhH3lLFa.d.ts → query-DUmVAfsF.d.ts} +11 -6
  36. package/dist/updates.d.ts +8 -1
  37. package/dist/updates.js +44 -69
  38. package/dist/updates.js.map +1 -1
  39. package/package.json +222 -5
  40. package/src/app.tsx +491 -0
  41. package/src/auth.tsx +176 -0
  42. package/src/blocks.tsx +144 -0
  43. package/src/boot.tsx +188 -0
  44. package/src/dev-warnings.ts +43 -0
  45. package/src/devtools/block.tsx +488 -0
  46. package/src/devtools/gestures.ts +107 -0
  47. package/src/devtools/index.ts +22 -0
  48. package/src/devtools/info.ts +45 -0
  49. package/src/devtools/slots.ts +21 -0
  50. package/src/errors.tsx +195 -0
  51. package/src/held-link.ts +90 -0
  52. package/src/hydration-gate.ts +88 -0
  53. package/src/index.ts +83 -0
  54. package/src/links.ts +140 -0
  55. package/src/nativewind.d.ts +41 -0
  56. package/src/observability/block.tsx +300 -0
  57. package/src/observability/events.ts +89 -0
  58. package/src/observability/index.ts +44 -0
  59. package/src/observability/scrub.ts +166 -0
  60. package/src/observability/sentry.ts +222 -0
  61. package/src/observability/sink.ts +147 -0
  62. package/src/persist.ts +83 -0
  63. package/src/push/backend.ts +129 -0
  64. package/src/push/block.tsx +545 -0
  65. package/src/push/index.ts +102 -0
  66. package/src/push/modules.ts +111 -0
  67. package/src/push/permission.ts +191 -0
  68. package/src/push/preferences.ts +136 -0
  69. package/src/push/quiet-hours.ts +71 -0
  70. package/src/push/registration.ts +189 -0
  71. package/src/push/routing.ts +27 -0
  72. package/src/push/screens.tsx +46 -0
  73. package/src/push/settings.tsx +378 -0
  74. package/src/query.ts +100 -0
  75. package/src/updates/block.tsx +442 -0
  76. package/src/updates/channels.ts +131 -0
  77. package/src/updates/checker.ts +190 -0
  78. package/src/updates/index.ts +87 -0
  79. package/src/updates/modules.ts +64 -0
  80. package/src/updates/policy.ts +314 -0
  81. package/src/updates/screens.tsx +159 -0
  82. package/src/updates/signature.ts +80 -0
  83. package/src/updates/store.ts +33 -0
  84. package/src/updates/version.ts +39 -0
  85. package/src/updates/whats-new.ts +122 -0
  86. package/dist/block-Dh5ny06W.d.ts +0 -535
  87. package/dist/chunk-MMTPWUWI.js.map +0 -1
@@ -0,0 +1,545 @@
1
+ /**
2
+ * `createPush(options)`: the push block. Pass the result to
3
+ * `<GenesisNativeApp blocks={[push]}>` (removing it turns the block off), or
4
+ * render `<PushRoot block={push}>` yourself, inside the session provider.
5
+ *
6
+ * It is a `defineBlock` declaration (`id: "push"`): it contributes a Settings
7
+ * row for the notification settings (`useBlockSettings`, href
8
+ * `/settings/notifications`), a boot step that reads the permission behind
9
+ * the splash (bounded, never fails), and names migration 0005.
10
+ *
11
+ * PushRoot
12
+ * ├ follows the session: registers this device's token on sign-in (only
13
+ * │ once permission is granted), forgets it on sign-out, clears the badge
14
+ * ├ routes a tapped notification (the one that launched the app too)
15
+ * │ through normalizeLink; signed out, the link is held until sign-in
16
+ * ├ decides what shows while the app is open (the foreground handler)
17
+ * ├ your screens
18
+ * └ pushPrompt (the soft pre-prompt, only after `usePushPrompt().offer()`)
19
+ *
20
+ * The block never asks for permission by itself, and never at launch.
21
+ * Every part is a slot or a primitive, and a hook (`usePushPermission`, …).
22
+ */
23
+ import { createContext, useContext, useEffect, useRef, useSyncExternalStore, type ComponentType, type ReactNode } from "react";
24
+ import { View } from "react-native";
25
+ import { renderSlot, type SlotPropsFor, type Slots } from "@marktiderman/genesis-core/slots";
26
+ import { defineBlock } from "@marktiderman/genesis-features/blocks";
27
+ import { useSession } from "../auth";
28
+ import type { NativeBlock, NativeBlockRootProps } from "../blocks";
29
+ import type { BootStep } from "../boot";
30
+ import { delay } from "../updates/store";
31
+ import type { LinkHold } from "../held-link";
32
+ import type { LinkOptions } from "../links";
33
+ import type { NotificationPreferences, PushBackend } from "./backend";
34
+ import {
35
+ report,
36
+ type NotificationLike,
37
+ type NotificationResponseLike,
38
+ type NotificationsModuleLike,
39
+ type PermissionRequestLike,
40
+ type PushAppStateLike,
41
+ type PushOnError,
42
+ type PushStorageLike,
43
+ } from "./modules";
44
+ import {
45
+ canPush,
46
+ createPermissionStore,
47
+ createPromptStore,
48
+ type PermissionState,
49
+ type PermissionStore,
50
+ type PromptOptions,
51
+ type PromptState,
52
+ type PromptStore,
53
+ type PushPermissionStatus,
54
+ } from "./permission";
55
+ import { createPreferencesStore, type ChannelImportance, type NotificationCategory, type PreferencesState, type PreferencesStore } from "./preferences";
56
+ import { createRegistration, type Registration, type RegistrationState } from "./registration";
57
+ import { notificationData, pathForResponse } from "./routing";
58
+ import { PushPromptSheet, type PushPromptProps } from "./screens";
59
+
60
+ /** An Android channel beyond the categories' own (one per category is made for you). */
61
+ export interface PushChannel {
62
+ id: string;
63
+ name: string;
64
+ description?: string;
65
+ importance?: ChannelImportance;
66
+ }
67
+
68
+ // A type, not an interface, so it fits the slots contract's Record constraint.
69
+ export type PushSlots = {
70
+ /** The soft pre-prompt, shown after `usePushPrompt().offer()`. Default `PushPromptSheet`. */
71
+ pushPrompt: PushPromptProps;
72
+ };
73
+
74
+ export interface PushOptions {
75
+ /** `import * as Notifications from "expo-notifications"`. Without it the block does nothing (web, tests). */
76
+ notifications?: NotificationsModuleLike;
77
+ /** `Platform.OS`. */
78
+ platform: string;
79
+ /** The EAS project id (`Constants.expoConfig?.extra?.eas?.projectId`). Without it there is no push token; the block says so once through `onError`. */
80
+ projectId?: string | null;
81
+ /** The installed version, kept with the device row (`Application.nativeApplicationVersion`). */
82
+ appVersion?: string | null;
83
+ /** Devices and preferences: `supabasePushBackend(supabase)`. */
84
+ backend: PushBackend;
85
+ /** The kinds of notification the app sends; each is a toggle in NotificationSettings and an Android channel. */
86
+ categories?: readonly NotificationCategory[];
87
+ /** More Android channels. A `default` channel is always made. */
88
+ channels?: readonly PushChannel[];
89
+ /** AsyncStorage: this device's token (to forget it after sign-out), the prompt's "Not now"s. */
90
+ storage?: PushStorageLike;
91
+ /** `AppState`: read the permission again on coming to the front (the person may have changed it in Settings). */
92
+ appState?: PushAppStateLike;
93
+ /** `Linking.openSettings`, for "Turn on in Settings" once the system won't ask again. */
94
+ openSettings?: () => unknown;
95
+ /** Where a tap goes: the app's deep-link options, so a pushed path is normalized like any link. */
96
+ links?: LinkOptions;
97
+ /** Open a tapped notification's path (`router.push`). Without it a tap opens nothing, and says so through `onError`. */
98
+ onOpen?: (path: string) => void;
99
+ /** Where a tap that arrives signed out waits for sign-in: the same `linkHold` as GenesisNativeApp's `links.hold`. */
100
+ hold?: LinkHold;
101
+ /**
102
+ * The Settings row the block contributes (`useBlockSettings`), or `false`
103
+ * for none. Default `{ title: "Notifications", section: "notifications", href: "/settings/notifications" }`;
104
+ * its subtitle is the permission's status (`statusLabels`).
105
+ */
106
+ settingsRow?: false | { title?: string; section?: string; href?: string };
107
+ /** The words for each permission status (the Settings row's subtitle, NotificationSettings' default). */
108
+ statusLabels?: Partial<Record<PushPermissionStatus, string>>;
109
+ /** Every tap and action, after routing (an action button, analytics). */
110
+ onResponse?: (response: NotificationResponseLike) => void;
111
+ /** Show a notification that arrives while the app is open? Default yes; `useSuppressNotifications` adds rules. */
112
+ foreground?: (notification: NotificationLike) => boolean;
113
+ /** The iOS permissions asked for. Default alert, badge and sound. */
114
+ permissions?: PermissionRequestLike;
115
+ prompt?: PromptOptions;
116
+ /** Clear the badge whenever the app comes to the front. Default false (the server sets it). */
117
+ clearBadgeOnForeground?: boolean;
118
+ /** Storage key prefix. Default `"genesis:push"`. */
119
+ storageKeyPrefix?: string;
120
+ /** Every caught error, for reporting; none of them breaks the app. */
121
+ onError?: PushOnError;
122
+ now?: () => number;
123
+ }
124
+
125
+ /** The FeatureId of the push block. */
126
+ export const PUSH_BLOCK_ID = "push";
127
+
128
+ /** The default words for each permission status. */
129
+ export const PUSH_STATUS_LABELS: Record<PushPermissionStatus, string> = {
130
+ unknown: "Checking…",
131
+ undetermined: "Off",
132
+ granted: "On",
133
+ provisional: "Delivered quietly",
134
+ denied: "Off in Settings",
135
+ unavailable: "Not available on this device",
136
+ };
137
+
138
+ export interface PushBlock extends NativeBlock<typeof PUSH_BLOCK_ID> {
139
+ readonly options: PushOptions;
140
+ readonly permission: PermissionStore;
141
+ readonly registration: Registration;
142
+ readonly preferences: PreferencesStore;
143
+ readonly prompt: PromptStore;
144
+ readonly categories: readonly NotificationCategory[];
145
+ readonly badge: { set(count: number): Promise<void>; clear(): Promise<void> };
146
+ /** Follow this signed-in user (null when signed out). `PushRoot` calls it from the session. */
147
+ setUser(userId: string | null): void;
148
+ /** Hide notifications that match while the app is open (the thread on screen); returns the removal. */
149
+ suppress(match: (data: Record<string, unknown>, notification: NotificationLike) => boolean): () => void;
150
+ /** Make the Android channels (once; again after a failure). */
151
+ ensureChannels(): Promise<void>;
152
+ /** Start the foreground handler and the listeners; returns the stop. `PushRoot` calls it. */
153
+ start(): () => void;
154
+ /** Reads the permission behind the splash, at most a second; never fails the boot. */
155
+ readonly bootSteps: readonly BootStep[];
156
+ readonly Root: ComponentType<NativeBlockRootProps>;
157
+ }
158
+
159
+ /** Expo's `AndroidImportance` numbers (checked against expo-notifications 58.1). */
160
+ const IMPORTANCE: Record<ChannelImportance, number> = { min: 3, low: 4, default: 5, high: 6, max: 7 };
161
+ /** `AndroidNotificationVisibility.PRIVATE`: on a secure lock screen, Android shows that there is one, not what it says. */
162
+ const VISIBILITY_PRIVATE = 2;
163
+
164
+ /** Build the block once, at module scope, next to your Supabase client. */
165
+ export function createPush(options: PushOptions): PushBlock {
166
+ const { notifications, platform, storage, onError, now = Date.now } = options;
167
+ const prefix = options.storageKeyPrefix ?? "genesis:push";
168
+ const categories = options.categories ?? [];
169
+ let userId: string | null = null;
170
+ let startedAt: number | null = null;
171
+ let channelsMade: Promise<void> | null = null;
172
+ const suppressors = new Set<(data: Record<string, unknown>, notification: NotificationLike) => boolean>();
173
+
174
+ const ensureChannels = (): Promise<void> => {
175
+ if (!notifications || platform !== "android") return Promise.resolve();
176
+ channelsMade ??= (async () => {
177
+ const all = [
178
+ { id: "default", name: "Notifications", importance: "default" as ChannelImportance, description: undefined as string | undefined },
179
+ ...categories.map((c) => ({ id: c.id, name: c.title, importance: c.importance ?? "default", description: c.description })),
180
+ ...(options.channels ?? []).map((c) => ({ ...c, importance: c.importance ?? "default" })),
181
+ ];
182
+ for (const channel of all) {
183
+ await notifications.setNotificationChannelAsync(channel.id, {
184
+ name: channel.name,
185
+ importance: IMPORTANCE[channel.importance],
186
+ description: channel.description ?? null,
187
+ lockscreenVisibility: VISIBILITY_PRIVATE,
188
+ showBadge: true,
189
+ });
190
+ }
191
+ })().catch((error) => {
192
+ channelsMade = null;
193
+ throw error;
194
+ });
195
+ return channelsMade;
196
+ };
197
+
198
+ const permission = createPermissionStore({ notifications, request: options.permissions, beforeRequest: ensureChannels, onError });
199
+ const registration = createRegistration({
200
+ notifications,
201
+ backend: options.backend,
202
+ projectId: options.projectId,
203
+ platform,
204
+ appVersion: options.appVersion,
205
+ storage,
206
+ storageKeyPrefix: prefix,
207
+ ensureChannels,
208
+ isCurrent: (id) => userId === id,
209
+ now,
210
+ onError,
211
+ });
212
+ const preferences = createPreferencesStore({ backend: options.backend, categories, onError });
213
+ const prompt = createPromptStore({
214
+ ...options.prompt,
215
+ permission,
216
+ storage,
217
+ storageKey: `${prefix}:prompt`,
218
+ signedIn: () => userId !== null,
219
+ startedAt: () => startedAt,
220
+ now,
221
+ onError,
222
+ });
223
+
224
+ const badge = {
225
+ async set(count: number) {
226
+ try {
227
+ await notifications?.setBadgeCountAsync(Math.max(0, Math.floor(count)));
228
+ } catch (error) {
229
+ report(onError, error, "badge");
230
+ }
231
+ },
232
+ clear: () => badge.set(0),
233
+ };
234
+
235
+ // Granted later (the prompt, Settings, another screen) while signed in: register now. The
236
+ // first read (from "unknown") is left to setUser, which registers after it.
237
+ let lastStatus = permission.getState().status;
238
+ permission.subscribe(() => {
239
+ const status = permission.getState().status;
240
+ if (status !== lastStatus && lastStatus !== "unknown" && canPush(status) && userId) void registration.sync(userId);
241
+ lastStatus = status;
242
+ });
243
+
244
+ const row = options.settingsRow;
245
+ const labels = { ...PUSH_STATUS_LABELS, ...options.statusLabels };
246
+ const declaration = defineBlock<typeof PUSH_BLOCK_ID, ComponentType<never>>({
247
+ id: PUSH_BLOCK_ID,
248
+ platforms: ["native"],
249
+ contributes: {
250
+ settings:
251
+ row === false
252
+ ? []
253
+ : [
254
+ {
255
+ id: "notifications",
256
+ section: row?.section ?? "notifications",
257
+ title: row?.title ?? "Notifications",
258
+ href: row?.href ?? "/settings/notifications",
259
+ testID: "genesis-push-settings",
260
+ // Bound to this block, so it needs no provider above the Settings screen.
261
+ useSubtitle: () => labels[useSyncExternalStore(permission.subscribe, permission.getState, permission.getState).status],
262
+ },
263
+ ],
264
+ routes: [{ path: row === false ? "/settings/notifications" : (row?.href ?? "/settings/notifications"), description: "Notification settings: categories, quiet hours, previews", signedIn: true }],
265
+ migrations: [
266
+ { package: "@marktiderman/genesis-supabase", file: "migrations/0005_genesis_push.sql", test: "migrations/tests/0005_genesis_push.test.sql" },
267
+ ],
268
+ privacy: {
269
+ collects: [{ type: "device ID", purposes: ["app functionality"], linked: true, tracking: false }],
270
+ notes: [
271
+ "Keeps this device's push token against the signed-in person, to send them notifications; forgotten on sign-out and account deletion.",
272
+ "Keeps notification preferences (categories, quiet hours and time zone, previews) per person.",
273
+ ],
274
+ },
275
+ },
276
+ });
277
+ const bootStep: BootStep = {
278
+ id: "genesis:push",
279
+ // Bounded and never rejects (refresh reports its own errors), so it can't fail or hold the boot.
280
+ run: () => Promise.race([permission.refresh(), delay(1000)]).then(() => undefined),
281
+ timeoutMs: 3000,
282
+ };
283
+
284
+ const block: PushBlock = {
285
+ ...declaration,
286
+ bootSteps: [bootStep],
287
+ options,
288
+ permission,
289
+ registration,
290
+ preferences,
291
+ prompt,
292
+ categories,
293
+ badge,
294
+ ensureChannels,
295
+ setUser(next) {
296
+ if (next === userId) return;
297
+ const previous = userId;
298
+ userId = next;
299
+ if (previous !== null) {
300
+ // Signed out or switched: this phone stops getting the last person's pushes, and shows none of their count.
301
+ void registration.forget();
302
+ void badge.clear();
303
+ preferences.reset();
304
+ prompt.hide();
305
+ }
306
+ if (next !== null) {
307
+ void preferences.load(next);
308
+ void permission.refresh().then((status) => {
309
+ if (canPush(status) && userId === next) void registration.sync(next);
310
+ });
311
+ }
312
+ },
313
+ suppress(match) {
314
+ suppressors.add(match);
315
+ return () => {
316
+ suppressors.delete(match);
317
+ };
318
+ },
319
+ start() {
320
+ startedAt = now();
321
+ if (!notifications) return () => {};
322
+ notifications.setNotificationHandler({
323
+ handleNotification: async (notification) => {
324
+ let show = true;
325
+ try {
326
+ const data = notificationData(notification);
327
+ show = !userId ? false : ![...suppressors].some((match) => match(data, notification)) && (options.foreground?.(notification) ?? true);
328
+ } catch (error) {
329
+ report(onError, error, "routing");
330
+ }
331
+ // Nothing of a push that isn't shown, the badge included (it may be the last person's).
332
+ return { shouldShowBanner: show, shouldShowList: show, shouldPlaySound: show, shouldSetBadge: show };
333
+ },
334
+ });
335
+ void registration.flush();
336
+ void permission.refresh();
337
+ const tokenSubscription = notifications.addPushTokenListener(() => {
338
+ // The OS rotated the device token: fetch the Expo token again and register it.
339
+ if (userId) void registration.sync(userId, { force: true });
340
+ });
341
+ const appSubscription = options.appState?.addEventListener("change", (state) => {
342
+ if (state !== "active") return;
343
+ // Back in front: a forget that failed offline goes now, and this phone's registration is
344
+ // confirmed (it may have failed, or another login may have taken the token since).
345
+ void registration.flush();
346
+ void permission.refresh().then((status) => {
347
+ if (canPush(status) && userId) void registration.sync(userId);
348
+ });
349
+ if (options.clearBadgeOnForeground && userId) void badge.clear();
350
+ });
351
+ return () => {
352
+ startedAt = null;
353
+ notifications.setNotificationHandler(null);
354
+ tokenSubscription.remove();
355
+ appSubscription?.remove();
356
+ };
357
+ },
358
+ Root: function PushBlockRoot({ slots, slotProps, children }: NativeBlockRootProps) {
359
+ return (
360
+ <PushRoot block={block} slots={slots} slotProps={slotProps}>
361
+ {children}
362
+ </PushRoot>
363
+ );
364
+ },
365
+ };
366
+ return block;
367
+ }
368
+
369
+ const PushContext = createContext<PushBlock | null>(null);
370
+
371
+ /** Provides the block to the hooks, without rendering any of its parts (rung 4). */
372
+ export function PushProvider({ block, children }: { block: PushBlock; children?: ReactNode }) {
373
+ return <PushContext.Provider value={block}>{children}</PushContext.Provider>;
374
+ }
375
+
376
+ /** The block from `PushProvider` / `PushRoot` / `GenesisNativeApp`. Throws outside one. */
377
+ export function usePush(): PushBlock {
378
+ const block = useContext(PushContext);
379
+ if (!block) throw new Error("usePush: no push block. Put `createPush(…)` in GenesisNativeApp's `blocks`, or wrap in <PushProvider>.");
380
+ return block;
381
+ }
382
+
383
+ /** The block, or null when push is off: for a screen that works either way. */
384
+ export function useOptionalPush(): PushBlock | null {
385
+ return useContext(PushContext);
386
+ }
387
+
388
+ export function usePushPermissionState(): PermissionState {
389
+ const { permission } = usePush();
390
+ return useSyncExternalStore(permission.subscribe, permission.getState, permission.getState);
391
+ }
392
+
393
+ export function usePushRegistration(): RegistrationState {
394
+ const { registration } = usePush();
395
+ return useSyncExternalStore(registration.subscribe, registration.getState, registration.getState);
396
+ }
397
+
398
+ export function useNotificationPreferences(): PreferencesState & { update: PreferencesStore["update"] } {
399
+ const { preferences } = usePush();
400
+ const state = useSyncExternalStore(preferences.subscribe, preferences.getState, preferences.getState);
401
+ return { ...state, update: preferences.update };
402
+ }
403
+
404
+ /** The soft prompt: `offer("to hear when someone replies")` at the right moment. */
405
+ export function usePushPrompt(): PromptState & Pick<PromptStore, "offer" | "accept" | "decline"> {
406
+ const { prompt } = usePush();
407
+ const state = useSyncExternalStore(prompt.subscribe, prompt.getState, prompt.getState);
408
+ return { ...state, offer: prompt.offer, accept: prompt.accept, decline: prompt.decline };
409
+ }
410
+
411
+ /** The badge: `set(unread)`, `clear()`. */
412
+ export function useBadge(): PushBlock["badge"] {
413
+ return usePush().badge;
414
+ }
415
+
416
+ /**
417
+ * Hide notifications that match while this screen is open (`data` is the
418
+ * notification's `data`): a chat screen hides pushes for its own thread.
419
+ * Does nothing without a push block.
420
+ */
421
+ export function useSuppressNotifications(match: ((data: Record<string, unknown>) => boolean) | null) {
422
+ const block = useOptionalPush();
423
+ const latest = useRef(match);
424
+ latest.current = match;
425
+ const active = match !== null;
426
+ useEffect(() => (block && active ? block.suppress((data) => latest.current?.(data) ?? false) : undefined), [block, active]);
427
+ }
428
+
429
+ export interface PushRootProps {
430
+ block: PushBlock;
431
+ /** Where a tap that arrives signed out waits for sign-in (GenesisNativeApp passes its `links.hold`). */
432
+ hold?: LinkHold;
433
+ /** Open a path; used when the block's own `onOpen` isn't given. */
434
+ onOpen?: (path: string) => void;
435
+ slots?: Slots<PushSlots>;
436
+ slotProps?: SlotPropsFor<PushSlots>;
437
+ children?: ReactNode;
438
+ }
439
+
440
+ /** The block's parts around your screens. Must be inside GenesisNativeApp's session (or a `SessionProvider`). */
441
+ export function PushRoot({ block, hold, onOpen, slots, slotProps, children }: PushRootProps) {
442
+ const { status, userId } = useSession();
443
+ useEffect(() => block.start(), [block]);
444
+ useEffect(() => {
445
+ if (status === "loading") return;
446
+ block.setUser(status === "signedIn" ? userId : null);
447
+ }, [block, status, userId]);
448
+ useTapRouting(block, status, hold, onOpen);
449
+
450
+ return (
451
+ <PushProvider block={block}>
452
+ <View style={{ flex: 1 }}>
453
+ {children}
454
+ <PushPromptPart slots={slots} slotProps={slotProps} />
455
+ </View>
456
+ </PushProvider>
457
+ );
458
+ }
459
+
460
+ function PushPromptPart({ slots, slotProps }: Pick<PushRootProps, "slots" | "slotProps">) {
461
+ const prompt = usePushPrompt();
462
+ if (!prompt.visible) return null;
463
+ return renderSlot(
464
+ PushPromptSheet,
465
+ slots?.pushPrompt,
466
+ { reason: prompt.reason, accept: () => void prompt.accept(), decline: () => void prompt.decline() },
467
+ slotProps?.pushPrompt,
468
+ );
469
+ }
470
+
471
+ /**
472
+ * Taps: the one that launched the app (read once), then each one while it
473
+ * runs. Signed in, the path opens on the next tick (after the navigator has
474
+ * mounted); signed out, it is held and GenesisNativeApp follows it after
475
+ * sign-in. Each notification is followed once, however many ways it arrives.
476
+ */
477
+ function useTapRouting(block: PushBlock, status: string, hold: LinkHold | undefined, onOpen: ((path: string) => void) | undefined) {
478
+ const auth = useRef(status);
479
+ auth.current = status;
480
+ const latest = useRef({ hold, onOpen });
481
+ latest.current = { hold, onOpen };
482
+ const waiting = useRef<string | null>(null);
483
+
484
+ const follow = (path: string) => {
485
+ const open = block.options.onOpen ?? latest.current.onOpen;
486
+ if (auth.current === "signedIn") {
487
+ if (!open) {
488
+ report(block.options.onError, new Error("push: a tapped notification has nowhere to go; pass `onOpen` to createPush (router.push)."), "setup");
489
+ return;
490
+ }
491
+ // After this render: at a cold start the router's navigator mounts with the screens.
492
+ setTimeout(() => {
493
+ try {
494
+ open(path);
495
+ } catch (error) {
496
+ report(block.options.onError, error, "routing");
497
+ }
498
+ }, 0);
499
+ } else if (auth.current === "signedOut") {
500
+ const hold = block.options.hold ?? latest.current.hold;
501
+ if (hold) hold.hold(path);
502
+ else report(block.options.onError, new Error("push: a notification tapped while signed out was dropped; pass `hold` (your linkHold) to createPush."), "setup");
503
+ } else {
504
+ waiting.current = path;
505
+ }
506
+ };
507
+
508
+ useEffect(() => {
509
+ if (status === "loading" || waiting.current === null) return;
510
+ const path = waiting.current;
511
+ waiting.current = null;
512
+ follow(path);
513
+ }, [status]);
514
+
515
+ useEffect(() => {
516
+ const notifications = block.options.notifications;
517
+ if (!notifications) return;
518
+ const seen = new Set<string>();
519
+ const handle = (response: NotificationResponseLike | null) => {
520
+ if (!response) return;
521
+ try {
522
+ const id = response.notification.request.identifier;
523
+ if (seen.has(id)) return;
524
+ seen.add(id);
525
+ if (response.actionIdentifier === notifications.DEFAULT_ACTION_IDENTIFIER) {
526
+ const path = pathForResponse(response, block.options.links);
527
+ if (path) follow(path);
528
+ }
529
+ block.options.onResponse?.(response);
530
+ } catch (error) {
531
+ report(block.options.onError, error, "routing");
532
+ }
533
+ };
534
+ try {
535
+ handle(notifications.getLastNotificationResponse());
536
+ notifications.clearLastNotificationResponse();
537
+ } catch (error) {
538
+ report(block.options.onError, error, "routing");
539
+ }
540
+ const subscription = notifications.addNotificationResponseReceivedListener(handle);
541
+ return () => subscription.remove();
542
+ }, [block]);
543
+ }
544
+
545
+ export type { NotificationPreferences };
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `@marktiderman/genesis-native/push`: push registration, the soft
3
+ * permission prompt, Android channels, the foreground handler, tap routing,
4
+ * the badge, and the notification preferences screen (categories, quiet
5
+ * hours, privacy previews), on migration 0005 of @marktiderman/genesis-supabase.
6
+ *
7
+ * Rung 1: `<GenesisNativeApp blocks={[createPush(…)]}>` and `<NotificationSettings />`.
8
+ * Rung 2: the `pushPrompt` slot, and NotificationSettings' `permission`,
9
+ * `categories`, `quietHours` and `privacy` slots. Rung 3: the stores
10
+ * (`createRegistration`, …) and row builders. Rung 4: the hooks
11
+ * (`usePushPrompt`, `useQuietHours`, …) under `PushProvider`.
12
+ *
13
+ * Optional peer: `expo-notifications`, passed in rather than imported.
14
+ *
15
+ * @stability Beta
16
+ */
17
+ export {
18
+ createPush,
19
+ PushProvider,
20
+ PushRoot,
21
+ usePush,
22
+ useOptionalPush,
23
+ usePushPermissionState,
24
+ usePushRegistration,
25
+ useNotificationPreferences,
26
+ usePushPrompt,
27
+ useBadge,
28
+ useSuppressNotifications,
29
+ type PushBlock,
30
+ type PushChannel,
31
+ type PushOptions,
32
+ type PushRootProps,
33
+ type PushSlots,
34
+ } from "./block";
35
+ export { PushPromptSheet, type PushPromptProps } from "./screens";
36
+ export {
37
+ NotificationSettings,
38
+ notificationSettingsCopy,
39
+ usePushPermission,
40
+ useNotificationCategories,
41
+ useQuietHours,
42
+ usePrivacyPreviews,
43
+ permissionRows,
44
+ categoryRows,
45
+ quietHoursRows,
46
+ privacyRows,
47
+ type CategoriesPartProps,
48
+ type CategoryRow,
49
+ type NotificationSettingsCopy,
50
+ type NotificationSettingsProps,
51
+ type NotificationSettingsSlots,
52
+ type PermissionPartProps,
53
+ type PrivacyPartProps,
54
+ type QuietHoursPartProps,
55
+ } from "./settings";
56
+ export {
57
+ supabasePushBackend,
58
+ PushBackendError,
59
+ DEFAULT_PREFERENCES,
60
+ type NotificationPreferences,
61
+ type PushBackend,
62
+ type PushSupabaseLike,
63
+ type QuietHours,
64
+ } from "./backend";
65
+ export {
66
+ createPermissionStore,
67
+ createPromptStore,
68
+ statusOf,
69
+ canPush,
70
+ type PermissionState,
71
+ type PermissionStore,
72
+ type PromptOptions,
73
+ type PromptState,
74
+ type PromptStore,
75
+ type PushPermissionStatus,
76
+ } from "./permission";
77
+ export { createRegistration, type Registration, type RegistrationState, type RegistrationStatus } from "./registration";
78
+ export {
79
+ createPreferencesStore,
80
+ validPreferences,
81
+ type ChannelImportance,
82
+ type NotificationCategory,
83
+ type PreferencesState,
84
+ type PreferencesStatus,
85
+ type PreferencesStore,
86
+ } from "./preferences";
87
+ export { pathForResponse, notificationData } from "./routing";
88
+ export { parseClock, formatClock, clockOf, dateAt, deviceTimeZone, minutesIn, isQuietAt } from "./quiet-hours";
89
+ export type {
90
+ ChannelInputLike,
91
+ NotificationBehaviorLike,
92
+ NotificationLike,
93
+ NotificationResponseLike,
94
+ NotificationsModuleLike,
95
+ PermissionRequestLike,
96
+ PermissionStatusLike,
97
+ PushAppStateLike,
98
+ PushErrorSource,
99
+ PushOnError,
100
+ PushStorageLike,
101
+ SubscriptionLike,
102
+ } from "./modules";