@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.
- package/LICENSE +21 -0
- package/README.md +251 -2
- package/dist/blocks.d.ts +10 -0
- package/dist/blocks.js +35 -0
- package/dist/blocks.js.map +1 -0
- package/dist/chunk-2BG336II.js +124 -0
- package/dist/chunk-2BG336II.js.map +1 -0
- package/dist/chunk-7PHA3WVG.js +51 -0
- package/dist/chunk-7PHA3WVG.js.map +1 -0
- package/dist/chunk-EWZB35V6.js +113 -0
- package/dist/chunk-EWZB35V6.js.map +1 -0
- package/dist/chunk-UXNWI7OS.js +118 -0
- package/dist/chunk-UXNWI7OS.js.map +1 -0
- package/dist/email-render-CID_pXDy.d.ts +53 -0
- package/dist/email.d.ts +46 -0
- package/dist/email.js +64 -0
- package/dist/email.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +151 -0
- package/dist/index.js.map +1 -0
- package/dist/native.d.ts +92 -0
- package/dist/native.js +170 -0
- package/dist/native.js.map +1 -0
- package/dist/react.d.ts +98 -0
- package/dist/react.js +4 -0
- package/dist/react.js.map +1 -0
- package/dist/runner.d.ts +174 -0
- package/dist/runner.js +3 -0
- package/dist/runner.js.map +1 -0
- package/dist/server.d.ts +658 -0
- package/dist/server.js +995 -0
- package/dist/server.js.map +1 -0
- package/dist/supabase.d.ts +118 -0
- package/dist/supabase.js +3 -0
- package/dist/supabase.js.map +1 -0
- package/dist/types-BilguCPW.d.ts +332 -0
- package/dist/web.d.ts +79 -0
- package/dist/web.js +233 -0
- package/dist/web.js.map +1 -0
- package/functions/genesis-notifications/index.ts +60 -0
- package/functions/genesis-notifications/notifications.ts +18 -0
- package/migrations/0001_genesis_notifications.sql +2057 -0
- package/migrations/tests/0001_genesis_notifications.test.sql +473 -0
- package/package.json +191 -3
- package/src/blocks.ts +42 -0
- package/src/core/index.ts +43 -0
- package/src/core/registry.ts +354 -0
- package/src/core/types.ts +90 -0
- package/src/core/validate.ts +73 -0
- package/src/email/index.tsx +142 -0
- package/src/index.ts +6 -0
- package/src/native/index.tsx +283 -0
- package/src/react/index.tsx +226 -0
- package/src/runner/index.ts +299 -0
- package/src/server/crypto.ts +44 -0
- package/src/server/dns.ts +106 -0
- package/src/server/email-render.ts +90 -0
- package/src/server/errors.ts +61 -0
- package/src/server/expo.ts +133 -0
- package/src/server/handler.ts +127 -0
- package/src/server/index.ts +57 -0
- package/src/server/notify.ts +100 -0
- package/src/server/rate.ts +47 -0
- package/src/server/resend.ts +143 -0
- package/src/server/runner.ts +524 -0
- package/src/server/secret-links.ts +53 -0
- package/src/server/store.ts +150 -0
- package/src/server/unsubscribe.ts +78 -0
- package/src/server/webhook.ts +55 -0
- package/src/supabase/index.ts +210 -0
- 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,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
|
+
}
|