@marktiderman/genesis-notifications 0.0.0-stage → 0.1.1

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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +251 -2
  3. package/dist/blocks.d.ts +10 -0
  4. package/dist/blocks.js +35 -0
  5. package/dist/blocks.js.map +1 -0
  6. package/dist/chunk-2BG336II.js +124 -0
  7. package/dist/chunk-2BG336II.js.map +1 -0
  8. package/dist/chunk-7PHA3WVG.js +51 -0
  9. package/dist/chunk-7PHA3WVG.js.map +1 -0
  10. package/dist/chunk-EWZB35V6.js +113 -0
  11. package/dist/chunk-EWZB35V6.js.map +1 -0
  12. package/dist/chunk-UXNWI7OS.js +118 -0
  13. package/dist/chunk-UXNWI7OS.js.map +1 -0
  14. package/dist/email-render-CID_pXDy.d.ts +53 -0
  15. package/dist/email.d.ts +46 -0
  16. package/dist/email.js +64 -0
  17. package/dist/email.js.map +1 -0
  18. package/dist/index.d.ts +26 -0
  19. package/dist/index.js +151 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/native.d.ts +92 -0
  22. package/dist/native.js +170 -0
  23. package/dist/native.js.map +1 -0
  24. package/dist/react.d.ts +98 -0
  25. package/dist/react.js +4 -0
  26. package/dist/react.js.map +1 -0
  27. package/dist/runner.d.ts +174 -0
  28. package/dist/runner.js +3 -0
  29. package/dist/runner.js.map +1 -0
  30. package/dist/server.d.ts +658 -0
  31. package/dist/server.js +995 -0
  32. package/dist/server.js.map +1 -0
  33. package/dist/supabase.d.ts +118 -0
  34. package/dist/supabase.js +3 -0
  35. package/dist/supabase.js.map +1 -0
  36. package/dist/types-BilguCPW.d.ts +332 -0
  37. package/dist/web.d.ts +79 -0
  38. package/dist/web.js +233 -0
  39. package/dist/web.js.map +1 -0
  40. package/functions/genesis-notifications/index.ts +60 -0
  41. package/functions/genesis-notifications/notifications.ts +18 -0
  42. package/migrations/0001_genesis_notifications.sql +2057 -0
  43. package/migrations/tests/0001_genesis_notifications.test.sql +473 -0
  44. package/package.json +191 -3
  45. package/src/blocks.ts +42 -0
  46. package/src/core/index.ts +43 -0
  47. package/src/core/registry.ts +354 -0
  48. package/src/core/types.ts +90 -0
  49. package/src/core/validate.ts +73 -0
  50. package/src/email/index.tsx +142 -0
  51. package/src/index.ts +6 -0
  52. package/src/native/index.tsx +283 -0
  53. package/src/react/index.tsx +226 -0
  54. package/src/runner/index.ts +299 -0
  55. package/src/server/crypto.ts +44 -0
  56. package/src/server/dns.ts +106 -0
  57. package/src/server/email-render.ts +90 -0
  58. package/src/server/errors.ts +61 -0
  59. package/src/server/expo.ts +133 -0
  60. package/src/server/handler.ts +127 -0
  61. package/src/server/index.ts +57 -0
  62. package/src/server/notify.ts +100 -0
  63. package/src/server/rate.ts +47 -0
  64. package/src/server/resend.ts +143 -0
  65. package/src/server/runner.ts +524 -0
  66. package/src/server/secret-links.ts +53 -0
  67. package/src/server/store.ts +150 -0
  68. package/src/server/unsubscribe.ts +78 -0
  69. package/src/server/webhook.ts +55 -0
  70. package/src/supabase/index.ts +210 -0
  71. package/src/web/index.tsx +428 -0
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The shapes the notifier's parts share: what a domain passes to `notify()`,
3
+ * a feed row as the database returns it, and a person's preferences.
4
+ */
5
+ import type { Channel, NotificationData } from "./registry";
6
+
7
+ /** A recipient with a login, by user id. */
8
+ export interface UserRecipient {
9
+ userId: string;
10
+ /** Their own app icon badge (an unread count), over the call's `badge`. */
11
+ badge?: number;
12
+ }
13
+
14
+ /**
15
+ * Someone with no login (an invitee, a form respondent): email only, never a
16
+ * feed. `ref` is the domain's id for them ("people:42"), so erasing them
17
+ * finds their rows.
18
+ */
19
+ export interface EmailRecipient {
20
+ email: string;
21
+ name?: string;
22
+ ref?: string;
23
+ }
24
+
25
+ /** A user id string, or one of the two shapes. */
26
+ export type Recipient = string | UserRecipient | EmailRecipient;
27
+
28
+ /** A brand the caller passes (the tenant's default is used for what it leaves out). */
29
+ export interface NotificationBrand {
30
+ name?: string;
31
+ logoUrl?: string;
32
+ /** A CSS colour (`#1d4ed8`), for the email's accent. */
33
+ color?: string;
34
+ /** Where a recipient goes from the email's footer. */
35
+ url?: string;
36
+ }
37
+
38
+ /** What a domain asks for. The notifier decides channel, timing and preferences. */
39
+ export interface NotifyInput<K extends string = string> {
40
+ type: K;
41
+ recipients: readonly Recipient[];
42
+ /** References and display values the type's render reads. Never a secret or a secret link. */
43
+ data?: NotificationData;
44
+ /** Unique per recipient: the same key again is the same notification, never a second one. */
45
+ idempotencyKey: string;
46
+ /** Groups pushes on the lock screen (iOS `threadId`): a conversation id. */
47
+ threadKey?: string;
48
+ /** A newer one replaces an older one with the same key that hasn't gone out (and on screen). */
49
+ collapseKey?: string;
50
+ /** The app icon badge, as the caller counts it. */
51
+ badge?: number;
52
+ /** The practice, workspace or organization it belongs to: its sender identity and defaults apply. */
53
+ tenantId?: string;
54
+ brand?: NotificationBrand;
55
+ /**
56
+ * A plaintext token that exists only hashed in the domain's tables (an
57
+ * invite). It travels only on the email's send job, encrypted, and is
58
+ * cleared once the email is sent; `resolveSecretLink` receives it.
59
+ */
60
+ secret?: string;
61
+ }
62
+
63
+ /** One row `notify()` made or found again. */
64
+ export interface NotifyResult {
65
+ notificationId: string;
66
+ recipientKey: string;
67
+ /** False when the idempotency key had been used for this recipient already. */
68
+ created: boolean;
69
+ }
70
+
71
+ /** A feed row, as the browser reads it under row level security. */
72
+ export interface NotificationRow {
73
+ id: string;
74
+ type: string;
75
+ category: string;
76
+ tenant_id: string | null;
77
+ data: NotificationData;
78
+ thread_key: string | null;
79
+ created_at: string;
80
+ seen_at: string | null;
81
+ read_at: string | null;
82
+ archived_at: string | null;
83
+ }
84
+
85
+ /** Delivery states. */
86
+ export const DELIVERY_STATUSES = ["pending", "sending", "sent", "delivered", "failed", "skipped", "cancelled"] as const;
87
+ export type DeliveryStatus = (typeof DELIVERY_STATUSES)[number];
88
+
89
+ /** A person's explicit choices: category id → channel → on. Absent means the default. */
90
+ export type ChannelChoices = Record<string, Partial<Record<Channel, boolean>>>;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Checks `notify()` runs before anything reaches the database (which checks
3
+ * the same again): recipients are well formed, and `data` holds no secret.
4
+ */
5
+ import type { NotificationData } from "./registry";
6
+ import type { Recipient } from "./types";
7
+
8
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+ const EMAIL = /^[^\s@<>,;:"()[\]\\]+@[^\s@<>,;:"()[\]\\]+\.[^\s@<>,;:"()[\]\\]+$/;
10
+
11
+ /**
12
+ * Keys that hold a secret by their name, matched on the key split into words
13
+ * (`inviteToken` is `invite_token`): any key ending in token, code, secret,
14
+ * key, otp or password, and a few more by word. The database refuses the same
15
+ * list. A backstop, not a guarantee: it catches the usual names, so a display
16
+ * value named that way (`country_code`) is refused too; name it otherwise.
17
+ */
18
+ export const SECRET_KEY = /((token|code|secret|key|otp|password)|(^|[._-])(signature|sig|magic_?link|invite_?link|reset_?link))$/;
19
+
20
+ /** A key as words, lower case: `inviteToken` → `invite_token`. */
21
+ export function keyWords(key: string): string {
22
+ return key.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
23
+ }
24
+ /**
25
+ * A link, absolute (`https://…`, `mailto:…`) or relative (`/…`, `./…`, `?…`,
26
+ * `#…`), with a query or fragment parameter whose name ends in token, code,
27
+ * secret, key, otp, password, sig or signature (`?token=`, `#access_token=`,
28
+ * `&inviteCode=`).
29
+ */
30
+ export const SECRET_URL = /^([a-z][a-z0-9+.-]*:|\/|\.\.?\/|(?=[?#]))\S*[?&#;][^=&#;\s]*(token|code|secret|key|otp|password|signature|sig)=/i;
31
+
32
+ /** `data` held something that looks like a secret: build such links at send time instead. */
33
+ export class SecretInDataError extends Error {
34
+ constructor(readonly path: string) {
35
+ super(`notify: data.${path} looks like a secret. Store a reference and build the link at send time with resolveSecretLink.`);
36
+ this.name = "SecretInDataError";
37
+ }
38
+ }
39
+
40
+ /** Throws a `SecretInDataError` naming the first key (or link) that looks like a secret. */
41
+ export function assertNoSecrets(data: NotificationData | undefined, path = ""): void {
42
+ if (!data || typeof data !== "object") return;
43
+ for (const [key, value] of Object.entries(data)) {
44
+ const here = path ? `${path}.${key}` : key;
45
+ if (SECRET_KEY.test(keyWords(key))) throw new SecretInDataError(here);
46
+ if (typeof value === "string" && SECRET_URL.test(value)) throw new SecretInDataError(here);
47
+ if (value && typeof value === "object") assertNoSecrets(value as NotificationData, here);
48
+ }
49
+ }
50
+
51
+ /** The recipients as `genesis_notify` takes them; throws on a malformed one. */
52
+ export function recipientsToJson(recipients: readonly Recipient[]): ({ user_id: string; badge?: number } | { email: string; name?: string; ref?: string })[] {
53
+ if (!Array.isArray(recipients) || recipients.length === 0) throw new Error("notify: no recipients");
54
+ if (recipients.length > 1000) throw new Error("notify: at most 1000 recipients a call");
55
+ return recipients.map((r, i) => {
56
+ if (typeof r === "string") {
57
+ if (!UUID.test(r)) throw new Error(`notify: recipient ${i} is not a user id`);
58
+ return { user_id: r };
59
+ }
60
+ if ("userId" in r) {
61
+ if (!UUID.test(r.userId)) throw new Error(`notify: recipient ${i} is not a user id`);
62
+ if (r.badge === undefined) return { user_id: r.userId };
63
+ if (!Number.isInteger(r.badge) || r.badge < 0 || r.badge > 99999) throw new Error(`notify: recipient ${i}'s badge is a whole number from 0`);
64
+ return { user_id: r.userId, badge: r.badge };
65
+ }
66
+ if ("email" in r) {
67
+ const email = r.email.trim();
68
+ if (!EMAIL.test(email) || email.length > 254) throw new Error(`notify: recipient ${i} has no valid email address`);
69
+ return { email, ...(r.name ? { name: r.name.slice(0, 200) } : {}), ...(r.ref ? { ref: r.ref.slice(0, 200) } : {}) };
70
+ }
71
+ throw new Error(`notify: recipient ${i} is neither a user id nor an email`);
72
+ });
73
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * `@marktiderman/genesis-notifications/email`: React Email templates for the
3
+ * notifier. `BrandLayout` wraps a message in the tenant's brand (logo or
4
+ * name, accent colour, footer with the unsubscribe link when there is one);
5
+ * `reactEmailTemplate` turns a component into an `EmailTemplate` the runner
6
+ * takes. Server-only; needs `@react-email/components` (an optional peer).
7
+ *
8
+ * ```tsx
9
+ * const templates = {
10
+ * "chat.message": reactEmailTemplate((ctx) => ({
11
+ * subject: `${ctx.text.title} sent you a message`,
12
+ * body: (
13
+ * <BrandLayout ctx={ctx} preview={ctx.text.body}>
14
+ * <EmailText>{ctx.text.body}</EmailText>
15
+ * {ctx.url ? <EmailButton ctx={ctx} href={ctx.url}>Reply</EmailButton> : null}
16
+ * </BrandLayout>
17
+ * ),
18
+ * })),
19
+ * };
20
+ * ```
21
+ *
22
+ * @packageDocumentation
23
+ */
24
+ import type { ReactElement, ReactNode } from "react";
25
+ import { Body, Button, Container, Head, Heading, Hr, Html, Img, Link, Preview, Section, Text, render } from "@react-email/components";
26
+ import type { EmailContent, EmailTemplate, EmailTemplateContext } from "../server/email-render";
27
+
28
+ const SAFE_COLOR = /^#[0-9a-fA-F]{3,8}$/;
29
+
30
+ function accentOf(ctx: EmailTemplateContext): string {
31
+ return typeof ctx.brand.color === "string" && SAFE_COLOR.test(ctx.brand.color) ? ctx.brand.color : "#111827";
32
+ }
33
+
34
+ function httpsOrNull(url: unknown): string | null {
35
+ if (typeof url !== "string") return null;
36
+ try {
37
+ return new URL(url).protocol === "https:" ? url : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
43
+ export interface BrandLayoutProps {
44
+ ctx: EmailTemplateContext;
45
+ /** The inbox preview line. */
46
+ preview?: string;
47
+ /** Default `ctx.text.title`. */
48
+ heading?: string;
49
+ /** Words before the unsubscribe link. Default "Don't want these emails?". */
50
+ unsubscribeLabel?: string;
51
+ children?: ReactNode;
52
+ }
53
+
54
+ /**
55
+ * The brand frame every notification email shares.
56
+ *
57
+ * @stability Beta
58
+ */
59
+ export function BrandLayout({ ctx, preview, heading, unsubscribeLabel = "Don't want these emails?", children }: BrandLayoutProps) {
60
+ const accent = accentOf(ctx);
61
+ const logo = httpsOrNull(ctx.brand.logoUrl);
62
+ const site = httpsOrNull(ctx.brand.url);
63
+ return (
64
+ <Html lang="en">
65
+ <Head />
66
+ {preview ? <Preview>{preview}</Preview> : null}
67
+ <Body style={{ backgroundColor: "#f6f7f9", margin: 0, padding: "24px 0", fontFamily: "-apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif" }}>
68
+ <Container style={{ backgroundColor: "#ffffff", borderRadius: 8, padding: 24, maxWidth: 560 }}>
69
+ <Section style={{ marginBottom: 16 }}>
70
+ {logo ? (
71
+ <Img src={logo} alt={ctx.brand.name ?? ""} height={32} />
72
+ ) : ctx.brand.name ? (
73
+ <Text style={{ margin: 0, fontWeight: 600, fontSize: 16, color: accent }}>{ctx.brand.name}</Text>
74
+ ) : null}
75
+ </Section>
76
+ <Heading as="h1" style={{ fontSize: 20, lineHeight: "28px", margin: "0 0 12px", color: "#111827" }}>
77
+ {heading ?? ctx.text.title}
78
+ </Heading>
79
+ {children}
80
+ <Hr style={{ borderColor: "#e5e7eb", margin: "24px 0 12px" }} />
81
+ <Text style={{ fontSize: 12, color: "#6b7280", margin: 0 }}>
82
+ {site && ctx.brand.name ? <Link href={site} style={{ color: "#6b7280" }}>{ctx.brand.name}</Link> : ctx.brand.name ?? null}
83
+ {ctx.unsubscribeUrl ? (
84
+ <>
85
+ {ctx.brand.name ? " · " : null}
86
+ {unsubscribeLabel}{" "}
87
+ <Link href={ctx.unsubscribeUrl} style={{ color: "#6b7280", textDecoration: "underline" }}>
88
+ Unsubscribe
89
+ </Link>
90
+ </>
91
+ ) : null}
92
+ </Text>
93
+ </Container>
94
+ </Body>
95
+ </Html>
96
+ );
97
+ }
98
+
99
+ /** A paragraph in the brand's type. */
100
+ export function EmailText({ children }: { children?: ReactNode }) {
101
+ return <Text style={{ fontSize: 15, lineHeight: "24px", color: "#111827", margin: "0 0 16px", whiteSpace: "pre-wrap" }}>{children}</Text>;
102
+ }
103
+
104
+ /** A button in the brand's accent. */
105
+ export function EmailButton({ ctx, href, children }: { ctx: EmailTemplateContext; href: string; children?: ReactNode }) {
106
+ return (
107
+ <Button href={href} style={{ backgroundColor: accentOf(ctx), color: "#ffffff", borderRadius: 6, padding: "10px 16px", fontSize: 15, textDecoration: "none" }}>
108
+ {children}
109
+ </Button>
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Turn a React Email component into the runner's `EmailTemplate`: HTML and a
115
+ * plain-text part from the same element.
116
+ *
117
+ * @stability Beta
118
+ */
119
+ export function reactEmailTemplate(build: (ctx: EmailTemplateContext) => { subject: string; body: ReactElement }): EmailTemplate {
120
+ return async (ctx): Promise<EmailContent> => {
121
+ const { subject, body } = build(ctx);
122
+ const [html, text] = await Promise.all([render(body), render(body, { plainText: true })]);
123
+ return { subject, html, text };
124
+ };
125
+ }
126
+
127
+ /** The default template, on the brand layout: the registry's title and body, and a button to the link. */
128
+ export const brandedDefaultTemplate: EmailTemplate = reactEmailTemplate((ctx) => ({
129
+ subject: ctx.text.title,
130
+ body: (
131
+ <BrandLayout ctx={ctx} preview={ctx.text.body}>
132
+ {ctx.text.body ? <EmailText>{ctx.text.body}</EmailText> : null}
133
+ {ctx.secretLink ?? ctx.url ? (
134
+ <EmailButton ctx={ctx} href={(ctx.secretLink ?? ctx.url)!}>
135
+ Open
136
+ </EmailButton>
137
+ ) : null}
138
+ </BrandLayout>
139
+ ),
140
+ }));
141
+
142
+ export type { EmailContent, EmailTemplate, EmailTemplateContext };
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @marktiderman/genesis-notifications: the registry and shared types (the `/core` entry).
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ export * from "./core/index";
@@ -0,0 +1,283 @@
1
+ /**
2
+ * `@marktiderman/genesis-notifications/native`: the notifier on the phone.
3
+ *
4
+ * NotificationsFeed the feed on genesis-ui-native's NotificationsPage
5
+ * useNotificationsFeedHeader the "Mark all read" header for the feed's Stack.Screen
6
+ * ChannelPreferences category × channel switches, as NotificationSettings' `categories` part
7
+ * NotificationPreferences genesis-native's NotificationSettings with that part in place
8
+ * notificationsPushBackend the push block's `PushBackend` on this package's migration
9
+ * pushOptionsFrom(registry) the push block's categories, Android channels and device categories
10
+ * notificationAction a tapped action button (Reply, Mark as read), with the notification id
11
+ *
12
+ * The device side stays in genesis-native's push block (registration,
13
+ * permission, routing, badge, hiding a push for the screen already open);
14
+ * this is what connects it to the notifier.
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+ import { useMemo } from "react";
19
+ import { NativeListItem, NativeSwitch } from "@marktiderman/genesis-ui-native/components";
20
+ import { NotificationsPage, getNotificationsPageStackOptions, type NotificationItem, type NotificationsPageProps } from "@marktiderman/genesis-ui-native/layouts";
21
+ import {
22
+ NotificationSettings,
23
+ notificationAction,
24
+ type CategoriesPartProps,
25
+ type NotificationAction,
26
+ type NotificationCategory,
27
+ type NotificationPreferences as PushPreferences,
28
+ type NotificationSettingsProps,
29
+ type PushBackend,
30
+ type PushDeviceCategory,
31
+ type PushOptions,
32
+ } from "@marktiderman/genesis-native/push";
33
+ import type { Channel, NotificationRegistry } from "../core/index";
34
+ import { useNotifications, usePreferences, type FeedItem } from "../react/index";
35
+ import { choicesOf } from "../supabase/index";
36
+
37
+ export { notificationAction, type NotificationAction };
38
+
39
+ export const CHANNEL_LABELS: Record<Channel, string> = { in_app: "In the app", push: "Push", email: "Email" };
40
+
41
+ /** How many notifications the feed (and its header) reads: one query, shared by both. */
42
+ export const NOTIFICATIONS_FEED_LIMIT = 100;
43
+
44
+ export interface NotificationsFeedProps extends Omit<NotificationsPageProps, "items" | "refreshing" | "onRefresh" | "onMarkAllRead"> {
45
+ /** Opens a notification's link (`router.push(item.url)`); it is marked read first. */
46
+ onOpen?: (item: FeedItem) => void;
47
+ limit?: number;
48
+ }
49
+
50
+ /**
51
+ * The feed on ui-native's `NotificationsPage`: pull to refresh, unread dots,
52
+ * a tap marks read and opens. Realtime refetches it (NotificationsProvider).
53
+ *
54
+ * @stability Beta
55
+ */
56
+ export function NotificationsFeed({ onOpen, limit = NOTIFICATIONS_FEED_LIMIT, ...page }: NotificationsFeedProps) {
57
+ const { items, isRefetching, refetch, markRead } = useNotifications({ limit });
58
+ const rows = useMemo<NotificationItem[]>(
59
+ () =>
60
+ items.map((item) => ({
61
+ id: item.id,
62
+ title: item.title,
63
+ ...(item.body ? { body: item.body } : {}),
64
+ timestamp: item.created_at,
65
+ unread: item.unread,
66
+ onPress: () => {
67
+ if (item.unread) void markRead(item.id);
68
+ onOpen?.(item);
69
+ },
70
+ })),
71
+ [items, markRead, onOpen],
72
+ );
73
+ return (
74
+ <NotificationsPage
75
+ testID="notifications"
76
+ {...page}
77
+ items={rows}
78
+ refreshing={isRefetching}
79
+ onRefresh={async () => {
80
+ await refetch();
81
+ }}
82
+ />
83
+ );
84
+ }
85
+
86
+ /**
87
+ * The feed's `Stack.Screen` options, with "Mark all read" while anything is
88
+ * unread. Pass the feed's `limit` if you changed it, so both read one query.
89
+ */
90
+ export function useNotificationsFeedHeader(options: { headerTitle?: string; limit?: number } = {}): Record<string, unknown> {
91
+ const { items, markAllRead } = useNotifications({ limit: options.limit ?? NOTIFICATIONS_FEED_LIMIT });
92
+ const unread = items.some((i) => i.unread);
93
+ // With nothing unread the header gets an explicit empty right side: leaving it out would keep the old button.
94
+ return getNotificationsPageStackOptions({
95
+ headerTitle: options.headerTitle,
96
+ ...(unread ? { onMarkAllRead: () => void markAllRead() } : { headerRight: () => null }),
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Category × channel switches (push and email, and the feed when a type
102
+ * offers it as optional), for NotificationSettings' `categories` slot. It
103
+ * ignores the push block's own category switches and reads the notifier's
104
+ * preferences instead.
105
+ *
106
+ * @stability Beta
107
+ */
108
+ export function ChannelPreferences(_props: Partial<CategoriesPartProps> & { channelLabels?: Partial<Record<Channel, string>> }) {
109
+ const { rows, status, set } = usePreferences();
110
+ const labels = { ...CHANNEL_LABELS, ..._props.channelLabels };
111
+ return (
112
+ <>
113
+ {rows.flatMap((row) =>
114
+ row.channels.map((ch) => (
115
+ <NativeListItem
116
+ key={`${row.id}:${ch}`}
117
+ testID={`notification-preference-${row.id}-${ch}`}
118
+ title={row.label}
119
+ subtitle={row.critical ? `${labels[ch]} · Always on` : labels[ch]}
120
+ trailing={
121
+ <NativeSwitch
122
+ testID={`notification-preference-${row.id}-${ch}-switch`}
123
+ accessibilityLabel={`${row.label}: ${labels[ch]}`}
124
+ checked={Boolean(row.values[ch])}
125
+ disabled={status !== "ready" || row.critical}
126
+ onCheckedChange={(on) => void set(row.id, ch, on).catch(() => undefined)}
127
+ />
128
+ }
129
+ />
130
+ )),
131
+ )}
132
+ </>
133
+ );
134
+ }
135
+
136
+ /**
137
+ * Settings → Notifications with the notifier's channel switches: the push
138
+ * block's permission, quiet hours and previews, and `ChannelPreferences` for
139
+ * the categories. Needs the push block and NotificationsProvider.
140
+ *
141
+ * @stability Beta
142
+ */
143
+ export function NotificationPreferences(props: NotificationSettingsProps) {
144
+ return <NotificationSettings {...props} slots={{ categories: ChannelPreferences, ...props.slots }} />;
145
+ }
146
+
147
+ /**
148
+ * What the push block needs from the registry: a category per notification
149
+ * category (its Android channel; NotificationSettings shows the categories
150
+ * section only when there are some), and the device categories with their
151
+ * action buttons.
152
+ *
153
+ * @stability Beta
154
+ */
155
+ export function pushOptionsFrom(registry: NotificationRegistry): Pick<PushOptions, "categories" | "deviceCategories"> {
156
+ const categories: NotificationCategory[] = registry.androidChannels().map((c) => ({
157
+ id: c.id,
158
+ title: c.name,
159
+ ...(c.description ? { description: c.description } : {}),
160
+ importance: c.importance,
161
+ }));
162
+ const deviceCategories: PushDeviceCategory[] = registry.deviceCategories();
163
+ return { categories, deviceCategories };
164
+ }
165
+
166
+ type Result = { data: unknown; error: { code?: string | null } | null };
167
+
168
+ /** The parts of a Supabase client the backend uses. */
169
+ export interface PushBackendClientLike {
170
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
171
+ rpc: (fn: any, args?: any) => PromiseLike<Result>;
172
+ from(table: string): unknown;
173
+ }
174
+
175
+ interface PreferencesQuery {
176
+ select(columns: string): { eq(column: string, value: string): { maybeSingle(): PromiseLike<Result> } };
177
+ upsert(row: Record<string, unknown>, options: { onConflict: string }): { select(columns: string): PromiseLike<Result> };
178
+ }
179
+
180
+ /** A database refusal, with its SQLSTATE; never a token. */
181
+ export class NotificationsPushBackendError extends Error {
182
+ constructor(
183
+ readonly operation: string,
184
+ readonly code: string | null,
185
+ ) {
186
+ super(`${operation} failed${code ? ` (${code})` : ""}`);
187
+ this.name = "NotificationsPushBackendError";
188
+ }
189
+ }
190
+
191
+ function clock(value: unknown, fallback: string): string {
192
+ const match = typeof value === "string" ? /^(\d{2}):(\d{2})/.exec(value) : null;
193
+ return match ? `${match[1]}:${match[2]}` : fallback;
194
+ }
195
+
196
+ /**
197
+ * The push block's `PushBackend` on genesis-notifications' migration:
198
+ * registers with this install's id (so a token moves between logins only on
199
+ * its own phone), and keeps the push block's category switches as each
200
+ * category's push channel (`genesis_notifications_set_preference`, one cell
201
+ * at a time, so nothing else in the row is overwritten). A category no
202
+ * notifier type sends push in (chat's `messages` while chat still sends
203
+ * through 0005's sender) keeps its switch in 0005's `categories`, which that
204
+ * sender reads, instead of failing the whole save. Quiet hours and previews
205
+ * are the same columns as before.
206
+ *
207
+ * @stability Beta
208
+ */
209
+ export function notificationsPushBackend(client: PushBackendClientLike): PushBackend {
210
+ const must = (operation: string, result: Result) => {
211
+ if (result.error) throw new NotificationsPushBackendError(operation, result.error.code ?? null);
212
+ return result.data;
213
+ };
214
+ // The push channel per category as last loaded or saved, so a save writes only what changed.
215
+ let known: Record<string, boolean> = {};
216
+ // 0005's `categories` as last loaded or saved: the switches of categories the notifier has no push type in.
217
+ let legacy: Record<string, boolean> = {};
218
+ return {
219
+ usesInstallationId: true,
220
+ async register(token, platform, appVersion, installationId) {
221
+ must("genesis_push_register", await client.rpc("genesis_push_register", { p_token: token, p_platform: platform, p_app_version: appVersion, p_installation_id: installationId ?? null }));
222
+ },
223
+ async forget(token) {
224
+ must("genesis_push_forget", await client.rpc("genesis_push_forget", { p_token: token }));
225
+ },
226
+ async loadPreferences(userId) {
227
+ const data = must(
228
+ "notification_preferences",
229
+ await (client.from("notification_preferences") as PreferencesQuery)
230
+ .select("channels, categories, quiet_hours_enabled, quiet_hours_start, quiet_hours_end, time_zone, hide_previews")
231
+ .eq("user_id", userId)
232
+ .maybeSingle(),
233
+ ) as Record<string, unknown> | null;
234
+ known = {};
235
+ legacy = {};
236
+ if (!data) return null;
237
+ if (data.categories && typeof data.categories === "object") {
238
+ for (const [category, on] of Object.entries(data.categories as Record<string, unknown>)) if (typeof on === "boolean") legacy[category] = on;
239
+ }
240
+ // As the server reads it: the push channel chosen, or 0005's switch for the whole category.
241
+ for (const [category, row] of Object.entries(choicesOf(data))) if (typeof row.push === "boolean") known[category] = row.push;
242
+ return {
243
+ categories: { ...known },
244
+ quietHours: {
245
+ enabled: data.quiet_hours_enabled === true,
246
+ start: clock(data.quiet_hours_start, "22:00"),
247
+ end: clock(data.quiet_hours_end, "07:00"),
248
+ timeZone: typeof data.time_zone === "string" ? data.time_zone : null,
249
+ },
250
+ hidePreviews: data.hide_previews === true,
251
+ } satisfies PushPreferences;
252
+ },
253
+ async savePreferences(userId, p) {
254
+ let legacyChanged = false;
255
+ for (const [category, on] of Object.entries(p.categories)) {
256
+ if (known[category] === on) continue;
257
+ const result = await client.rpc("genesis_notifications_set_preference", { p_category: category, p_channel: "push", p_on: on });
258
+ // 42704 is only "no type in that category uses push"; any other refusal (a critical category, 22023) is thrown.
259
+ if (result.error?.code === "42704") {
260
+ // No notifier type sends push in this category: keep the switch where 0005's sender reads it.
261
+ legacy[category] = on;
262
+ legacyChanged = true;
263
+ } else {
264
+ must("genesis_notifications_set_preference", result);
265
+ // The server moved this category's 0005 switch into channel choices.
266
+ if (category in legacy) delete legacy[category];
267
+ }
268
+ known[category] = on;
269
+ }
270
+ const row = {
271
+ ...(legacyChanged ? { categories: { ...legacy } } : {}),
272
+ user_id: userId,
273
+ quiet_hours_enabled: p.quietHours.enabled,
274
+ quiet_hours_start: p.quietHours.start,
275
+ quiet_hours_end: p.quietHours.end,
276
+ time_zone: p.quietHours.timeZone,
277
+ hide_previews: p.hidePreviews,
278
+ };
279
+ const data = must("notification_preferences", await (client.from("notification_preferences") as PreferencesQuery).upsert(row, { onConflict: "user_id" }).select("user_id"));
280
+ if (!Array.isArray(data) || data.length !== 1) throw new NotificationsPushBackendError("notification_preferences", "no_rows");
281
+ },
282
+ };
283
+ }