@12-apps/notifications 1.0.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/ADOPTING.md +316 -0
- package/README.md +153 -0
- package/package.json +94 -0
- package/prisma/migrations/20260813140000_add_notification_tables/migration.sql +218 -0
- package/prisma/notifications.prisma +141 -0
- package/scripts/sync-notifications-schema.mjs +60 -0
- package/src/errors.ts +21 -0
- package/src/generators.ts +44 -0
- package/src/hono/index.ts +121 -0
- package/src/index.ts +73 -0
- package/src/messages.ts +156 -0
- package/src/phone.ts +57 -0
- package/src/preferences-core.ts +89 -0
- package/src/react/api.ts +111 -0
- package/src/react/bell-button.tsx +78 -0
- package/src/react/bell-icon.tsx +33 -0
- package/src/react/create-web-notifications.tsx +127 -0
- package/src/react/hooks.ts +74 -0
- package/src/react/inbox-state.ts +216 -0
- package/src/react/index.ts +61 -0
- package/src/react/panel.tsx +181 -0
- package/src/react/preferences-screen.tsx +242 -0
- package/src/react/relative-time.ts +18 -0
- package/src/react/row.tsx +98 -0
- package/src/react/transport.ts +72 -0
- package/src/react/web-push-client.ts +113 -0
- package/src/react/web-push-setup.tsx +167 -0
- package/src/server/by-permission.ts +255 -0
- package/src/server/context.ts +269 -0
- package/src/server/create-api-notifications.ts +215 -0
- package/src/server/db.ts +252 -0
- package/src/server/dispatch.ts +298 -0
- package/src/server/inbox.ts +155 -0
- package/src/server/index.ts +115 -0
- package/src/server/preferences.ts +103 -0
- package/src/server/push-subscriptions.ts +121 -0
- package/src/server/router.ts +275 -0
- package/src/server/routes.ts +218 -0
- package/src/server/transports/drivers.ts +148 -0
- package/src/server/transports/email.ts +141 -0
- package/src/server/transports/registry.ts +106 -0
- package/src/server/transports/sms.ts +120 -0
- package/src/server/transports/web-push.ts +168 -0
- package/src/server/transports/whatsapp.ts +183 -0
- package/src/types.ts +158 -0
- package/src/web-push/index.ts +70 -0
- package/src/wire.ts +62 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useState, type ComponentType, type JSX } from 'react';
|
|
2
|
+
|
|
3
|
+
import { messagesOf, type NotificationMessages } from '../messages';
|
|
4
|
+
|
|
5
|
+
import { createNotificationsApiClient, type NotificationsApiClient } from './api';
|
|
6
|
+
import { BellButton, type BellButtonProps } from './bell-button';
|
|
7
|
+
import { useUnreadCount, type NotificationsSubscribe } from './hooks';
|
|
8
|
+
import { createInboxStore, type InboxStore } from './inbox-state';
|
|
9
|
+
import { NotificationsPanel, type NotificationsPanelProps } from './panel';
|
|
10
|
+
import { PreferencesScreen, type PreferencesScreenProps } from './preferences-screen';
|
|
11
|
+
import { httpNotificationsTransport, type NotificationsTransport } from './transport';
|
|
12
|
+
import type { WebPushSetupConfig } from './web-push-setup';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The one thing this package exposes to a FRONTEND host (12-15).
|
|
16
|
+
*
|
|
17
|
+
* Everything the notification centre IS — the bell with its live badge, the
|
|
18
|
+
* slide-over inbox with its optimistic mark-read / delete / mark-all and its
|
|
19
|
+
* cursor pager, the preferences matrix with its availability hints and the
|
|
20
|
+
* per-browser push enable step, and every wire call between them — lives inside
|
|
21
|
+
* this package. The host names where the API is mounted, and that is the whole
|
|
22
|
+
* wiring.
|
|
23
|
+
*
|
|
24
|
+
* `page` is the standalone surface (the preferences screen), which is the one
|
|
25
|
+
* thing a host routes to. The bell and the panel are a PAIR a host drops into
|
|
26
|
+
* its own chrome, and they share one store, so a read in the panel moves the
|
|
27
|
+
* badge in the same tick.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export interface NotificationsWebConfig {
|
|
31
|
+
/** The account mount the routes live under, e.g. `/api/account`. */
|
|
32
|
+
apiBase: string;
|
|
33
|
+
/** How the surface reaches its data. Default: same-origin fetch. */
|
|
34
|
+
transport?: NotificationsTransport;
|
|
35
|
+
/** User-facing copy overrides (pt-BR product copy by default). */
|
|
36
|
+
messages?: Partial<NotificationMessages>;
|
|
37
|
+
/**
|
|
38
|
+
* How the surface learns an inbox changed without asking — the host's message
|
|
39
|
+
* bus. Without it the badge keeps its 60 s poll, which is the standing
|
|
40
|
+
* contract rather than a fallback: a dropped event must cost latency, never
|
|
41
|
+
* correctness.
|
|
42
|
+
*/
|
|
43
|
+
subscribe?: NotificationsSubscribe;
|
|
44
|
+
/** The browser push enable step's host seams (SW path, platform hint). */
|
|
45
|
+
webPush?: WebPushSetupConfig;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface WebNotifications {
|
|
49
|
+
/** The routed surface: the preferences screen. */
|
|
50
|
+
page: ComponentType<PreferencesScreenProps>;
|
|
51
|
+
/** The bell, already bound to the shared store. */
|
|
52
|
+
BellButton: ComponentType<BellButtonProps>;
|
|
53
|
+
/** The inbox slide-over, sharing that store. */
|
|
54
|
+
Panel: ComponentType<NotificationsPanelProps>;
|
|
55
|
+
/**
|
|
56
|
+
* Bell + panel as ONE element, for a host that just wants the feature in its
|
|
57
|
+
* header and does not want to own the open/closed state.
|
|
58
|
+
*/
|
|
59
|
+
BellWithPanel: ComponentType<{
|
|
60
|
+
enabled?: boolean;
|
|
61
|
+
onNavigate?: (link: string) => void;
|
|
62
|
+
}>;
|
|
63
|
+
/** The badge number, for a host with its own trigger chrome. */
|
|
64
|
+
useUnreadCount: (options?: { enabled?: boolean }) => number;
|
|
65
|
+
/** The shared client state, for host glue. */
|
|
66
|
+
store: InboxStore;
|
|
67
|
+
/** The bound wire client. */
|
|
68
|
+
api: NotificationsApiClient;
|
|
69
|
+
/** The copy in force, so a host's own chrome can reuse a sentence. */
|
|
70
|
+
messages: NotificationMessages;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createWebNotifications(config: NotificationsWebConfig): WebNotifications {
|
|
74
|
+
const messages = messagesOf(config);
|
|
75
|
+
const api = createNotificationsApiClient(
|
|
76
|
+
config.apiBase,
|
|
77
|
+
config.transport ?? httpNotificationsTransport(messages.operationFailed),
|
|
78
|
+
);
|
|
79
|
+
const store = createInboxStore(api);
|
|
80
|
+
const webPush = config.webPush ?? {};
|
|
81
|
+
const subscribe = config.subscribe;
|
|
82
|
+
const subscribeOption = subscribe ? { subscribe } : {};
|
|
83
|
+
|
|
84
|
+
const Bell: ComponentType<BellButtonProps> = (props) => (
|
|
85
|
+
<BellButton {...props} store={store} messages={messages} {...subscribeOption} />
|
|
86
|
+
);
|
|
87
|
+
const Panel: ComponentType<NotificationsPanelProps> = (props) => (
|
|
88
|
+
<NotificationsPanel {...props} store={store} messages={messages} />
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
function useBoundUnreadCount(options: { enabled?: boolean } = {}): number {
|
|
92
|
+
return useUnreadCount(store, { ...options, ...subscribeOption });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function BellWithPanel({
|
|
96
|
+
enabled = true,
|
|
97
|
+
onNavigate,
|
|
98
|
+
}: {
|
|
99
|
+
enabled?: boolean;
|
|
100
|
+
onNavigate?: (link: string) => void;
|
|
101
|
+
}): JSX.Element {
|
|
102
|
+
const [open, setOpen] = useState(false);
|
|
103
|
+
return (
|
|
104
|
+
<>
|
|
105
|
+
<Bell enabled={enabled} onClick={() => setOpen(true)} />
|
|
106
|
+
<Panel
|
|
107
|
+
open={open}
|
|
108
|
+
onClose={() => setOpen(false)}
|
|
109
|
+
{...(onNavigate ? { onNavigate } : {})}
|
|
110
|
+
/>
|
|
111
|
+
</>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
page: (props) => (
|
|
117
|
+
<PreferencesScreen {...props} api={api} messages={messages} webPush={webPush} />
|
|
118
|
+
),
|
|
119
|
+
BellButton: Bell,
|
|
120
|
+
Panel,
|
|
121
|
+
BellWithPanel,
|
|
122
|
+
useUnreadCount: useBoundUnreadCount,
|
|
123
|
+
store,
|
|
124
|
+
api,
|
|
125
|
+
messages,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { useEffect, useSyncExternalStore } from 'react';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
BADGE_POLL_MS,
|
|
5
|
+
BADGE_RECONCILE_MS,
|
|
6
|
+
type InboxState,
|
|
7
|
+
type InboxStore,
|
|
8
|
+
} from './inbox-state';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The two hooks the bell and the panel use, and the realtime seam between them.
|
|
12
|
+
*
|
|
13
|
+
* A host that has a message bus passes `subscribe`; one that has not passes
|
|
14
|
+
* nothing and keeps the 60 s poll. The bell ships in this package and mounts in
|
|
15
|
+
* whatever embeds it, so it must not require the host to have adopted anything.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How the surface learns an inbox changed without asking.
|
|
20
|
+
*
|
|
21
|
+
* Called once per mounted bell with a callback that means only "ask again" — no
|
|
22
|
+
* payload, so the number on screen is always one the server just gave us.
|
|
23
|
+
* Returns its own teardown. A host wires this to whatever it already has.
|
|
24
|
+
*/
|
|
25
|
+
export type NotificationsSubscribe = (onHint: () => void) => () => void;
|
|
26
|
+
|
|
27
|
+
export function useInboxState(store: InboxStore): InboxState {
|
|
28
|
+
return useSyncExternalStore(store.subscribe, store.getState, store.getState);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The bell badge number: pushed while a subscription is live, polled otherwise.
|
|
33
|
+
*
|
|
34
|
+
* `enabled` gates the poll AND the subscription. A signed-out header still
|
|
35
|
+
* mounts the bell, and there is nothing for it to hear.
|
|
36
|
+
*/
|
|
37
|
+
export function useUnreadCount(
|
|
38
|
+
store: InboxStore,
|
|
39
|
+
options: { enabled?: boolean; subscribe?: NotificationsSubscribe } = {},
|
|
40
|
+
): number {
|
|
41
|
+
const enabled = options.enabled ?? true;
|
|
42
|
+
const subscribe = options.subscribe;
|
|
43
|
+
const { unread } = useInboxState(store);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!enabled) return;
|
|
47
|
+
store.refreshBadge();
|
|
48
|
+
const unsubscribe = subscribe?.(() => store.invalidate());
|
|
49
|
+
// A live subscription relaxes the poll to the reconcile interval; without
|
|
50
|
+
// one it stays the 60 s poll.
|
|
51
|
+
const interval = setInterval(
|
|
52
|
+
() => store.refreshBadge(),
|
|
53
|
+
subscribe ? BADGE_RECONCILE_MS : BADGE_POLL_MS,
|
|
54
|
+
);
|
|
55
|
+
const onFocus = (): void => store.refreshBadge();
|
|
56
|
+
globalThis.addEventListener?.('focus', onFocus);
|
|
57
|
+
return () => {
|
|
58
|
+
clearInterval(interval);
|
|
59
|
+
globalThis.removeEventListener?.('focus', onFocus);
|
|
60
|
+
unsubscribe?.();
|
|
61
|
+
};
|
|
62
|
+
}, [store, enabled, subscribe]);
|
|
63
|
+
|
|
64
|
+
return enabled ? unread : 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The panel's list — only fetches while the panel is open. */
|
|
68
|
+
export function useInboxList(store: InboxStore, open: boolean): InboxState {
|
|
69
|
+
const state = useInboxState(store);
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (open) store.open();
|
|
72
|
+
}, [store, open]);
|
|
73
|
+
return state;
|
|
74
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import type { InboxNotification } from '../wire';
|
|
2
|
+
|
|
3
|
+
import type { NotificationsApiClient } from './api';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The inbox's client state, as ONE store shared by the bell and the panel.
|
|
7
|
+
*
|
|
8
|
+
* They have to share it: marking a row read in the panel must move the badge in
|
|
9
|
+
* the same tick, and an arrival must add a row to the list AND to the count.
|
|
10
|
+
* future-pay got that for free from a react-query cache the host had already
|
|
11
|
+
* mounted; a published package cannot assume one — a query client is a host
|
|
12
|
+
* decision, and requiring a particular one (or a particular version of one) is
|
|
13
|
+
* the kind of dependency that keeps a package out of a host that made the other
|
|
14
|
+
* choice. So the sharing is explicit and dependency-free: one subscribable
|
|
15
|
+
* store, read through `useSyncExternalStore`.
|
|
16
|
+
*
|
|
17
|
+
* Optimistic on every write, with invalidate-on-error: the badge and the list
|
|
18
|
+
* update instantly, and a failed write refetches the server truth rather than
|
|
19
|
+
* leaving the screen asserting something the database does not say.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const PAGE_SIZE = 20;
|
|
23
|
+
|
|
24
|
+
/** The badge's poll while nothing is pushing to us. */
|
|
25
|
+
export const BADGE_POLL_MS = 60_000;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The badge's interval while a realtime connection is live.
|
|
29
|
+
*
|
|
30
|
+
* Five minutes, not "never": this is the reconcile that catches an event the bus
|
|
31
|
+
* dropped, and it costs one COUNT per open tab per five minutes. Deliberately
|
|
32
|
+
* far slower than an operational screen's — a bell badge is ambient, and the
|
|
33
|
+
* arrival that matters is pushed within milliseconds anyway. The poll does NOT
|
|
34
|
+
* stop, which is the standing contract: a dropped event must cost latency and
|
|
35
|
+
* never correctness.
|
|
36
|
+
*/
|
|
37
|
+
export const BADGE_RECONCILE_MS = 300_000;
|
|
38
|
+
|
|
39
|
+
export type InboxListStatus = 'idle' | 'pending' | 'ready' | 'error';
|
|
40
|
+
|
|
41
|
+
export interface InboxState {
|
|
42
|
+
unread: number;
|
|
43
|
+
items: InboxNotification[];
|
|
44
|
+
status: InboxListStatus;
|
|
45
|
+
/** A cursor means there is another page. */
|
|
46
|
+
nextCursor: string | null;
|
|
47
|
+
loadingMore: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface InboxStore {
|
|
51
|
+
getState(): InboxState;
|
|
52
|
+
subscribe(listener: () => void): () => void;
|
|
53
|
+
/** Load the first page (idempotent while one is in flight). */
|
|
54
|
+
open(): void;
|
|
55
|
+
/** Refetch the badge count. */
|
|
56
|
+
refreshBadge(): void;
|
|
57
|
+
/** Refetch both — what a realtime hint or a failed write triggers. */
|
|
58
|
+
invalidate(): void;
|
|
59
|
+
loadMore(): void;
|
|
60
|
+
markRead(ids: readonly string[]): void;
|
|
61
|
+
markAllRead(): void;
|
|
62
|
+
remove(id: string): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const EMPTY: InboxState = {
|
|
66
|
+
unread: 0,
|
|
67
|
+
items: [],
|
|
68
|
+
status: 'idle',
|
|
69
|
+
nextCursor: null,
|
|
70
|
+
loadingMore: false,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** The mutable cell the functions below share, so each one stays small. */
|
|
74
|
+
interface Cell {
|
|
75
|
+
state: InboxState;
|
|
76
|
+
listeners: Set<() => void>;
|
|
77
|
+
/** Fences a stale reload: a newer one must always win. */
|
|
78
|
+
request: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function patch(cell: Cell, next: Partial<InboxState>): void {
|
|
82
|
+
cell.state = { ...cell.state, ...next };
|
|
83
|
+
for (const listener of cell.listeners) listener();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Refetch the badge count. The number is always one the server just gave us. */
|
|
87
|
+
function refreshBadge(cell: Cell, api: NotificationsApiClient): void {
|
|
88
|
+
void api
|
|
89
|
+
.unreadCount()
|
|
90
|
+
.then((unread) => patch(cell, { unread }))
|
|
91
|
+
.catch(() => undefined);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Reload page one, discarding whatever the optimistic path had produced.
|
|
96
|
+
* `request` fences it: a reload that started before a newer one must not land
|
|
97
|
+
* after it and reinstate stale rows.
|
|
98
|
+
*/
|
|
99
|
+
function reloadList(cell: Cell, api: NotificationsApiClient): void {
|
|
100
|
+
const token = (cell.request += 1);
|
|
101
|
+
patch(cell, { status: cell.state.items.length > 0 ? cell.state.status : 'pending' });
|
|
102
|
+
void api
|
|
103
|
+
.listNotifications({ limit: PAGE_SIZE })
|
|
104
|
+
.then((page) => {
|
|
105
|
+
if (token !== cell.request) return;
|
|
106
|
+
patch(cell, { items: page.items, nextCursor: page.nextCursor, status: 'ready' });
|
|
107
|
+
})
|
|
108
|
+
.catch(() => {
|
|
109
|
+
if (token !== cell.request) return;
|
|
110
|
+
patch(cell, { status: 'error' });
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function invalidate(cell: Cell, api: NotificationsApiClient): void {
|
|
115
|
+
refreshBadge(cell, api);
|
|
116
|
+
if (cell.state.status !== 'idle') reloadList(cell, api);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Apply an optimistic edit; on failure, take the server's word instead. */
|
|
120
|
+
function write(
|
|
121
|
+
cell: Cell,
|
|
122
|
+
api: NotificationsApiClient,
|
|
123
|
+
apply: () => void,
|
|
124
|
+
send: () => Promise<{ ok: boolean }>,
|
|
125
|
+
): void {
|
|
126
|
+
apply();
|
|
127
|
+
void send()
|
|
128
|
+
.then((result) => {
|
|
129
|
+
if (!result.ok) invalidate(cell, api);
|
|
130
|
+
})
|
|
131
|
+
.catch(() => invalidate(cell, api));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function bumpUnread(cell: Cell, delta: number): void {
|
|
135
|
+
patch(cell, { unread: Math.max(0, cell.state.unread + delta) });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function loadMore(cell: Cell, api: NotificationsApiClient): void {
|
|
139
|
+
const cursor = cell.state.nextCursor;
|
|
140
|
+
if (!cursor || cell.state.loadingMore) return;
|
|
141
|
+
patch(cell, { loadingMore: true });
|
|
142
|
+
void api
|
|
143
|
+
.listNotifications({ cursor, limit: PAGE_SIZE })
|
|
144
|
+
.then((page) => {
|
|
145
|
+
patch(cell, {
|
|
146
|
+
items: [...cell.state.items, ...page.items],
|
|
147
|
+
nextCursor: page.nextCursor,
|
|
148
|
+
loadingMore: false,
|
|
149
|
+
});
|
|
150
|
+
})
|
|
151
|
+
.catch(() => patch(cell, { loadingMore: false }));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function markRead(cell: Cell, api: NotificationsApiClient, ids: readonly string[]): void {
|
|
155
|
+
const readAt = new Date().toISOString();
|
|
156
|
+
let flipped = 0;
|
|
157
|
+
const items = cell.state.items.map((item) => {
|
|
158
|
+
if (!ids.includes(item.id) || item.readAt !== null) return item;
|
|
159
|
+
flipped += 1;
|
|
160
|
+
return { ...item, readAt };
|
|
161
|
+
});
|
|
162
|
+
if (flipped === 0) return;
|
|
163
|
+
write(
|
|
164
|
+
cell,
|
|
165
|
+
api,
|
|
166
|
+
() => {
|
|
167
|
+
patch(cell, { items });
|
|
168
|
+
bumpUnread(cell, -flipped);
|
|
169
|
+
},
|
|
170
|
+
() => api.markRead(ids),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function remove(cell: Cell, api: NotificationsApiClient, id: string): void {
|
|
175
|
+
const target = cell.state.items.find((item) => item.id === id);
|
|
176
|
+
if (!target) return;
|
|
177
|
+
const items = cell.state.items.filter((item) => item.id !== id);
|
|
178
|
+
write(
|
|
179
|
+
cell,
|
|
180
|
+
api,
|
|
181
|
+
() => {
|
|
182
|
+
patch(cell, { items });
|
|
183
|
+
if (target.readAt === null) bumpUnread(cell, -1);
|
|
184
|
+
},
|
|
185
|
+
() => api.remove([id]),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function createInboxStore(api: NotificationsApiClient): InboxStore {
|
|
190
|
+
const cell: Cell = { state: EMPTY, listeners: new Set(), request: 0 };
|
|
191
|
+
return {
|
|
192
|
+
getState: () => cell.state,
|
|
193
|
+
subscribe(listener) {
|
|
194
|
+
cell.listeners.add(listener);
|
|
195
|
+
return () => cell.listeners.delete(listener);
|
|
196
|
+
},
|
|
197
|
+
open() {
|
|
198
|
+
if (cell.state.status === 'idle') reloadList(cell, api);
|
|
199
|
+
},
|
|
200
|
+
refreshBadge: () => refreshBadge(cell, api),
|
|
201
|
+
invalidate: () => invalidate(cell, api),
|
|
202
|
+
loadMore: () => loadMore(cell, api),
|
|
203
|
+
markRead: (ids) => markRead(cell, api, ids),
|
|
204
|
+
markAllRead() {
|
|
205
|
+
const readAt = new Date().toISOString();
|
|
206
|
+
const items = cell.state.items.map((item) => ({ ...item, readAt: item.readAt ?? readAt }));
|
|
207
|
+
write(
|
|
208
|
+
cell,
|
|
209
|
+
api,
|
|
210
|
+
() => patch(cell, { items, unread: 0 }),
|
|
211
|
+
() => api.markAllRead(),
|
|
212
|
+
);
|
|
213
|
+
},
|
|
214
|
+
remove: (id) => remove(cell, api, id),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/notifications/react` — the frontend half (12-15).
|
|
3
|
+
*
|
|
4
|
+
* One factory, `createWebNotifications({ apiBase })`, and everything it returns
|
|
5
|
+
* is already bound to one shared store and one copy table. The individual
|
|
6
|
+
* pieces are exported too, for a host composing its own chrome — but a host that
|
|
7
|
+
* only wants the feature needs the factory and nothing else.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
createWebNotifications,
|
|
12
|
+
type NotificationsWebConfig,
|
|
13
|
+
type WebNotifications,
|
|
14
|
+
} from './create-web-notifications';
|
|
15
|
+
|
|
16
|
+
export { BellIcon } from './bell-icon';
|
|
17
|
+
export type { BellButtonProps } from './bell-button';
|
|
18
|
+
export type { NotificationsPanelProps } from './panel';
|
|
19
|
+
export type { PreferencesScreenProps } from './preferences-screen';
|
|
20
|
+
export type { WebPushPlatformHint, WebPushSetupConfig } from './web-push-setup';
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
createNotificationsApiClient,
|
|
24
|
+
type NotificationsApiClient,
|
|
25
|
+
type PreferencesPayload,
|
|
26
|
+
type PushRegistrationPayload,
|
|
27
|
+
} from './api';
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
BADGE_POLL_MS,
|
|
31
|
+
BADGE_RECONCILE_MS,
|
|
32
|
+
PAGE_SIZE,
|
|
33
|
+
createInboxStore,
|
|
34
|
+
type InboxListStatus,
|
|
35
|
+
type InboxState,
|
|
36
|
+
type InboxStore,
|
|
37
|
+
} from './inbox-state';
|
|
38
|
+
|
|
39
|
+
export {
|
|
40
|
+
useInboxList,
|
|
41
|
+
useInboxState,
|
|
42
|
+
useUnreadCount,
|
|
43
|
+
type NotificationsSubscribe,
|
|
44
|
+
} from './hooks';
|
|
45
|
+
|
|
46
|
+
export { relativeTime } from './relative-time';
|
|
47
|
+
|
|
48
|
+
export {
|
|
49
|
+
NotificationsHttpError,
|
|
50
|
+
httpNotificationsTransport,
|
|
51
|
+
type NotificationsResult,
|
|
52
|
+
type NotificationsTransport,
|
|
53
|
+
} from './transport';
|
|
54
|
+
|
|
55
|
+
export {
|
|
56
|
+
disableWebPush,
|
|
57
|
+
enableWebPush,
|
|
58
|
+
getExistingPushSubscription,
|
|
59
|
+
pushSupported,
|
|
60
|
+
type PushSetupResult,
|
|
61
|
+
} from './web-push-client';
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notification-centre slide-over: newest-first list with unread styling,
|
|
3
|
+
* per-item open (marks read + deep-links), soft delete, mark-all, empty /
|
|
4
|
+
* loading / error states and a "load more" cursor pager.
|
|
5
|
+
*
|
|
6
|
+
* Rendering is app-agnostic — the host passes `onNavigate` (its router's
|
|
7
|
+
* navigate) for deep links. Without one a link is simply not followed, which is
|
|
8
|
+
* what lets the panel mount in a host that has no router at all.
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, type JSX } from 'react';
|
|
11
|
+
|
|
12
|
+
import { EmptyState } from '@12-apps/ui/data-display/EmptyState';
|
|
13
|
+
import { LoadingState } from '@12-apps/ui/data-display/LoadingState';
|
|
14
|
+
import { Button } from '@12-apps/ui/form/Button';
|
|
15
|
+
import { Drawer, DrawerContent, DrawerHeader } from '@12-apps/ui/layout/Drawer';
|
|
16
|
+
import { Box } from '@12-apps/ui/mui/Box';
|
|
17
|
+
import { useMediaQuery } from '@12-apps/ui/mui/useMediaQuery';
|
|
18
|
+
import { useTheme } from '@12-apps/ui/mui/styles';
|
|
19
|
+
|
|
20
|
+
import type { NotificationMessages } from '../messages';
|
|
21
|
+
import type { InboxNotification } from '../wire';
|
|
22
|
+
|
|
23
|
+
import { BellIcon } from './bell-icon';
|
|
24
|
+
import { useInboxList } from './hooks';
|
|
25
|
+
import type { InboxState, InboxStore } from './inbox-state';
|
|
26
|
+
import { NotificationRow } from './row';
|
|
27
|
+
|
|
28
|
+
interface PanelBodyProps {
|
|
29
|
+
state: InboxState;
|
|
30
|
+
messages: NotificationMessages;
|
|
31
|
+
onRetry: () => void;
|
|
32
|
+
onLoadMore: () => void;
|
|
33
|
+
onOpen: (notification: InboxNotification) => void;
|
|
34
|
+
onDelete: (id: string) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The scrollable panel body: loading / error / empty / the list + pager. */
|
|
38
|
+
function PanelBody({
|
|
39
|
+
state,
|
|
40
|
+
messages,
|
|
41
|
+
onRetry,
|
|
42
|
+
onLoadMore,
|
|
43
|
+
onOpen,
|
|
44
|
+
onDelete,
|
|
45
|
+
}: PanelBodyProps): JSX.Element {
|
|
46
|
+
if (state.status === 'pending' || state.status === 'idle') {
|
|
47
|
+
return (
|
|
48
|
+
<LoadingState
|
|
49
|
+
variant="spinner"
|
|
50
|
+
message={messages.loading}
|
|
51
|
+
size="md"
|
|
52
|
+
dataTestId="notifications-loading"
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
if (state.status === 'error') {
|
|
57
|
+
return (
|
|
58
|
+
<EmptyState
|
|
59
|
+
variant="minimal"
|
|
60
|
+
title={messages.loadFailedTitle}
|
|
61
|
+
description={messages.loadFailedBody}
|
|
62
|
+
onRefresh={onRetry}
|
|
63
|
+
refreshLabel={messages.retry}
|
|
64
|
+
dataTestId="notifications-error"
|
|
65
|
+
/>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (state.items.length === 0) {
|
|
69
|
+
return (
|
|
70
|
+
<EmptyState
|
|
71
|
+
variant="illustrated"
|
|
72
|
+
illustration={<BellIcon size={44} dim />}
|
|
73
|
+
title={messages.emptyTitle}
|
|
74
|
+
description={messages.emptyBody}
|
|
75
|
+
dataTestId="notifications-empty"
|
|
76
|
+
/>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return (
|
|
80
|
+
<Box>
|
|
81
|
+
{state.items.map((notification) => (
|
|
82
|
+
<NotificationRow
|
|
83
|
+
key={notification.id}
|
|
84
|
+
notification={notification}
|
|
85
|
+
messages={messages}
|
|
86
|
+
onOpen={onOpen}
|
|
87
|
+
onDelete={onDelete}
|
|
88
|
+
/>
|
|
89
|
+
))}
|
|
90
|
+
{state.nextCursor ? (
|
|
91
|
+
<Box sx={{ display: 'flex', justifyContent: 'center', py: 1.5 }}>
|
|
92
|
+
<Button
|
|
93
|
+
variant="outline"
|
|
94
|
+
color="neutral"
|
|
95
|
+
size="sm"
|
|
96
|
+
disabled={state.loadingMore}
|
|
97
|
+
onClick={onLoadMore}
|
|
98
|
+
dataTestId="notifications-load-more"
|
|
99
|
+
>
|
|
100
|
+
{state.loadingMore ? messages.loadingMore : messages.loadMore}
|
|
101
|
+
</Button>
|
|
102
|
+
</Box>
|
|
103
|
+
) : null}
|
|
104
|
+
</Box>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface NotificationsPanelProps {
|
|
109
|
+
open: boolean;
|
|
110
|
+
onClose: () => void;
|
|
111
|
+
/** Navigate to a notification's in-app link (the host's router). */
|
|
112
|
+
onNavigate?: (link: string) => void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function NotificationsPanel({
|
|
116
|
+
open,
|
|
117
|
+
onClose,
|
|
118
|
+
onNavigate,
|
|
119
|
+
store,
|
|
120
|
+
messages,
|
|
121
|
+
}: NotificationsPanelProps & {
|
|
122
|
+
store: InboxStore;
|
|
123
|
+
messages: NotificationMessages;
|
|
124
|
+
}): JSX.Element {
|
|
125
|
+
// `useTheme` from @mui/material/styles falls back to the DEFAULT theme when
|
|
126
|
+
// no provider is mounted, where the callback form of `useMediaQuery` would
|
|
127
|
+
// hand the callback a null theme and throw. A published component must render
|
|
128
|
+
// in a host that has not wrapped it yet.
|
|
129
|
+
const theme = useTheme();
|
|
130
|
+
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
|
131
|
+
const state = useInboxList(store, open);
|
|
132
|
+
|
|
133
|
+
const openNotification = useCallback(
|
|
134
|
+
(notification: InboxNotification) => {
|
|
135
|
+
if (notification.readAt === null) store.markRead([notification.id]);
|
|
136
|
+
if (notification.link && onNavigate) {
|
|
137
|
+
onClose();
|
|
138
|
+
onNavigate(notification.link);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
[store, onClose, onNavigate],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const hasUnread = state.items.some((item) => item.readAt === null);
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<Drawer
|
|
148
|
+
open={open}
|
|
149
|
+
onClose={onClose}
|
|
150
|
+
anchor="right"
|
|
151
|
+
variant="right"
|
|
152
|
+
width={isMobile ? '100vw' : 400}
|
|
153
|
+
dataTestId="notifications-panel"
|
|
154
|
+
>
|
|
155
|
+
<DrawerHeader onClose={onClose}>{messages.panelTitle}</DrawerHeader>
|
|
156
|
+
<DrawerContent>
|
|
157
|
+
{hasUnread ? (
|
|
158
|
+
<Box sx={{ display: 'flex', justifyContent: 'flex-end', pb: 1 }}>
|
|
159
|
+
<Button
|
|
160
|
+
variant="ghost"
|
|
161
|
+
color="primary"
|
|
162
|
+
size="xs"
|
|
163
|
+
onClick={() => store.markAllRead()}
|
|
164
|
+
dataTestId="notifications-mark-all-read"
|
|
165
|
+
>
|
|
166
|
+
{messages.markAllRead}
|
|
167
|
+
</Button>
|
|
168
|
+
</Box>
|
|
169
|
+
) : null}
|
|
170
|
+
<PanelBody
|
|
171
|
+
state={state}
|
|
172
|
+
messages={messages}
|
|
173
|
+
onRetry={() => store.invalidate()}
|
|
174
|
+
onLoadMore={() => store.loadMore()}
|
|
175
|
+
onOpen={openNotification}
|
|
176
|
+
onDelete={(id) => store.remove(id)}
|
|
177
|
+
/>
|
|
178
|
+
</DrawerContent>
|
|
179
|
+
</Drawer>
|
|
180
|
+
);
|
|
181
|
+
}
|