@marktiderman/genesis-notifications 0.1.1 → 0.2.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 CHANGED
@@ -24,7 +24,7 @@ for a domain are in [docs/NOTIFICATIONS.md](../../docs/NOTIFICATIONS.md).
24
24
  | `/supabase` | `NOTIFICATIONS_MIGRATIONS` and `notificationsClient(supabase)`, the browser and phone side under row level security. |
25
25
  | `/react` | `NotificationsProvider`, `useNotifications`, `useUnreadCount`, `usePreferences`. |
26
26
  | `/web` | `NotificationBell`, `NotificationFeed`, `PreferencesMatrix`, and for admins `DeliveryLog`, `SuppressionList`, `SendTestNotification`, all on genesis-ui. |
27
- | `/native` | `NotificationsFeed` (on genesis-ui-native's `NotificationsPage`), `ChannelPreferences`, `NotificationPreferences`, `notificationsPushBackend`, `pushOptionsFrom`, `notificationAction`. |
27
+ | `/native` | `NotificationsFeed` (on genesis-ui-native's `NotificationsPage`), `ChannelPreferences`, `NotificationPreferences`, `notificationsPushBackend`, `notificationsPushPreferences` (its preferences half), `pushOptionsFrom`, `notificationAction`. |
28
28
  | `/blocks` | `notificationsBlock`: routes, nav, a Settings row, the migration and the privacy notes. |
29
29
 
30
30
  The package ships `migrations/0001_genesis_notifications.sql` with its pgTAP
@@ -57,13 +57,13 @@ test, and the `functions/genesis-notifications` Edge Function template.
57
57
 
58
58
  export const notifications = defineNotifications({
59
59
  categories: { messages: { label: "Messages", android: { importance: "high" } }, ...GENESIS_TEST_CATEGORY },
60
- actions: { message: MESSAGE_ACTIONS },
60
+ actions: { messages: MESSAGE_ACTIONS },
61
61
  types: {
62
62
  "chat.message": {
63
63
  category: "messages",
64
64
  channels: { in_app: true, push: true, email: true },
65
65
  emailDelaySeconds: 600,
66
- actions: "message",
66
+ actions: "messages",
67
67
  render: (d) => ({ title: String(d.sender_name), body: String(d.preview), url: `/messages/${d.conversation_id}` }),
68
68
  },
69
69
  ...GENESIS_TEST_TYPE,
@@ -87,6 +87,13 @@ test, and the `functions/genesis-notifications` Edge Function template.
87
87
  On native, give the push block this package's backend and the registry's categories:
88
88
  `createPush({ backend: notificationsPushBackend(supabase), ...pushOptionsFrom(notifications), randomUUID: Crypto.randomUUID, … })`.
89
89
 
90
+ An app that registers devices its own way keeps its `register` and `forget` and takes only the
91
+ preferences: `backend: { register, forget, ...notificationsPushPreferences(supabase) }`. Spread it
92
+ last: keys that come after it (an app's own `loadPreferences` or `savePreferences`) replace its
93
+ functions, and keys before it are replaced by them. The push
94
+ settings screen then reads and saves this package's preferences row (each category switch is that
95
+ category's push channel; quiet hours and previews are the row's own columns).
96
+
90
97
  ## Sending
91
98
 
92
99
  ```ts
@@ -199,8 +206,13 @@ badge, and hiding a push for the screen already open (`useSuppressNotifications`
199
206
  The runner sends every due job's devices in Expo batches of 100 with `threadId`, `categoryId`
200
207
  (the device category with Reply and Mark as read), `badge`, and a `collapseId` (the collapse key,
201
208
  else the notification id, so a resend after a timeout replaces rather than repeats). The payload's
202
- `data` is `{ url, notification_id, type, thread_key }`, never the notification's own `data`. A
203
- person who hides previews, or a type with `pushPrivacy: "generic"`, gets neutral text. Receipts
209
+ `data` is `{ url, notification_id, type, thread_key }` plus what the type's `render` returns under
210
+ `push`, never the notification's whole `data`: `push.ids` always travel (genesis-chat's
211
+ `conversationId`, `messageId` and `seq`, so Reply and Mark read work from the notification), and
212
+ `push.display` (a sender's name and photo) and `push.mutableContent` (which lets the app's
213
+ Notification Service Extension run) only while previews are shown. Only flat strings and numbers
214
+ pass, and they can't replace the notifier's own keys. A person who hides previews, or a type with
215
+ `pushPrivacy: "generic"`, gets neutral text and no display values. Receipts
204
216
  are checked about every 15 minutes; `DeviceNotRegistered` forgets the device.
205
217
 
206
218
  Android shows a push only on a channel the app made: the runner sends the category id as
@@ -240,12 +252,16 @@ switch becomes explicit channel choices (`genesis_notifications_set_preference`)
240
252
  - **Digests** (next, v1.1).
241
253
  - SMS.
242
254
  - Web push.
243
- - The Notification Service Extension and Communication Notifications (rich pushes, sender avatars).
244
255
  - Live Activities.
245
256
  - Slack as a channel.
246
257
  - Routing auth emails (sign-in links, password resets) through Supabase's send-email hook.
247
258
  - Per-tenant Resend keys (the provider seam is there; reading a key from Vault isn't).
248
259
 
260
+ The Notification Service Extension and Communication Notifications (rich pushes with sender
261
+ avatars) aren't here but work with it: genesis-native's rich push plugin adds the extension, and a
262
+ type opts in through `render`'s `push.display` and `push.mutableContent` (genesis-chat's
263
+ `chat.message` does).
264
+
249
265
  Also not done yet: nothing has been sent through Expo or Resend with real credentials; the
250
266
  providers are tested against faked services.
251
267
 
@@ -1,4 +1,4 @@
1
- import { t as RenderedText, j as NotificationBrand } from './types-BilguCPW.js';
1
+ import { t as RenderedText, j as NotificationBrand } from './types-ygEEDJSM.js';
2
2
 
3
3
  /**
4
4
  * What an email says, and the checks every one passes: subjects and names on
package/dist/email.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode, ReactElement } from 'react';
3
- import { E as EmailTemplateContext, a as EmailTemplate } from './email-render-CID_pXDy.js';
4
- export { b as EmailContent } from './email-render-CID_pXDy.js';
5
- import './types-BilguCPW.js';
3
+ import { E as EmailTemplateContext, a as EmailTemplate } from './email-render-DsJ1cM5P.js';
4
+ export { b as EmailContent } from './email-render-DsJ1cM5P.js';
5
+ import './types-ygEEDJSM.js';
6
6
 
7
7
  interface BrandLayoutProps {
8
8
  ctx: EmailTemplateContext;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { N as NotificationData, R as Recipient } from './types-BilguCPW.js';
2
- export { C as CHANNELS, a as Channel, b as ChannelChoices, c as ChannelImportance, D as DELIVERY_STATUSES, d as DeliveryStatus, E as EmailRecipient, G as GENESIS_TEST_CATEGORY, e as GENESIS_TEST_TYPE, I as IOS_ID_PATTERN, K as KEY_PATTERN, M as MAX_EMAIL_DELAY_SECONDS, f as MESSAGE_ACTIONS, g as MatrixRow, h as NotificationActionDef, i as NotificationActionSetDef, j as NotificationBrand, k as NotificationCategoryDef, l as NotificationRegistry, m as NotificationRegistryError, n as NotificationRow, o as NotificationTypeDef, p as NotificationTypeRow, q as NotificationsInput, r as NotifyInput, s as NotifyResult, t as RenderedText, U as UserRecipient, u as defineNotifications } from './types-BilguCPW.js';
1
+ import { N as NotificationData, R as Recipient } from './types-ygEEDJSM.js';
2
+ export { C as CHANNELS, a as Channel, b as ChannelChoices, c as ChannelImportance, D as DELIVERY_STATUSES, d as DeliveryStatus, E as EmailRecipient, G as GENESIS_TEST_CATEGORY, e as GENESIS_TEST_TYPE, I as IOS_ID_PATTERN, K as KEY_PATTERN, M as MAX_EMAIL_DELAY_SECONDS, f as MESSAGE_ACTIONS, g as MatrixRow, h as NotificationActionDef, i as NotificationActionSetDef, j as NotificationBrand, k as NotificationCategoryDef, l as NotificationRegistry, m as NotificationRegistryError, n as NotificationRow, o as NotificationTypeDef, p as NotificationTypeRow, q as NotificationsInput, r as NotifyInput, s as NotifyResult, t as RenderedText, U as UserRecipient, u as defineNotifications } from './types-ygEEDJSM.js';
3
3
 
4
4
  /**
5
5
  * Checks `notify()` runs before anything reaches the database (which checks
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/core/registry.ts","../src/core/types.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * `defineNotifications`: the one list of what an app notifies people about,\n * as `defineFeatures` is the list of what it does. Each type names its\n * category, the channels it may use (and which are on by default), and two\n * flags: `critical` (preferences can't turn it off) and `transactional` (no\n * unsubscribe link, no quiet hours, no digest). The registry also defines the\n * device notification categories (iOS action buttons) and the Android\n * channels the app makes on the phone.\n *\n * It is plain data plus small render functions, so the browser, the phone\n * and the server import the same list. `toTypeRows()` is what is mirrored\n * into the database's `notification_types`.\n */\n\n/** Where a notification can go. `in_app` is the feed. */\nexport const CHANNELS = [\"in_app\", \"push\", \"email\"] as const;\nexport type Channel = (typeof CHANNELS)[number];\n\n/** Lower case letters, digits, \".\", \"_\" or \"-\", up to 64 characters: type keys, category ids. */\nexport const KEY_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;\n/** Device category and action ids: Expo asks for no \":\" or \"-\". */\nexport const IOS_ID_PATTERN = /^[A-Za-z0-9_]{1,64}$/;\n\n/** Android channel importance (Expo's `AndroidImportance`). */\nexport type ChannelImportance = \"min\" | \"low\" | \"default\" | \"high\" | \"max\";\n\nexport interface NotificationCategoryDef {\n /** What a person sees in their preferences (\"Messages\"). */\n label: string;\n description?: string;\n /** Preferences can't turn this category off (account security, say). */\n critical?: boolean;\n /** The Android channel made for it (its id is the category id). Default importance `\"default\"`. */\n android?: { importance?: ChannelImportance; name?: string };\n}\n\n/** An action button on a notification. */\nexport interface NotificationActionDef {\n id: string;\n title: string;\n /** A reply field instead of a plain button. */\n textInput?: { placeholder?: string; submitTitle?: string };\n /** Opens the app (default false: the action is handled in the background). */\n opensApp?: boolean;\n destructive?: boolean;\n /** Needs the phone unlocked. */\n requiresAuth?: boolean;\n}\n\n/** A notification category on the device (iOS's word): the actions a notification shows. */\nexport interface NotificationActionSetDef {\n actions: readonly NotificationActionDef[];\n /** Shown when the person has hidden previews for the app. */\n previewPlaceholder?: string;\n}\n\n/** What a notification says, made from its `data`. */\nexport interface RenderedText {\n title: string;\n body?: string;\n /** The app path a tap opens (`/messages/42`). */\n url?: string;\n /**\n * Push-only extras, for a type whose app reads more than the link: `ids` always travel\n * (a Reply action needs the conversation and message), `display` only when previews are\n * shown (a sender's name and photo for iOS rich push), and `mutableContent` lets the app's\n * Notification Service Extension run (dropped with hidden previews). Flat values only; the\n * notifier's own keys (`notification_id`, `type`, `url`, `thread_key`) can't be replaced.\n */\n push?: {\n ids?: Record<string, string | number>;\n display?: Record<string, string | number>;\n mutableContent?: boolean;\n };\n}\n\n/** A notification's `data`: references and display values, never a secret. */\nexport type NotificationData = Record<string, unknown>;\n\nexport interface NotificationTypeDef<C extends string = string, A extends string = string> {\n category: C;\n /**\n * The channels this type may use, and whether each is on for someone who\n * never chose: `{ in_app: true, push: true, email: false }` offers email\n * as an opt-in. A channel left out is never used.\n */\n channels: Partial<Record<Channel, boolean>>;\n /** Preferences can't turn it off: each channel it offers stays as defaulted. */\n critical?: boolean;\n /**\n * Mail the person needs for something they did (a receipt, a reset): no\n * unsubscribe link, never held by quiet hours or folded into a digest.\n * Rate caps are the domain's.\n */\n transactional?: boolean;\n /**\n * Wait this long before emailing, and skip the email if the person read\n * the notification in the app meanwhile. Default 0 (send at once).\n */\n emailDelaySeconds?: number;\n /** The device category (a key of `actions`) its pushes carry. */\n actions?: A;\n /** `\"generic\"` always sends neutral push text, whatever the person chose. */\n pushPrivacy?: \"default\" | \"generic\";\n /**\n * The email holds a secret link (a one-time token). It is never stored in\n * `data` or pushed: the server builds it at send time with\n * `resolveSecretLink(type, ref)`.\n */\n secretLink?: boolean;\n /** What the feed and the push say. */\n render: (data: NotificationData) => RenderedText;\n}\n\nexport interface NotificationsInput<\n C extends Record<string, NotificationCategoryDef>,\n A extends Record<string, NotificationActionSetDef>,\n T extends Record<string, NotificationTypeDef<Extract<keyof C, string>, Extract<keyof A, string>>>,\n> {\n categories: C;\n actions?: A;\n types: T;\n /** Neutral text for hidden previews. Default \"New notification\". */\n hiddenPreview?: { title: string; body?: string };\n}\n\n/** One row of `notification_types`, as `genesis_notifications_sync_types` takes it. */\nexport interface NotificationTypeRow {\n key: string;\n category: string;\n channels: Partial<Record<Channel, boolean>>;\n critical: boolean;\n transactional: boolean;\n email_delay_seconds: number;\n secret_link: boolean;\n push_privacy: \"default\" | \"generic\";\n ios_category: string | null;\n}\n\nexport interface MatrixRow {\n id: string;\n label: string;\n description?: string;\n critical: boolean;\n /** The channels some type in the category offers, in `CHANNELS` order. */\n channels: Channel[];\n /** Each offered channel's default: on when any type in the category has it on. */\n defaults: Partial<Record<Channel, boolean>>;\n}\n\nexport interface NotificationRegistry<\n C extends Record<string, NotificationCategoryDef> = Record<string, NotificationCategoryDef>,\n A extends Record<string, NotificationActionSetDef> = Record<string, NotificationActionSetDef>,\n T extends Record<string, NotificationTypeDef> = Record<string, NotificationTypeDef>,\n> {\n readonly categories: C;\n readonly actions: A;\n readonly types: T;\n readonly hiddenPreview: { title: string; body?: string };\n /** The type, or throws: an unknown key is a bug, never dropped quietly. */\n type(key: string): NotificationTypeDef;\n has(key: string): boolean;\n /** Preferences can't turn it off: a critical type, or a type in a critical category. */\n isCritical(key: string): boolean;\n /** What the feed and the push say. A render that throws gives the type's key as the title. */\n render(key: string, data: NotificationData): RenderedText;\n /** The rows mirrored into `notification_types`. */\n toTypeRows(): NotificationTypeRow[];\n /** For a preferences matrix: each category with the channels its types offer. */\n matrix(): MatrixRow[];\n /** The device categories to register (`setNotificationCategoryAsync`). */\n deviceCategories(): { identifier: string; actions: NotificationActionDef[]; previewPlaceholder?: string }[];\n /** The Android channels, one per category. */\n androidChannels(): { id: string; name: string; description?: string; importance: ChannelImportance }[];\n}\n\n/** Thrown by `defineNotifications`; lists every problem at once. */\nexport class NotificationRegistryError extends Error {\n constructor(readonly problems: string[]) {\n super(`Invalid notifications:\\n${problems.map((p) => ` - ${p}`).join(\"\\n\")}`);\n this.name = \"NotificationRegistryError\";\n }\n}\n\n/** The longest email delay: a week. */\nexport const MAX_EMAIL_DELAY_SECONDS = 7 * 24 * 3600;\n\n/**\n * The notification registry.\n *\n * ```ts\n * export const notifications = defineNotifications({\n * categories: { messages: { label: \"Messages\", android: { importance: \"high\" } } },\n * actions: { message: { actions: [{ id: \"reply\", title: \"Reply\", textInput: {} }, { id: \"mark_read\", title: \"Mark as read\" }] } },\n * types: {\n * \"chat.message\": {\n * category: \"messages\",\n * channels: { in_app: true, push: true, email: true },\n * emailDelaySeconds: 600,\n * actions: \"message\",\n * render: (d) => ({ title: String(d.sender_name), body: String(d.preview), url: `/messages/${d.conversation_id}` }),\n * },\n * },\n * });\n * ```\n *\n * @stability Beta\n */\nexport function defineNotifications<\n const C extends Record<string, NotificationCategoryDef>,\n const A extends Record<string, NotificationActionSetDef>,\n const T extends Record<string, NotificationTypeDef<Extract<keyof C, string>, Extract<keyof A, string>>>,\n>(input: NotificationsInput<C, A, T>): NotificationRegistry<C, A, T> {\n const problems: string[] = [];\n const categories = input.categories as C & Record<string, NotificationCategoryDef>;\n const actions = (input.actions ?? {}) as A & Record<string, NotificationActionSetDef>;\n const types = input.types as T & Record<string, NotificationTypeDef>;\n const own = (o: object, k: string) => Object.prototype.hasOwnProperty.call(o, k);\n\n for (const [id, c] of Object.entries(categories)) {\n if (!KEY_PATTERN.test(id)) problems.push(`category \"${id}\": an id is lower case letters, digits, \".\", \"_\" or \"-\"`);\n if (!c.label?.trim()) problems.push(`category \"${id}\": needs a label`);\n }\n for (const [id, set] of Object.entries(actions)) {\n if (!IOS_ID_PATTERN.test(id)) problems.push(`actions \"${id}\": a device category id is letters, digits or \"_\" (no \":\" or \"-\")`);\n const seen = new Set<string>();\n for (const a of set.actions) {\n if (!IOS_ID_PATTERN.test(a.id)) problems.push(`actions \"${id}\": action \"${a.id}\" is letters, digits or \"_\"`);\n if (seen.has(a.id)) problems.push(`actions \"${id}\": action \"${a.id}\" appears twice`);\n seen.add(a.id);\n }\n if (set.actions.length === 0 || set.actions.length > 4) problems.push(`actions \"${id}\": one to four actions`);\n }\n for (const [key, t] of Object.entries(types)) {\n if (!KEY_PATTERN.test(key)) problems.push(`type \"${key}\": a key is lower case letters, digits, \".\", \"_\" or \"-\"`);\n if (!own(categories, t.category)) problems.push(`type \"${key}\": unknown category \"${String(t.category)}\"`);\n const offered = Object.keys(t.channels ?? {});\n if (offered.length === 0) problems.push(`type \"${key}\": offers no channel`);\n for (const ch of offered) if (!(CHANNELS as readonly string[]).includes(ch)) problems.push(`type \"${key}\": unknown channel \"${ch}\"`);\n if (t.actions !== undefined && !own(actions, t.actions)) problems.push(`type \"${key}\": unknown actions \"${String(t.actions)}\"`);\n const delay = t.emailDelaySeconds ?? 0;\n if (!Number.isInteger(delay) || delay < 0 || delay > MAX_EMAIL_DELAY_SECONDS) {\n problems.push(`type \"${key}\": emailDelaySeconds is a whole number from 0 to ${MAX_EMAIL_DELAY_SECONDS}`);\n }\n if (delay > 0 && t.channels?.email === undefined) problems.push(`type \"${key}\": emailDelaySeconds needs the email channel`);\n if (delay > 0 && t.channels?.in_app === undefined) problems.push(`type \"${key}\": an email delay skips the mail once read in the app, so it needs the in_app channel`);\n if (t.secretLink && t.channels?.email === undefined) problems.push(`type \"${key}\": secretLink is for email, and it offers no email channel`);\n if (typeof t.render !== \"function\") problems.push(`type \"${key}\": needs a render function`);\n }\n if (problems.length) throw new NotificationRegistryError(problems);\n\n const hiddenPreview = input.hiddenPreview ?? { title: \"New notification\" };\n const typeOf = (key: string): NotificationTypeDef => {\n if (!own(types, key)) throw new NotificationRegistryError([`unknown notification type \"${key}\"`]);\n return types[key]!;\n };\n const isCritical = (key: string) => {\n const t = typeOf(key);\n return Boolean(t.critical || categories[t.category]?.critical);\n };\n\n return {\n categories,\n actions,\n types,\n hiddenPreview,\n type: typeOf,\n has: (key) => own(types, key),\n isCritical,\n render(key, data) {\n try {\n const out = typeOf(key).render(data ?? {});\n return {\n title: typeof out?.title === \"string\" && out.title ? out.title : key,\n ...(typeof out?.body === \"string\" ? { body: out.body } : {}),\n ...(typeof out?.url === \"string\" && out.url ? { url: out.url } : {}),\n ...(out?.push && typeof out.push === \"object\" ? { push: out.push } : {}),\n };\n } catch {\n return { title: key };\n }\n },\n toTypeRows() {\n return Object.entries(types).map(([key, t]) => ({\n key,\n category: t.category,\n channels: { ...t.channels },\n critical: isCritical(key),\n transactional: Boolean(t.transactional),\n email_delay_seconds: t.emailDelaySeconds ?? 0,\n secret_link: Boolean(t.secretLink),\n push_privacy: t.pushPrivacy ?? \"default\",\n ios_category: t.actions ?? null,\n }));\n },\n matrix() {\n const rows: MatrixRow[] = [];\n for (const [id, c] of Object.entries(categories)) {\n const defaults: Partial<Record<Channel, boolean>> = {};\n let critical = Boolean(c.critical);\n for (const [key, t] of Object.entries(types)) {\n if (t.category !== id) continue;\n for (const [ch, on] of Object.entries(t.channels) as [Channel, boolean][]) defaults[ch] = Boolean(defaults[ch] || on);\n if (isCritical(key)) critical = true;\n }\n const channels = CHANNELS.filter((ch) => ch in defaults);\n if (channels.length) rows.push({ id, label: c.label, ...(c.description ? { description: c.description } : {}), critical, channels, defaults });\n }\n return rows;\n },\n deviceCategories() {\n return Object.entries(actions).map(([identifier, set]) => ({\n identifier,\n actions: [...set.actions],\n ...(set.previewPlaceholder ? { previewPlaceholder: set.previewPlaceholder } : {}),\n }));\n },\n androidChannels() {\n return Object.entries(categories).map(([id, c]) => ({\n id,\n name: c.android?.name ?? c.label,\n ...(c.description ? { description: c.description } : {}),\n importance: c.android?.importance ?? \"default\",\n }));\n },\n };\n}\n\n/**\n * The actions a message notification shows: Reply (a text field) and Mark as\n * read. Put it in `actions` under an id (`message`) and name that id in the\n * message type's `actions`. A tap on either reaches the push block's\n * `onResponse`; `notificationAction(response)` gives the action id, the typed\n * text and the notification id (use it as the reply's client id, so a\n * retried reply stays one reply).\n */\nexport const MESSAGE_ACTIONS = {\n actions: [\n { id: \"reply\", title: \"Reply\", textInput: { placeholder: \"Message\", submitTitle: \"Send\" } },\n { id: \"mark_read\", title: \"Mark as read\" },\n ],\n} as const satisfies NotificationActionSetDef;\n\n/** The category and type of Admin -> Send a test (`genesis.test`, which the migration creates). Spread them into your registry. */\nexport const GENESIS_TEST_CATEGORY = { genesis: { label: \"Tests\", description: \"Test notifications an admin sends themself.\" } } as const satisfies Record<string, NotificationCategoryDef>;\nexport const GENESIS_TEST_TYPE = {\n \"genesis.test\": {\n category: \"genesis\",\n channels: { in_app: true, push: true, email: true },\n critical: true,\n transactional: true,\n render: () => ({ title: \"Test notification\", body: \"If you can read this, this channel works.\" }),\n },\n} as const satisfies Record<string, NotificationTypeDef<\"genesis\">>;\n","/**\n * The shapes the notifier's parts share: what a domain passes to `notify()`,\n * a feed row as the database returns it, and a person's preferences.\n */\nimport type { Channel, NotificationData } from \"./registry\";\n\n/** A recipient with a login, by user id. */\nexport interface UserRecipient {\n userId: string;\n /** Their own app icon badge (an unread count), over the call's `badge`. */\n badge?: number;\n}\n\n/**\n * Someone with no login (an invitee, a form respondent): email only, never a\n * feed. `ref` is the domain's id for them (\"people:42\"), so erasing them\n * finds their rows.\n */\nexport interface EmailRecipient {\n email: string;\n name?: string;\n ref?: string;\n}\n\n/** A user id string, or one of the two shapes. */\nexport type Recipient = string | UserRecipient | EmailRecipient;\n\n/** A brand the caller passes (the tenant's default is used for what it leaves out). */\nexport interface NotificationBrand {\n name?: string;\n logoUrl?: string;\n /** A CSS colour (`#1d4ed8`), for the email's accent. */\n color?: string;\n /** Where a recipient goes from the email's footer. */\n url?: string;\n}\n\n/** What a domain asks for. The notifier decides channel, timing and preferences. */\nexport interface NotifyInput<K extends string = string> {\n type: K;\n recipients: readonly Recipient[];\n /** References and display values the type's render reads. Never a secret or a secret link. */\n data?: NotificationData;\n /** Unique per recipient: the same key again is the same notification, never a second one. */\n idempotencyKey: string;\n /** Groups pushes on the lock screen (iOS `threadId`): a conversation id. */\n threadKey?: string;\n /** A newer one replaces an older one with the same key that hasn't gone out (and on screen). */\n collapseKey?: string;\n /** The app icon badge, as the caller counts it. */\n badge?: number;\n /** The practice, workspace or organization it belongs to: its sender identity and defaults apply. */\n tenantId?: string;\n brand?: NotificationBrand;\n /**\n * A plaintext token that exists only hashed in the domain's tables (an\n * invite). It travels only on the email's send job, encrypted, and is\n * cleared once the email is sent; `resolveSecretLink` receives it.\n */\n secret?: string;\n}\n\n/** One row `notify()` made or found again. */\nexport interface NotifyResult {\n notificationId: string;\n recipientKey: string;\n /** False when the idempotency key had been used for this recipient already. */\n created: boolean;\n}\n\n/** A feed row, as the browser reads it under row level security. */\nexport interface NotificationRow {\n id: string;\n type: string;\n category: string;\n tenant_id: string | null;\n data: NotificationData;\n thread_key: string | null;\n created_at: string;\n seen_at: string | null;\n read_at: string | null;\n archived_at: string | null;\n}\n\n/** Delivery states. */\nexport const DELIVERY_STATUSES = [\"pending\", \"sending\", \"sent\", \"delivered\", \"failed\", \"skipped\", \"cancelled\"] as const;\nexport type DeliveryStatus = (typeof DELIVERY_STATUSES)[number];\n\n/** A person's explicit choices: category id → channel → on. Absent means the default. */\nexport type ChannelChoices = Record<string, Partial<Record<Channel, boolean>>>;\n"]}
1
+ {"version":3,"sources":["../src/core/registry.ts","../src/core/types.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * `defineNotifications`: the one list of what an app notifies people about,\n * as `defineFeatures` is the list of what it does. Each type names its\n * category, the channels it may use (and which are on by default), and two\n * flags: `critical` (preferences can't turn it off) and `transactional` (no\n * unsubscribe link, no quiet hours, no digest). The registry also defines the\n * device notification categories (iOS action buttons) and the Android\n * channels the app makes on the phone.\n *\n * It is plain data plus small render functions, so the browser, the phone\n * and the server import the same list. `toTypeRows()` is what is mirrored\n * into the database's `notification_types`.\n */\n\n/** Where a notification can go. `in_app` is the feed. */\nexport const CHANNELS = [\"in_app\", \"push\", \"email\"] as const;\nexport type Channel = (typeof CHANNELS)[number];\n\n/** Lower case letters, digits, \".\", \"_\" or \"-\", up to 64 characters: type keys, category ids. */\nexport const KEY_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;\n/** Device category and action ids: Expo asks for no \":\" or \"-\". */\nexport const IOS_ID_PATTERN = /^[A-Za-z0-9_]{1,64}$/;\n\n/** Android channel importance (Expo's `AndroidImportance`). */\nexport type ChannelImportance = \"min\" | \"low\" | \"default\" | \"high\" | \"max\";\n\nexport interface NotificationCategoryDef {\n /** What a person sees in their preferences (\"Messages\"). */\n label: string;\n description?: string;\n /** Preferences can't turn this category off (account security, say). */\n critical?: boolean;\n /** The Android channel made for it (its id is the category id). Default importance `\"default\"`. */\n android?: { importance?: ChannelImportance; name?: string };\n}\n\n/** An action button on a notification. */\nexport interface NotificationActionDef {\n id: string;\n title: string;\n /** A reply field instead of a plain button. */\n textInput?: { placeholder?: string; submitTitle?: string };\n /** Opens the app (default false: the action is handled in the background). */\n opensApp?: boolean;\n destructive?: boolean;\n /** Needs the phone unlocked. */\n requiresAuth?: boolean;\n}\n\n/** A notification category on the device (iOS's word): the actions a notification shows. */\nexport interface NotificationActionSetDef {\n actions: readonly NotificationActionDef[];\n /** Shown when the person has hidden previews for the app. */\n previewPlaceholder?: string;\n}\n\n/** What a notification says, made from its `data`. */\nexport interface RenderedText {\n title: string;\n body?: string;\n /** The app path a tap opens (`/messages/42`). */\n url?: string;\n /**\n * Push-only extras, for a type whose app reads more than the link: `ids` always travel\n * (a Reply action needs the conversation and message), `display` only when previews are\n * shown (a sender's name and photo for iOS rich push), and `mutableContent` lets the app's\n * Notification Service Extension run (dropped with hidden previews). Flat values only; the\n * notifier's own keys (`notification_id`, `type`, `url`, `thread_key`) can't be replaced.\n */\n push?: {\n ids?: Record<string, string | number>;\n display?: Record<string, string | number>;\n mutableContent?: boolean;\n };\n}\n\n/** A notification's `data`: references and display values, never a secret. */\nexport type NotificationData = Record<string, unknown>;\n\nexport interface NotificationTypeDef<C extends string = string, A extends string = string> {\n category: C;\n /**\n * The channels this type may use, and whether each is on for someone who\n * never chose: `{ in_app: true, push: true, email: false }` offers email\n * as an opt-in. A channel left out is never used.\n */\n channels: Partial<Record<Channel, boolean>>;\n /** Preferences can't turn it off: each channel it offers stays as defaulted. */\n critical?: boolean;\n /**\n * Mail the person needs for something they did (a receipt, a reset): no\n * unsubscribe link, never held by quiet hours or folded into a digest.\n * Rate caps are the domain's.\n */\n transactional?: boolean;\n /**\n * Wait this long before emailing, and skip the email if the person read\n * the notification in the app meanwhile. Default 0 (send at once).\n */\n emailDelaySeconds?: number;\n /** The device category (a key of `actions`) its pushes carry. */\n actions?: A;\n /** `\"generic\"` always sends neutral push text, whatever the person chose. */\n pushPrivacy?: \"default\" | \"generic\";\n /**\n * The email holds a secret link (a one-time token). It is never stored in\n * `data` or pushed: the server builds it at send time with\n * `resolveSecretLink(type, ref)`.\n */\n secretLink?: boolean;\n /** What the feed and the push say. */\n render: (data: NotificationData) => RenderedText;\n}\n\nexport interface NotificationsInput<\n C extends Record<string, NotificationCategoryDef>,\n A extends Record<string, NotificationActionSetDef>,\n T extends Record<string, NotificationTypeDef<Extract<keyof C, string>, Extract<keyof A, string>>>,\n> {\n categories: C;\n actions?: A;\n types: T;\n /** Neutral text for hidden previews. Default \"New notification\". */\n hiddenPreview?: { title: string; body?: string };\n}\n\n/** One row of `notification_types`, as `genesis_notifications_sync_types` takes it. */\nexport interface NotificationTypeRow {\n key: string;\n category: string;\n channels: Partial<Record<Channel, boolean>>;\n critical: boolean;\n transactional: boolean;\n email_delay_seconds: number;\n secret_link: boolean;\n push_privacy: \"default\" | \"generic\";\n ios_category: string | null;\n}\n\nexport interface MatrixRow {\n id: string;\n label: string;\n description?: string;\n critical: boolean;\n /** The channels some type in the category offers, in `CHANNELS` order. */\n channels: Channel[];\n /** Each offered channel's default: on when any type in the category has it on. */\n defaults: Partial<Record<Channel, boolean>>;\n}\n\nexport interface NotificationRegistry<\n C extends Record<string, NotificationCategoryDef> = Record<string, NotificationCategoryDef>,\n A extends Record<string, NotificationActionSetDef> = Record<string, NotificationActionSetDef>,\n T extends Record<string, NotificationTypeDef> = Record<string, NotificationTypeDef>,\n> {\n readonly categories: C;\n readonly actions: A;\n readonly types: T;\n readonly hiddenPreview: { title: string; body?: string };\n /** The type, or throws: an unknown key is a bug, never dropped quietly. */\n type(key: string): NotificationTypeDef;\n has(key: string): boolean;\n /** Preferences can't turn it off: a critical type, or a type in a critical category. */\n isCritical(key: string): boolean;\n /** What the feed and the push say. A render that throws gives the type's key as the title. */\n render(key: string, data: NotificationData): RenderedText;\n /** The rows mirrored into `notification_types`. */\n toTypeRows(): NotificationTypeRow[];\n /** For a preferences matrix: each category with the channels its types offer. */\n matrix(): MatrixRow[];\n /** The device categories to register (`setNotificationCategoryAsync`). */\n deviceCategories(): { identifier: string; actions: NotificationActionDef[]; previewPlaceholder?: string }[];\n /** The Android channels, one per category. */\n androidChannels(): { id: string; name: string; description?: string; importance: ChannelImportance }[];\n}\n\n/** Thrown by `defineNotifications`; lists every problem at once. */\nexport class NotificationRegistryError extends Error {\n constructor(readonly problems: string[]) {\n super(`Invalid notifications:\\n${problems.map((p) => ` - ${p}`).join(\"\\n\")}`);\n this.name = \"NotificationRegistryError\";\n }\n}\n\n/** The longest email delay: a week. */\nexport const MAX_EMAIL_DELAY_SECONDS = 7 * 24 * 3600;\n\n/**\n * The notification registry.\n *\n * ```ts\n * export const notifications = defineNotifications({\n * categories: { messages: { label: \"Messages\", android: { importance: \"high\" } } },\n * actions: { messages: { actions: [{ id: \"reply\", title: \"Reply\", textInput: {} }, { id: \"mark_read\", title: \"Mark as read\" }] } },\n * types: {\n * \"chat.message\": {\n * category: \"messages\",\n * channels: { in_app: true, push: true, email: true },\n * emailDelaySeconds: 600,\n * actions: \"messages\",\n * render: (d) => ({ title: String(d.sender_name), body: String(d.preview), url: `/messages/${d.conversation_id}` }),\n * },\n * },\n * });\n * ```\n *\n * @stability Beta\n */\nexport function defineNotifications<\n const C extends Record<string, NotificationCategoryDef>,\n const A extends Record<string, NotificationActionSetDef>,\n const T extends Record<string, NotificationTypeDef<Extract<keyof C, string>, Extract<keyof A, string>>>,\n>(input: NotificationsInput<C, A, T>): NotificationRegistry<C, A, T> {\n const problems: string[] = [];\n const categories = input.categories as C & Record<string, NotificationCategoryDef>;\n const actions = (input.actions ?? {}) as A & Record<string, NotificationActionSetDef>;\n const types = input.types as T & Record<string, NotificationTypeDef>;\n const own = (o: object, k: string) => Object.prototype.hasOwnProperty.call(o, k);\n\n for (const [id, c] of Object.entries(categories)) {\n if (!KEY_PATTERN.test(id)) problems.push(`category \"${id}\": an id is lower case letters, digits, \".\", \"_\" or \"-\"`);\n if (!c.label?.trim()) problems.push(`category \"${id}\": needs a label`);\n }\n for (const [id, set] of Object.entries(actions)) {\n if (!IOS_ID_PATTERN.test(id)) problems.push(`actions \"${id}\": a device category id is letters, digits or \"_\" (no \":\" or \"-\")`);\n const seen = new Set<string>();\n for (const a of set.actions) {\n if (!IOS_ID_PATTERN.test(a.id)) problems.push(`actions \"${id}\": action \"${a.id}\" is letters, digits or \"_\"`);\n if (seen.has(a.id)) problems.push(`actions \"${id}\": action \"${a.id}\" appears twice`);\n seen.add(a.id);\n }\n if (set.actions.length === 0 || set.actions.length > 4) problems.push(`actions \"${id}\": one to four actions`);\n }\n for (const [key, t] of Object.entries(types)) {\n if (!KEY_PATTERN.test(key)) problems.push(`type \"${key}\": a key is lower case letters, digits, \".\", \"_\" or \"-\"`);\n if (!own(categories, t.category)) problems.push(`type \"${key}\": unknown category \"${String(t.category)}\"`);\n const offered = Object.keys(t.channels ?? {});\n if (offered.length === 0) problems.push(`type \"${key}\": offers no channel`);\n for (const ch of offered) if (!(CHANNELS as readonly string[]).includes(ch)) problems.push(`type \"${key}\": unknown channel \"${ch}\"`);\n if (t.actions !== undefined && !own(actions, t.actions)) problems.push(`type \"${key}\": unknown actions \"${String(t.actions)}\"`);\n const delay = t.emailDelaySeconds ?? 0;\n if (!Number.isInteger(delay) || delay < 0 || delay > MAX_EMAIL_DELAY_SECONDS) {\n problems.push(`type \"${key}\": emailDelaySeconds is a whole number from 0 to ${MAX_EMAIL_DELAY_SECONDS}`);\n }\n if (delay > 0 && t.channels?.email === undefined) problems.push(`type \"${key}\": emailDelaySeconds needs the email channel`);\n if (delay > 0 && t.channels?.in_app === undefined) problems.push(`type \"${key}\": an email delay skips the mail once read in the app, so it needs the in_app channel`);\n if (t.secretLink && t.channels?.email === undefined) problems.push(`type \"${key}\": secretLink is for email, and it offers no email channel`);\n if (typeof t.render !== \"function\") problems.push(`type \"${key}\": needs a render function`);\n }\n if (problems.length) throw new NotificationRegistryError(problems);\n\n const hiddenPreview = input.hiddenPreview ?? { title: \"New notification\" };\n const typeOf = (key: string): NotificationTypeDef => {\n if (!own(types, key)) throw new NotificationRegistryError([`unknown notification type \"${key}\"`]);\n return types[key]!;\n };\n const isCritical = (key: string) => {\n const t = typeOf(key);\n return Boolean(t.critical || categories[t.category]?.critical);\n };\n\n return {\n categories,\n actions,\n types,\n hiddenPreview,\n type: typeOf,\n has: (key) => own(types, key),\n isCritical,\n render(key, data) {\n try {\n const out = typeOf(key).render(data ?? {});\n return {\n title: typeof out?.title === \"string\" && out.title ? out.title : key,\n ...(typeof out?.body === \"string\" ? { body: out.body } : {}),\n ...(typeof out?.url === \"string\" && out.url ? { url: out.url } : {}),\n ...(out?.push && typeof out.push === \"object\" ? { push: out.push } : {}),\n };\n } catch {\n return { title: key };\n }\n },\n toTypeRows() {\n return Object.entries(types).map(([key, t]) => ({\n key,\n category: t.category,\n channels: { ...t.channels },\n critical: isCritical(key),\n transactional: Boolean(t.transactional),\n email_delay_seconds: t.emailDelaySeconds ?? 0,\n secret_link: Boolean(t.secretLink),\n push_privacy: t.pushPrivacy ?? \"default\",\n ios_category: t.actions ?? null,\n }));\n },\n matrix() {\n const rows: MatrixRow[] = [];\n for (const [id, c] of Object.entries(categories)) {\n const defaults: Partial<Record<Channel, boolean>> = {};\n let critical = Boolean(c.critical);\n for (const [key, t] of Object.entries(types)) {\n if (t.category !== id) continue;\n for (const [ch, on] of Object.entries(t.channels) as [Channel, boolean][]) defaults[ch] = Boolean(defaults[ch] || on);\n if (isCritical(key)) critical = true;\n }\n const channels = CHANNELS.filter((ch) => ch in defaults);\n if (channels.length) rows.push({ id, label: c.label, ...(c.description ? { description: c.description } : {}), critical, channels, defaults });\n }\n return rows;\n },\n deviceCategories() {\n return Object.entries(actions).map(([identifier, set]) => ({\n identifier,\n actions: [...set.actions],\n ...(set.previewPlaceholder ? { previewPlaceholder: set.previewPlaceholder } : {}),\n }));\n },\n androidChannels() {\n return Object.entries(categories).map(([id, c]) => ({\n id,\n name: c.android?.name ?? c.label,\n ...(c.description ? { description: c.description } : {}),\n importance: c.android?.importance ?? \"default\",\n }));\n },\n };\n}\n\n/**\n * The actions a message notification shows: Reply (a text field) and Mark as\n * read. Put it in `actions` under the id `messages` and name that id in the\n * message type's `actions`: genesis-chat's push handler and genesis-native's\n * notification service extension both look for a `messages` category, and\n * its Mark as read is genesis-chat's `CHAT_MARK_READ_ACTION`. A tap on either reaches the push block's\n * `onResponse`; `notificationAction(response)` gives the action id, the typed\n * text and the notification id (use it as the reply's client id, so a\n * retried reply stays one reply).\n */\nexport const MESSAGE_ACTIONS = {\n actions: [\n { id: \"reply\", title: \"Reply\", textInput: { placeholder: \"Message\", submitTitle: \"Send\" } },\n { id: \"mark_read\", title: \"Mark as read\" },\n ],\n} as const satisfies NotificationActionSetDef;\n\n/** The category and type of Admin -> Send a test (`genesis.test`, which the migration creates). Spread them into your registry. */\nexport const GENESIS_TEST_CATEGORY = { genesis: { label: \"Tests\", description: \"Test notifications an admin sends themself.\" } } as const satisfies Record<string, NotificationCategoryDef>;\nexport const GENESIS_TEST_TYPE = {\n \"genesis.test\": {\n category: \"genesis\",\n channels: { in_app: true, push: true, email: true },\n critical: true,\n transactional: true,\n render: () => ({ title: \"Test notification\", body: \"If you can read this, this channel works.\" }),\n },\n} as const satisfies Record<string, NotificationTypeDef<\"genesis\">>;\n","/**\n * The shapes the notifier's parts share: what a domain passes to `notify()`,\n * a feed row as the database returns it, and a person's preferences.\n */\nimport type { Channel, NotificationData } from \"./registry\";\n\n/** A recipient with a login, by user id. */\nexport interface UserRecipient {\n userId: string;\n /** Their own app icon badge (an unread count), over the call's `badge`. */\n badge?: number;\n}\n\n/**\n * Someone with no login (an invitee, a form respondent): email only, never a\n * feed. `ref` is the domain's id for them (\"people:42\"), so erasing them\n * finds their rows.\n */\nexport interface EmailRecipient {\n email: string;\n name?: string;\n ref?: string;\n}\n\n/** A user id string, or one of the two shapes. */\nexport type Recipient = string | UserRecipient | EmailRecipient;\n\n/** A brand the caller passes (the tenant's default is used for what it leaves out). */\nexport interface NotificationBrand {\n name?: string;\n logoUrl?: string;\n /** A CSS colour (`#1d4ed8`), for the email's accent. */\n color?: string;\n /** Where a recipient goes from the email's footer. */\n url?: string;\n}\n\n/** What a domain asks for. The notifier decides channel, timing and preferences. */\nexport interface NotifyInput<K extends string = string> {\n type: K;\n recipients: readonly Recipient[];\n /** References and display values the type's render reads. Never a secret or a secret link. */\n data?: NotificationData;\n /** Unique per recipient: the same key again is the same notification, never a second one. */\n idempotencyKey: string;\n /** Groups pushes on the lock screen (iOS `threadId`): a conversation id. */\n threadKey?: string;\n /** A newer one replaces an older one with the same key that hasn't gone out (and on screen). */\n collapseKey?: string;\n /** The app icon badge, as the caller counts it. */\n badge?: number;\n /** The practice, workspace or organization it belongs to: its sender identity and defaults apply. */\n tenantId?: string;\n brand?: NotificationBrand;\n /**\n * A plaintext token that exists only hashed in the domain's tables (an\n * invite). It travels only on the email's send job, encrypted, and is\n * cleared once the email is sent; `resolveSecretLink` receives it.\n */\n secret?: string;\n}\n\n/** One row `notify()` made or found again. */\nexport interface NotifyResult {\n notificationId: string;\n recipientKey: string;\n /** False when the idempotency key had been used for this recipient already. */\n created: boolean;\n}\n\n/** A feed row, as the browser reads it under row level security. */\nexport interface NotificationRow {\n id: string;\n type: string;\n category: string;\n tenant_id: string | null;\n data: NotificationData;\n thread_key: string | null;\n created_at: string;\n seen_at: string | null;\n read_at: string | null;\n archived_at: string | null;\n}\n\n/** Delivery states. */\nexport const DELIVERY_STATUSES = [\"pending\", \"sending\", \"sent\", \"delivered\", \"failed\", \"skipped\", \"cancelled\"] as const;\nexport type DeliveryStatus = (typeof DELIVERY_STATUSES)[number];\n\n/** A person's explicit choices: category id → channel → on. Absent means the default. */\nexport type ChannelChoices = Record<string, Partial<Record<Channel, boolean>>>;\n"]}
package/dist/native.d.ts CHANGED
@@ -2,7 +2,7 @@ import * as react from 'react';
2
2
  import { NotificationsPageProps } from '@marktiderman/genesis-ui-native/layouts';
3
3
  import { CategoriesPartProps, NotificationSettingsProps, PushBackend, PushOptions } from '@marktiderman/genesis-native/push';
4
4
  export { NotificationAction, notificationAction } from '@marktiderman/genesis-native/push';
5
- import { a as Channel, l as NotificationRegistry } from './types-BilguCPW.js';
5
+ import { a as Channel, l as NotificationRegistry } from './types-ygEEDJSM.js';
6
6
  import { FeedItem } from './react.js';
7
7
  import './supabase.js';
8
8
 
@@ -74,19 +74,35 @@ declare class NotificationsPushBackendError extends Error {
74
74
  readonly code: string | null;
75
75
  constructor(operation: string, code: string | null);
76
76
  }
77
+ /** The preferences half of the push block's `PushBackend`: what Settings → Notifications reads and saves. */
78
+ type PushPreferencesBackend = Pick<PushBackend, "loadPreferences" | "savePreferences">;
77
79
  /**
78
- * The push block's `PushBackend` on genesis-notifications' migration:
79
- * registers with this install's id (so a token moves between logins only on
80
- * its own phone), and keeps the push block's category switches as each
81
- * category's push channel (`genesis_notifications_set_preference`, one cell
82
- * at a time, so nothing else in the row is overwritten). A category no
83
- * notifier type sends push in (chat's `messages` while chat still sends
80
+ * The push block's `loadPreferences` / `savePreferences` on
81
+ * genesis-notifications' migration, for an app that registers devices its
82
+ * own way: spread it into the backend and NotificationSettings (quiet hours,
83
+ * previews, the category switches) works.
84
+ *
85
+ * createPush({ backend: { register, forget, ...notificationsPushPreferences(supabase) }, … })
86
+ *
87
+ * Each category switch is that category's push channel
88
+ * (`genesis_notifications_set_preference`, one cell at a time, and only the
89
+ * ones that changed, so nothing else in the row is overwritten). A category
90
+ * no notifier type sends push in (chat's `messages` while chat still sends
84
91
  * through 0005's sender) keeps its switch in 0005's `categories`, which that
85
92
  * sender reads, instead of failing the whole save. Quiet hours and previews
86
- * are the same columns as before.
93
+ * are the row's own columns. `loadPreferences` answers `null` before the
94
+ * person saved anything, and a save row level security refuses throws.
95
+ *
96
+ * @stability Beta
97
+ */
98
+ declare function notificationsPushPreferences(client: PushBackendClientLike): PushPreferencesBackend;
99
+ /**
100
+ * The push block's `PushBackend` on genesis-notifications' migration:
101
+ * registers with this install's id (so a token moves between logins only on
102
+ * its own phone), and keeps preferences with `notificationsPushPreferences`.
87
103
  *
88
104
  * @stability Beta
89
105
  */
90
106
  declare function notificationsPushBackend(client: PushBackendClientLike): PushBackend;
91
107
 
92
- export { CHANNEL_LABELS, ChannelPreferences, NOTIFICATIONS_FEED_LIMIT, NotificationPreferences, NotificationsFeed, type NotificationsFeedProps, NotificationsPushBackendError, type PushBackendClientLike, notificationsPushBackend, pushOptionsFrom, useNotificationsFeedHeader };
108
+ export { CHANNEL_LABELS, ChannelPreferences, NOTIFICATIONS_FEED_LIMIT, NotificationPreferences, NotificationsFeed, type NotificationsFeedProps, NotificationsPushBackendError, type PushBackendClientLike, type PushPreferencesBackend, notificationsPushBackend, notificationsPushPreferences, pushOptionsFrom, useNotificationsFeedHeader };
package/dist/native.js CHANGED
@@ -98,7 +98,7 @@ function clock(value, fallback) {
98
98
  const match = typeof value === "string" ? /^(\d{2}):(\d{2})/.exec(value) : null;
99
99
  return match ? `${match[1]}:${match[2]}` : fallback;
100
100
  }
101
- function notificationsPushBackend(client) {
101
+ function notificationsPushPreferences(client) {
102
102
  const must = (operation, result) => {
103
103
  if (result.error) throw new NotificationsPushBackendError(operation, result.error.code ?? null);
104
104
  return result.data;
@@ -106,13 +106,6 @@ function notificationsPushBackend(client) {
106
106
  let known = {};
107
107
  let legacy = {};
108
108
  return {
109
- usesInstallationId: true,
110
- async register(token, platform, appVersion, installationId) {
111
- 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 }));
112
- },
113
- async forget(token) {
114
- must("genesis_push_forget", await client.rpc("genesis_push_forget", { p_token: token }));
115
- },
116
109
  async loadPreferences(userId) {
117
110
  const data = must(
118
111
  "notification_preferences",
@@ -164,7 +157,23 @@ function notificationsPushBackend(client) {
164
157
  }
165
158
  };
166
159
  }
160
+ function notificationsPushBackend(client) {
161
+ const must = (operation, result) => {
162
+ if (result.error) throw new NotificationsPushBackendError(operation, result.error.code ?? null);
163
+ return result.data;
164
+ };
165
+ return {
166
+ usesInstallationId: true,
167
+ async register(token, platform, appVersion, installationId) {
168
+ 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 }));
169
+ },
170
+ async forget(token) {
171
+ must("genesis_push_forget", await client.rpc("genesis_push_forget", { p_token: token }));
172
+ },
173
+ ...notificationsPushPreferences(client)
174
+ };
175
+ }
167
176
 
168
- export { CHANNEL_LABELS, ChannelPreferences, NOTIFICATIONS_FEED_LIMIT, NotificationPreferences, NotificationsFeed, NotificationsPushBackendError, notificationsPushBackend, pushOptionsFrom, useNotificationsFeedHeader };
177
+ export { CHANNEL_LABELS, ChannelPreferences, NOTIFICATIONS_FEED_LIMIT, NotificationPreferences, NotificationsFeed, NotificationsPushBackendError, notificationsPushBackend, notificationsPushPreferences, pushOptionsFrom, useNotificationsFeedHeader };
169
178
  //# sourceMappingURL=native.js.map
170
179
  //# sourceMappingURL=native.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/native/index.tsx"],"names":[],"mappings":"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","file":"native.js","sourcesContent":["/**\n * `@marktiderman/genesis-notifications/native`: the notifier on the phone.\n *\n * NotificationsFeed the feed on genesis-ui-native's NotificationsPage\n * useNotificationsFeedHeader the \"Mark all read\" header for the feed's Stack.Screen\n * ChannelPreferences category × channel switches, as NotificationSettings' `categories` part\n * NotificationPreferences genesis-native's NotificationSettings with that part in place\n * notificationsPushBackend the push block's `PushBackend` on this package's migration\n * pushOptionsFrom(registry) the push block's categories, Android channels and device categories\n * notificationAction a tapped action button (Reply, Mark as read), with the notification id\n *\n * The device side stays in genesis-native's push block (registration,\n * permission, routing, badge, hiding a push for the screen already open);\n * this is what connects it to the notifier.\n *\n * @packageDocumentation\n */\nimport { useMemo } from \"react\";\nimport { NativeListItem, NativeSwitch } from \"@marktiderman/genesis-ui-native/components\";\nimport { NotificationsPage, getNotificationsPageStackOptions, type NotificationItem, type NotificationsPageProps } from \"@marktiderman/genesis-ui-native/layouts\";\nimport {\n NotificationSettings,\n notificationAction,\n type CategoriesPartProps,\n type NotificationAction,\n type NotificationCategory,\n type NotificationPreferences as PushPreferences,\n type NotificationSettingsProps,\n type PushBackend,\n type PushDeviceCategory,\n type PushOptions,\n} from \"@marktiderman/genesis-native/push\";\nimport type { Channel, NotificationRegistry } from \"../core/index\";\nimport { useNotifications, usePreferences, type FeedItem } from \"../react/index\";\nimport { choicesOf } from \"../supabase/index\";\n\nexport { notificationAction, type NotificationAction };\n\nexport const CHANNEL_LABELS: Record<Channel, string> = { in_app: \"In the app\", push: \"Push\", email: \"Email\" };\n\n/** How many notifications the feed (and its header) reads: one query, shared by both. */\nexport const NOTIFICATIONS_FEED_LIMIT = 100;\n\nexport interface NotificationsFeedProps extends Omit<NotificationsPageProps, \"items\" | \"refreshing\" | \"onRefresh\" | \"onMarkAllRead\"> {\n /** Opens a notification's link (`router.push(item.url)`); it is marked read first. */\n onOpen?: (item: FeedItem) => void;\n limit?: number;\n}\n\n/**\n * The feed on ui-native's `NotificationsPage`: pull to refresh, unread dots,\n * a tap marks read and opens. Realtime refetches it (NotificationsProvider).\n *\n * @stability Beta\n */\nexport function NotificationsFeed({ onOpen, limit = NOTIFICATIONS_FEED_LIMIT, ...page }: NotificationsFeedProps) {\n const { items, isRefetching, refetch, markRead } = useNotifications({ limit });\n const rows = useMemo<NotificationItem[]>(\n () =>\n items.map((item) => ({\n id: item.id,\n title: item.title,\n ...(item.body ? { body: item.body } : {}),\n timestamp: item.created_at,\n unread: item.unread,\n onPress: () => {\n if (item.unread) void markRead(item.id);\n onOpen?.(item);\n },\n })),\n [items, markRead, onOpen],\n );\n return (\n <NotificationsPage\n testID=\"notifications\"\n {...page}\n items={rows}\n refreshing={isRefetching}\n onRefresh={async () => {\n await refetch();\n }}\n />\n );\n}\n\n/**\n * The feed's `Stack.Screen` options, with \"Mark all read\" while anything is\n * unread. Pass the feed's `limit` if you changed it, so both read one query.\n */\nexport function useNotificationsFeedHeader(options: { headerTitle?: string; limit?: number } = {}): Record<string, unknown> {\n const { items, markAllRead } = useNotifications({ limit: options.limit ?? NOTIFICATIONS_FEED_LIMIT });\n const unread = items.some((i) => i.unread);\n // With nothing unread the header gets an explicit empty right side: leaving it out would keep the old button.\n return getNotificationsPageStackOptions({\n headerTitle: options.headerTitle,\n ...(unread ? { onMarkAllRead: () => void markAllRead() } : { headerRight: () => null }),\n });\n}\n\n/**\n * Category × channel switches (push and email, and the feed when a type\n * offers it as optional), for NotificationSettings' `categories` slot. It\n * ignores the push block's own category switches and reads the notifier's\n * preferences instead.\n *\n * @stability Beta\n */\nexport function ChannelPreferences(_props: Partial<CategoriesPartProps> & { channelLabels?: Partial<Record<Channel, string>> }) {\n const { rows, status, set } = usePreferences();\n const labels = { ...CHANNEL_LABELS, ..._props.channelLabels };\n return (\n <>\n {rows.flatMap((row) =>\n row.channels.map((ch) => (\n <NativeListItem\n key={`${row.id}:${ch}`}\n testID={`notification-preference-${row.id}-${ch}`}\n title={row.label}\n subtitle={row.critical ? `${labels[ch]} · Always on` : labels[ch]}\n trailing={\n <NativeSwitch\n testID={`notification-preference-${row.id}-${ch}-switch`}\n accessibilityLabel={`${row.label}: ${labels[ch]}`}\n checked={Boolean(row.values[ch])}\n disabled={status !== \"ready\" || row.critical}\n onCheckedChange={(on) => void set(row.id, ch, on).catch(() => undefined)}\n />\n }\n />\n )),\n )}\n </>\n );\n}\n\n/**\n * Settings → Notifications with the notifier's channel switches: the push\n * block's permission, quiet hours and previews, and `ChannelPreferences` for\n * the categories. Needs the push block and NotificationsProvider.\n *\n * @stability Beta\n */\nexport function NotificationPreferences(props: NotificationSettingsProps) {\n return <NotificationSettings {...props} slots={{ categories: ChannelPreferences, ...props.slots }} />;\n}\n\n/**\n * What the push block needs from the registry: a category per notification\n * category (its Android channel; NotificationSettings shows the categories\n * section only when there are some), and the device categories with their\n * action buttons.\n *\n * @stability Beta\n */\nexport function pushOptionsFrom(registry: NotificationRegistry): Pick<PushOptions, \"categories\" | \"deviceCategories\"> {\n const categories: NotificationCategory[] = registry.androidChannels().map((c) => ({\n id: c.id,\n title: c.name,\n ...(c.description ? { description: c.description } : {}),\n importance: c.importance,\n }));\n const deviceCategories: PushDeviceCategory[] = registry.deviceCategories();\n return { categories, deviceCategories };\n}\n\ntype Result = { data: unknown; error: { code?: string | null } | null };\n\n/** The parts of a Supabase client the backend uses. */\nexport interface PushBackendClientLike {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n rpc: (fn: any, args?: any) => PromiseLike<Result>;\n from(table: string): unknown;\n}\n\ninterface PreferencesQuery {\n select(columns: string): { eq(column: string, value: string): { maybeSingle(): PromiseLike<Result> } };\n upsert(row: Record<string, unknown>, options: { onConflict: string }): { select(columns: string): PromiseLike<Result> };\n}\n\n/** A database refusal, with its SQLSTATE; never a token. */\nexport class NotificationsPushBackendError extends Error {\n constructor(\n readonly operation: string,\n readonly code: string | null,\n ) {\n super(`${operation} failed${code ? ` (${code})` : \"\"}`);\n this.name = \"NotificationsPushBackendError\";\n }\n}\n\nfunction clock(value: unknown, fallback: string): string {\n const match = typeof value === \"string\" ? /^(\\d{2}):(\\d{2})/.exec(value) : null;\n return match ? `${match[1]}:${match[2]}` : fallback;\n}\n\n/**\n * The push block's `PushBackend` on genesis-notifications' migration:\n * registers with this install's id (so a token moves between logins only on\n * its own phone), and keeps the push block's category switches as each\n * category's push channel (`genesis_notifications_set_preference`, one cell\n * at a time, so nothing else in the row is overwritten). A category no\n * notifier type sends push in (chat's `messages` while chat still sends\n * through 0005's sender) keeps its switch in 0005's `categories`, which that\n * sender reads, instead of failing the whole save. Quiet hours and previews\n * are the same columns as before.\n *\n * @stability Beta\n */\nexport function notificationsPushBackend(client: PushBackendClientLike): PushBackend {\n const must = (operation: string, result: Result) => {\n if (result.error) throw new NotificationsPushBackendError(operation, result.error.code ?? null);\n return result.data;\n };\n // The push channel per category as last loaded or saved, so a save writes only what changed.\n let known: Record<string, boolean> = {};\n // 0005's `categories` as last loaded or saved: the switches of categories the notifier has no push type in.\n let legacy: Record<string, boolean> = {};\n return {\n usesInstallationId: true,\n async register(token, platform, appVersion, installationId) {\n 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 }));\n },\n async forget(token) {\n must(\"genesis_push_forget\", await client.rpc(\"genesis_push_forget\", { p_token: token }));\n },\n async loadPreferences(userId) {\n const data = must(\n \"notification_preferences\",\n await (client.from(\"notification_preferences\") as PreferencesQuery)\n .select(\"channels, categories, quiet_hours_enabled, quiet_hours_start, quiet_hours_end, time_zone, hide_previews\")\n .eq(\"user_id\", userId)\n .maybeSingle(),\n ) as Record<string, unknown> | null;\n known = {};\n legacy = {};\n if (!data) return null;\n if (data.categories && typeof data.categories === \"object\") {\n for (const [category, on] of Object.entries(data.categories as Record<string, unknown>)) if (typeof on === \"boolean\") legacy[category] = on;\n }\n // As the server reads it: the push channel chosen, or 0005's switch for the whole category.\n for (const [category, row] of Object.entries(choicesOf(data))) if (typeof row.push === \"boolean\") known[category] = row.push;\n return {\n categories: { ...known },\n quietHours: {\n enabled: data.quiet_hours_enabled === true,\n start: clock(data.quiet_hours_start, \"22:00\"),\n end: clock(data.quiet_hours_end, \"07:00\"),\n timeZone: typeof data.time_zone === \"string\" ? data.time_zone : null,\n },\n hidePreviews: data.hide_previews === true,\n } satisfies PushPreferences;\n },\n async savePreferences(userId, p) {\n let legacyChanged = false;\n for (const [category, on] of Object.entries(p.categories)) {\n if (known[category] === on) continue;\n const result = await client.rpc(\"genesis_notifications_set_preference\", { p_category: category, p_channel: \"push\", p_on: on });\n // 42704 is only \"no type in that category uses push\"; any other refusal (a critical category, 22023) is thrown.\n if (result.error?.code === \"42704\") {\n // No notifier type sends push in this category: keep the switch where 0005's sender reads it.\n legacy[category] = on;\n legacyChanged = true;\n } else {\n must(\"genesis_notifications_set_preference\", result);\n // The server moved this category's 0005 switch into channel choices.\n if (category in legacy) delete legacy[category];\n }\n known[category] = on;\n }\n const row = {\n ...(legacyChanged ? { categories: { ...legacy } } : {}),\n user_id: userId,\n quiet_hours_enabled: p.quietHours.enabled,\n quiet_hours_start: p.quietHours.start,\n quiet_hours_end: p.quietHours.end,\n time_zone: p.quietHours.timeZone,\n hide_previews: p.hidePreviews,\n };\n const data = must(\"notification_preferences\", await (client.from(\"notification_preferences\") as PreferencesQuery).upsert(row, { onConflict: \"user_id\" }).select(\"user_id\"));\n if (!Array.isArray(data) || data.length !== 1) throw new NotificationsPushBackendError(\"notification_preferences\", \"no_rows\");\n },\n };\n}\n"]}
1
+ {"version":3,"sources":["../src/native/index.tsx"],"names":[],"mappings":"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","file":"native.js","sourcesContent":["/**\n * `@marktiderman/genesis-notifications/native`: the notifier on the phone.\n *\n * NotificationsFeed the feed on genesis-ui-native's NotificationsPage\n * useNotificationsFeedHeader the \"Mark all read\" header for the feed's Stack.Screen\n * ChannelPreferences category × channel switches, as NotificationSettings' `categories` part\n * NotificationPreferences genesis-native's NotificationSettings with that part in place\n * notificationsPushBackend the push block's `PushBackend` on this package's migration\n * notificationsPushPreferences only its preferences half, beside an app's own register / forget\n * pushOptionsFrom(registry) the push block's categories, Android channels and device categories\n * notificationAction a tapped action button (Reply, Mark as read), with the notification id\n *\n * The device side stays in genesis-native's push block (registration,\n * permission, routing, badge, hiding a push for the screen already open);\n * this is what connects it to the notifier.\n *\n * @packageDocumentation\n */\nimport { useMemo } from \"react\";\nimport { NativeListItem, NativeSwitch } from \"@marktiderman/genesis-ui-native/components\";\nimport { NotificationsPage, getNotificationsPageStackOptions, type NotificationItem, type NotificationsPageProps } from \"@marktiderman/genesis-ui-native/layouts\";\nimport {\n NotificationSettings,\n notificationAction,\n type CategoriesPartProps,\n type NotificationAction,\n type NotificationCategory,\n type NotificationPreferences as PushPreferences,\n type NotificationSettingsProps,\n type PushBackend,\n type PushDeviceCategory,\n type PushOptions,\n} from \"@marktiderman/genesis-native/push\";\nimport type { Channel, NotificationRegistry } from \"../core/index\";\nimport { useNotifications, usePreferences, type FeedItem } from \"../react/index\";\nimport { choicesOf } from \"../supabase/index\";\n\nexport { notificationAction, type NotificationAction };\n\nexport const CHANNEL_LABELS: Record<Channel, string> = { in_app: \"In the app\", push: \"Push\", email: \"Email\" };\n\n/** How many notifications the feed (and its header) reads: one query, shared by both. */\nexport const NOTIFICATIONS_FEED_LIMIT = 100;\n\nexport interface NotificationsFeedProps extends Omit<NotificationsPageProps, \"items\" | \"refreshing\" | \"onRefresh\" | \"onMarkAllRead\"> {\n /** Opens a notification's link (`router.push(item.url)`); it is marked read first. */\n onOpen?: (item: FeedItem) => void;\n limit?: number;\n}\n\n/**\n * The feed on ui-native's `NotificationsPage`: pull to refresh, unread dots,\n * a tap marks read and opens. Realtime refetches it (NotificationsProvider).\n *\n * @stability Beta\n */\nexport function NotificationsFeed({ onOpen, limit = NOTIFICATIONS_FEED_LIMIT, ...page }: NotificationsFeedProps) {\n const { items, isRefetching, refetch, markRead } = useNotifications({ limit });\n const rows = useMemo<NotificationItem[]>(\n () =>\n items.map((item) => ({\n id: item.id,\n title: item.title,\n ...(item.body ? { body: item.body } : {}),\n timestamp: item.created_at,\n unread: item.unread,\n onPress: () => {\n if (item.unread) void markRead(item.id);\n onOpen?.(item);\n },\n })),\n [items, markRead, onOpen],\n );\n return (\n <NotificationsPage\n testID=\"notifications\"\n {...page}\n items={rows}\n refreshing={isRefetching}\n onRefresh={async () => {\n await refetch();\n }}\n />\n );\n}\n\n/**\n * The feed's `Stack.Screen` options, with \"Mark all read\" while anything is\n * unread. Pass the feed's `limit` if you changed it, so both read one query.\n */\nexport function useNotificationsFeedHeader(options: { headerTitle?: string; limit?: number } = {}): Record<string, unknown> {\n const { items, markAllRead } = useNotifications({ limit: options.limit ?? NOTIFICATIONS_FEED_LIMIT });\n const unread = items.some((i) => i.unread);\n // With nothing unread the header gets an explicit empty right side: leaving it out would keep the old button.\n return getNotificationsPageStackOptions({\n headerTitle: options.headerTitle,\n ...(unread ? { onMarkAllRead: () => void markAllRead() } : { headerRight: () => null }),\n });\n}\n\n/**\n * Category × channel switches (push and email, and the feed when a type\n * offers it as optional), for NotificationSettings' `categories` slot. It\n * ignores the push block's own category switches and reads the notifier's\n * preferences instead.\n *\n * @stability Beta\n */\nexport function ChannelPreferences(_props: Partial<CategoriesPartProps> & { channelLabels?: Partial<Record<Channel, string>> }) {\n const { rows, status, set } = usePreferences();\n const labels = { ...CHANNEL_LABELS, ..._props.channelLabels };\n return (\n <>\n {rows.flatMap((row) =>\n row.channels.map((ch) => (\n <NativeListItem\n key={`${row.id}:${ch}`}\n testID={`notification-preference-${row.id}-${ch}`}\n title={row.label}\n subtitle={row.critical ? `${labels[ch]} · Always on` : labels[ch]}\n trailing={\n <NativeSwitch\n testID={`notification-preference-${row.id}-${ch}-switch`}\n accessibilityLabel={`${row.label}: ${labels[ch]}`}\n checked={Boolean(row.values[ch])}\n disabled={status !== \"ready\" || row.critical}\n onCheckedChange={(on) => void set(row.id, ch, on).catch(() => undefined)}\n />\n }\n />\n )),\n )}\n </>\n );\n}\n\n/**\n * Settings → Notifications with the notifier's channel switches: the push\n * block's permission, quiet hours and previews, and `ChannelPreferences` for\n * the categories. Needs the push block and NotificationsProvider.\n *\n * @stability Beta\n */\nexport function NotificationPreferences(props: NotificationSettingsProps) {\n return <NotificationSettings {...props} slots={{ categories: ChannelPreferences, ...props.slots }} />;\n}\n\n/**\n * What the push block needs from the registry: a category per notification\n * category (its Android channel; NotificationSettings shows the categories\n * section only when there are some), and the device categories with their\n * action buttons.\n *\n * @stability Beta\n */\nexport function pushOptionsFrom(registry: NotificationRegistry): Pick<PushOptions, \"categories\" | \"deviceCategories\"> {\n const categories: NotificationCategory[] = registry.androidChannels().map((c) => ({\n id: c.id,\n title: c.name,\n ...(c.description ? { description: c.description } : {}),\n importance: c.importance,\n }));\n const deviceCategories: PushDeviceCategory[] = registry.deviceCategories();\n return { categories, deviceCategories };\n}\n\ntype Result = { data: unknown; error: { code?: string | null } | null };\n\n/** The parts of a Supabase client the backend uses. */\nexport interface PushBackendClientLike {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n rpc: (fn: any, args?: any) => PromiseLike<Result>;\n from(table: string): unknown;\n}\n\ninterface PreferencesQuery {\n select(columns: string): { eq(column: string, value: string): { maybeSingle(): PromiseLike<Result> } };\n upsert(row: Record<string, unknown>, options: { onConflict: string }): { select(columns: string): PromiseLike<Result> };\n}\n\n/** A database refusal, with its SQLSTATE; never a token. */\nexport class NotificationsPushBackendError extends Error {\n constructor(\n readonly operation: string,\n readonly code: string | null,\n ) {\n super(`${operation} failed${code ? ` (${code})` : \"\"}`);\n this.name = \"NotificationsPushBackendError\";\n }\n}\n\nfunction clock(value: unknown, fallback: string): string {\n const match = typeof value === \"string\" ? /^(\\d{2}):(\\d{2})/.exec(value) : null;\n return match ? `${match[1]}:${match[2]}` : fallback;\n}\n\n/** The preferences half of the push block's `PushBackend`: what Settings → Notifications reads and saves. */\nexport type PushPreferencesBackend = Pick<PushBackend, \"loadPreferences\" | \"savePreferences\">;\n\n/**\n * The push block's `loadPreferences` / `savePreferences` on\n * genesis-notifications' migration, for an app that registers devices its\n * own way: spread it into the backend and NotificationSettings (quiet hours,\n * previews, the category switches) works.\n *\n * createPush({ backend: { register, forget, ...notificationsPushPreferences(supabase) }, … })\n *\n * Each category switch is that category's push channel\n * (`genesis_notifications_set_preference`, one cell at a time, and only the\n * ones that changed, so nothing else in the row is overwritten). A category\n * no notifier type sends push in (chat's `messages` while chat still sends\n * through 0005's sender) keeps its switch in 0005's `categories`, which that\n * sender reads, instead of failing the whole save. Quiet hours and previews\n * are the row's own columns. `loadPreferences` answers `null` before the\n * person saved anything, and a save row level security refuses throws.\n *\n * @stability Beta\n */\nexport function notificationsPushPreferences(client: PushBackendClientLike): PushPreferencesBackend {\n const must = (operation: string, result: Result) => {\n if (result.error) throw new NotificationsPushBackendError(operation, result.error.code ?? null);\n return result.data;\n };\n // The push channel per category as last loaded or saved, so a save writes only what changed.\n let known: Record<string, boolean> = {};\n // 0005's `categories` as last loaded or saved: the switches of categories the notifier has no push type in.\n let legacy: Record<string, boolean> = {};\n return {\n async loadPreferences(userId) {\n const data = must(\n \"notification_preferences\",\n await (client.from(\"notification_preferences\") as PreferencesQuery)\n .select(\"channels, categories, quiet_hours_enabled, quiet_hours_start, quiet_hours_end, time_zone, hide_previews\")\n .eq(\"user_id\", userId)\n .maybeSingle(),\n ) as Record<string, unknown> | null;\n known = {};\n legacy = {};\n if (!data) return null;\n if (data.categories && typeof data.categories === \"object\") {\n for (const [category, on] of Object.entries(data.categories as Record<string, unknown>)) if (typeof on === \"boolean\") legacy[category] = on;\n }\n // As the server reads it: the push channel chosen, or 0005's switch for the whole category.\n for (const [category, row] of Object.entries(choicesOf(data))) if (typeof row.push === \"boolean\") known[category] = row.push;\n return {\n categories: { ...known },\n quietHours: {\n enabled: data.quiet_hours_enabled === true,\n start: clock(data.quiet_hours_start, \"22:00\"),\n end: clock(data.quiet_hours_end, \"07:00\"),\n timeZone: typeof data.time_zone === \"string\" ? data.time_zone : null,\n },\n hidePreviews: data.hide_previews === true,\n } satisfies PushPreferences;\n },\n async savePreferences(userId, p) {\n let legacyChanged = false;\n for (const [category, on] of Object.entries(p.categories)) {\n if (known[category] === on) continue;\n const result = await client.rpc(\"genesis_notifications_set_preference\", { p_category: category, p_channel: \"push\", p_on: on });\n // 42704 is only \"no type in that category uses push\"; any other refusal (a critical category, 22023) is thrown.\n if (result.error?.code === \"42704\") {\n // No notifier type sends push in this category: keep the switch where 0005's sender reads it.\n legacy[category] = on;\n legacyChanged = true;\n } else {\n must(\"genesis_notifications_set_preference\", result);\n // The server moved this category's 0005 switch into channel choices.\n if (category in legacy) delete legacy[category];\n }\n known[category] = on;\n }\n const row = {\n ...(legacyChanged ? { categories: { ...legacy } } : {}),\n user_id: userId,\n quiet_hours_enabled: p.quietHours.enabled,\n quiet_hours_start: p.quietHours.start,\n quiet_hours_end: p.quietHours.end,\n time_zone: p.quietHours.timeZone,\n hide_previews: p.hidePreviews,\n };\n const data = must(\"notification_preferences\", await (client.from(\"notification_preferences\") as PreferencesQuery).upsert(row, { onConflict: \"user_id\" }).select(\"user_id\"));\n // Row level security turns a write it refuses into zero rows, not an error.\n if (!Array.isArray(data) || data.length !== 1) throw new NotificationsPushBackendError(\"notification_preferences\", \"no_rows\");\n },\n };\n}\n\n/**\n * The push block's `PushBackend` on genesis-notifications' migration:\n * registers with this install's id (so a token moves between logins only on\n * its own phone), and keeps preferences with `notificationsPushPreferences`.\n *\n * @stability Beta\n */\nexport function notificationsPushBackend(client: PushBackendClientLike): PushBackend {\n const must = (operation: string, result: Result) => {\n if (result.error) throw new NotificationsPushBackendError(operation, result.error.code ?? null);\n return result.data;\n };\n return {\n usesInstallationId: true,\n async register(token, platform, appVersion, installationId) {\n 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 }));\n },\n async forget(token) {\n must(\"genesis_push_forget\", await client.rpc(\"genesis_push_forget\", { p_token: token }));\n },\n ...notificationsPushPreferences(client),\n };\n}\n"]}
package/dist/react.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
- import { n as NotificationRow, l as NotificationRegistry, g as MatrixRow, a as Channel, b as ChannelChoices } from './types-BilguCPW.js';
3
+ import { n as NotificationRow, l as NotificationRegistry, g as MatrixRow, a as Channel, b as ChannelChoices } from './types-ygEEDJSM.js';
4
4
  import { NotificationsSupabaseLike, NotificationsClient, FeedFilter } from './supabase.js';
5
5
  export { notificationsClient } from './supabase.js';
6
6
 
package/dist/server.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { a as Channel, p as NotificationTypeRow, R as Recipient, l as NotificationRegistry, r as NotifyInput, s as NotifyResult } from './types-BilguCPW.js';
1
+ import { a as Channel, p as NotificationTypeRow, R as Recipient, l as NotificationRegistry, r as NotifyInput, s as NotifyResult } from './types-ygEEDJSM.js';
2
2
  import { RunReport } from './runner.js';
3
- import { a as EmailTemplate } from './email-render-CID_pXDy.js';
4
- export { b as EmailContent, E as EmailTemplateContext, c as canonicalJson, d as defaultEmailTemplate, e as displayName, f as escapeHtml, h as headerSafe, i as isSafeAddress } from './email-render-CID_pXDy.js';
3
+ import { a as EmailTemplate } from './email-render-DsJ1cM5P.js';
4
+ export { b as EmailContent, E as EmailTemplateContext, c as canonicalJson, d as defaultEmailTemplate, e as displayName, f as escapeHtml, h as headerSafe, i as isSafeAddress } from './email-render-DsJ1cM5P.js';
5
5
 
6
6
  /**
7
7
  * The database half, through the service role: what the runner and the
@@ -1,4 +1,4 @@
1
- import { n as NotificationRow, b as ChannelChoices, a as Channel } from './types-BilguCPW.js';
1
+ import { n as NotificationRow, b as ChannelChoices, a as Channel } from './types-ygEEDJSM.js';
2
2
 
3
3
  /**
4
4
  * `@marktiderman/genesis-notifications/supabase`: the migration this package
@@ -186,13 +186,13 @@ declare const MAX_EMAIL_DELAY_SECONDS: number;
186
186
  * ```ts
187
187
  * export const notifications = defineNotifications({
188
188
  * categories: { messages: { label: "Messages", android: { importance: "high" } } },
189
- * actions: { message: { actions: [{ id: "reply", title: "Reply", textInput: {} }, { id: "mark_read", title: "Mark as read" }] } },
189
+ * actions: { messages: { actions: [{ id: "reply", title: "Reply", textInput: {} }, { id: "mark_read", title: "Mark as read" }] } },
190
190
  * types: {
191
191
  * "chat.message": {
192
192
  * category: "messages",
193
193
  * channels: { in_app: true, push: true, email: true },
194
194
  * emailDelaySeconds: 600,
195
- * actions: "message",
195
+ * actions: "messages",
196
196
  * render: (d) => ({ title: String(d.sender_name), body: String(d.preview), url: `/messages/${d.conversation_id}` }),
197
197
  * },
198
198
  * },
@@ -204,8 +204,10 @@ declare const MAX_EMAIL_DELAY_SECONDS: number;
204
204
  declare function defineNotifications<const C extends Record<string, NotificationCategoryDef>, const A extends Record<string, NotificationActionSetDef>, const T extends Record<string, NotificationTypeDef<Extract<keyof C, string>, Extract<keyof A, string>>>>(input: NotificationsInput<C, A, T>): NotificationRegistry<C, A, T>;
205
205
  /**
206
206
  * The actions a message notification shows: Reply (a text field) and Mark as
207
- * read. Put it in `actions` under an id (`message`) and name that id in the
208
- * message type's `actions`. A tap on either reaches the push block's
207
+ * read. Put it in `actions` under the id `messages` and name that id in the
208
+ * message type's `actions`: genesis-chat's push handler and genesis-native's
209
+ * notification service extension both look for a `messages` category, and
210
+ * its Mark as read is genesis-chat's `CHAT_MARK_READ_ACTION`. A tap on either reaches the push block's
209
211
  * `onResponse`; `notificationAction(response)` gives the action id, the typed
210
212
  * text and the notification id (use it as the reply's client id, so a
211
213
  * retried reply stays one reply).
package/dist/web.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
- import { a as Channel } from './types-BilguCPW.js';
3
+ import { a as Channel } from './types-ygEEDJSM.js';
4
4
  import { FeedItem } from './react.js';
5
5
  import './supabase.js';
6
6
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marktiderman/genesis-notifications",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -78,12 +78,12 @@
78
78
  "README.md"
79
79
  ],
80
80
  "dependencies": {
81
- "@marktiderman/genesis-features": "^0.3.0"
81
+ "@marktiderman/genesis-features": "^0.4.1"
82
82
  },
83
83
  "peerDependencies": {
84
84
  "@marktiderman/genesis-native": ">=0.2.0 <1.0.0",
85
- "@marktiderman/genesis-ui": "^2.13.0",
86
- "@marktiderman/genesis-ui-native": "^2.4.0",
85
+ "@marktiderman/genesis-ui": "^2.17.0",
86
+ "@marktiderman/genesis-ui-native": "^2.5.0",
87
87
  "@react-email/components": "^1.0.0",
88
88
  "@tanstack/react-query": "^5.0.0",
89
89
  "lucide-react": "^1.0.0",
@@ -113,9 +113,9 @@
113
113
  }
114
114
  },
115
115
  "devDependencies": {
116
- "@marktiderman/genesis-native": "^0.4.0",
117
- "@marktiderman/genesis-ui": "^2.13.0",
118
- "@marktiderman/genesis-ui-native": "^2.4.0",
116
+ "@marktiderman/genesis-native": "^0.4.2",
117
+ "@marktiderman/genesis-ui": "^2.17.0",
118
+ "@marktiderman/genesis-ui-native": "^2.5.0",
119
119
  "@react-email/components": "1.0.12",
120
120
  "@tanstack/react-query": "^5.75.0",
121
121
  "@testing-library/react": "^16.3.2",
@@ -191,13 +191,13 @@ export const MAX_EMAIL_DELAY_SECONDS = 7 * 24 * 3600;
191
191
  * ```ts
192
192
  * export const notifications = defineNotifications({
193
193
  * categories: { messages: { label: "Messages", android: { importance: "high" } } },
194
- * actions: { message: { actions: [{ id: "reply", title: "Reply", textInput: {} }, { id: "mark_read", title: "Mark as read" }] } },
194
+ * actions: { messages: { actions: [{ id: "reply", title: "Reply", textInput: {} }, { id: "mark_read", title: "Mark as read" }] } },
195
195
  * types: {
196
196
  * "chat.message": {
197
197
  * category: "messages",
198
198
  * channels: { in_app: true, push: true, email: true },
199
199
  * emailDelaySeconds: 600,
200
- * actions: "message",
200
+ * actions: "messages",
201
201
  * render: (d) => ({ title: String(d.sender_name), body: String(d.preview), url: `/messages/${d.conversation_id}` }),
202
202
  * },
203
203
  * },
@@ -328,8 +328,10 @@ export function defineNotifications<
328
328
 
329
329
  /**
330
330
  * The actions a message notification shows: Reply (a text field) and Mark as
331
- * read. Put it in `actions` under an id (`message`) and name that id in the
332
- * message type's `actions`. A tap on either reaches the push block's
331
+ * read. Put it in `actions` under the id `messages` and name that id in the
332
+ * message type's `actions`: genesis-chat's push handler and genesis-native's
333
+ * notification service extension both look for a `messages` category, and
334
+ * its Mark as read is genesis-chat's `CHAT_MARK_READ_ACTION`. A tap on either reaches the push block's
333
335
  * `onResponse`; `notificationAction(response)` gives the action id, the typed
334
336
  * text and the notification id (use it as the reply's client id, so a
335
337
  * retried reply stays one reply).
@@ -6,6 +6,7 @@
6
6
  * ChannelPreferences category × channel switches, as NotificationSettings' `categories` part
7
7
  * NotificationPreferences genesis-native's NotificationSettings with that part in place
8
8
  * notificationsPushBackend the push block's `PushBackend` on this package's migration
9
+ * notificationsPushPreferences only its preferences half, beside an app's own register / forget
9
10
  * pushOptionsFrom(registry) the push block's categories, Android channels and device categories
10
11
  * notificationAction a tapped action button (Reply, Mark as read), with the notification id
11
12
  *
@@ -193,20 +194,29 @@ function clock(value: unknown, fallback: string): string {
193
194
  return match ? `${match[1]}:${match[2]}` : fallback;
194
195
  }
195
196
 
197
+ /** The preferences half of the push block's `PushBackend`: what Settings → Notifications reads and saves. */
198
+ export type PushPreferencesBackend = Pick<PushBackend, "loadPreferences" | "savePreferences">;
199
+
196
200
  /**
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
201
+ * The push block's `loadPreferences` / `savePreferences` on
202
+ * genesis-notifications' migration, for an app that registers devices its
203
+ * own way: spread it into the backend and NotificationSettings (quiet hours,
204
+ * previews, the category switches) works.
205
+ *
206
+ * createPush({ backend: { register, forget, ...notificationsPushPreferences(supabase) }, … })
207
+ *
208
+ * Each category switch is that category's push channel
209
+ * (`genesis_notifications_set_preference`, one cell at a time, and only the
210
+ * ones that changed, so nothing else in the row is overwritten). A category
211
+ * no notifier type sends push in (chat's `messages` while chat still sends
203
212
  * through 0005's sender) keeps its switch in 0005's `categories`, which that
204
213
  * sender reads, instead of failing the whole save. Quiet hours and previews
205
- * are the same columns as before.
214
+ * are the row's own columns. `loadPreferences` answers `null` before the
215
+ * person saved anything, and a save row level security refuses throws.
206
216
  *
207
217
  * @stability Beta
208
218
  */
209
- export function notificationsPushBackend(client: PushBackendClientLike): PushBackend {
219
+ export function notificationsPushPreferences(client: PushBackendClientLike): PushPreferencesBackend {
210
220
  const must = (operation: string, result: Result) => {
211
221
  if (result.error) throw new NotificationsPushBackendError(operation, result.error.code ?? null);
212
222
  return result.data;
@@ -216,13 +226,6 @@ export function notificationsPushBackend(client: PushBackendClientLike): PushBac
216
226
  // 0005's `categories` as last loaded or saved: the switches of categories the notifier has no push type in.
217
227
  let legacy: Record<string, boolean> = {};
218
228
  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
229
  async loadPreferences(userId) {
227
230
  const data = must(
228
231
  "notification_preferences",
@@ -277,7 +280,32 @@ export function notificationsPushBackend(client: PushBackendClientLike): PushBac
277
280
  hide_previews: p.hidePreviews,
278
281
  };
279
282
  const data = must("notification_preferences", await (client.from("notification_preferences") as PreferencesQuery).upsert(row, { onConflict: "user_id" }).select("user_id"));
283
+ // Row level security turns a write it refuses into zero rows, not an error.
280
284
  if (!Array.isArray(data) || data.length !== 1) throw new NotificationsPushBackendError("notification_preferences", "no_rows");
281
285
  },
282
286
  };
283
287
  }
288
+
289
+ /**
290
+ * The push block's `PushBackend` on genesis-notifications' migration:
291
+ * registers with this install's id (so a token moves between logins only on
292
+ * its own phone), and keeps preferences with `notificationsPushPreferences`.
293
+ *
294
+ * @stability Beta
295
+ */
296
+ export function notificationsPushBackend(client: PushBackendClientLike): PushBackend {
297
+ const must = (operation: string, result: Result) => {
298
+ if (result.error) throw new NotificationsPushBackendError(operation, result.error.code ?? null);
299
+ return result.data;
300
+ };
301
+ return {
302
+ usesInstallationId: true,
303
+ async register(token, platform, appVersion, installationId) {
304
+ 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 }));
305
+ },
306
+ async forget(token) {
307
+ must("genesis_push_forget", await client.rpc("genesis_push_forget", { p_token: token }));
308
+ },
309
+ ...notificationsPushPreferences(client),
310
+ };
311
+ }