@xtremax/pigeon 0.0.0-stage → 0.3.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/CHANGELOG.md +22 -0
- package/README.md +68 -2
- package/bin/pigeon.mjs +92 -0
- package/dist/cjs/browser-stub.js +10 -0
- package/dist/cjs/client.js +112 -0
- package/dist/cjs/contract.js +5 -0
- package/dist/cjs/errors.js +48 -0
- package/dist/cjs/events.js +62 -0
- package/dist/cjs/idempotency.js +14 -0
- package/dist/cjs/inbox/browser-stub.js +10 -0
- package/dist/cjs/inbox/client.js +39 -0
- package/dist/cjs/inbox/errors.js +44 -0
- package/dist/cjs/inbox/handler.js +127 -0
- package/dist/cjs/inbox/index.js +15 -0
- package/dist/cjs/inbox/origin.js +22 -0
- package/dist/cjs/inbox/project.js +84 -0
- package/dist/cjs/inbox/validate.js +59 -0
- package/dist/cjs/inbox-react/format.js +72 -0
- package/dist/cjs/inbox-react/inbox-client.js +63 -0
- package/dist/cjs/inbox-react/inbox-context.js +13 -0
- package/dist/cjs/inbox-react/inbox-full-list.js +13 -0
- package/dist/cjs/inbox-react/inbox-list.js +37 -0
- package/dist/cjs/inbox-react/inbox-panel.js +13 -0
- package/dist/cjs/inbox-react/inbox-provider.js +157 -0
- package/dist/cjs/inbox-react/inbox-state.js +88 -0
- package/dist/cjs/inbox-react/index.js +25 -0
- package/dist/cjs/inbox-react/labels.js +38 -0
- package/dist/cjs/inbox-react/notification-bell.js +32 -0
- package/dist/cjs/inbox-react/notification-item.js +17 -0
- package/dist/cjs/inbox-react/parse.js +94 -0
- package/dist/cjs/inbox-react/preference-dialog.js +49 -0
- package/dist/cjs/inbox-react/types.js +4 -0
- package/dist/cjs/inbox-react/use-inbox-polling.js +94 -0
- package/dist/cjs/index.js +14 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/retry.js +14 -0
- package/dist/esm/browser-stub.js +6 -0
- package/dist/esm/client.js +107 -0
- package/dist/esm/contract.js +2 -0
- package/dist/esm/errors.js +42 -0
- package/dist/esm/events.js +57 -0
- package/dist/esm/idempotency.js +10 -0
- package/dist/esm/inbox/browser-stub.js +7 -0
- package/dist/esm/inbox/client.js +35 -0
- package/dist/esm/inbox/errors.js +38 -0
- package/dist/esm/inbox/handler.js +124 -0
- package/dist/esm/inbox/index.js +5 -0
- package/dist/esm/inbox/origin.js +19 -0
- package/dist/esm/inbox/project.js +76 -0
- package/dist/esm/inbox/validate.js +49 -0
- package/dist/esm/inbox-react/format.js +64 -0
- package/dist/esm/inbox-react/inbox-client.js +58 -0
- package/dist/esm/inbox-react/inbox-context.js +9 -0
- package/dist/esm/inbox-react/inbox-full-list.js +10 -0
- package/dist/esm/inbox-react/inbox-list.js +34 -0
- package/dist/esm/inbox-react/inbox-panel.js +10 -0
- package/dist/esm/inbox-react/inbox-provider.js +154 -0
- package/dist/esm/inbox-react/inbox-state.js +83 -0
- package/dist/esm/inbox-react/index.js +18 -0
- package/dist/esm/inbox-react/labels.js +34 -0
- package/dist/esm/inbox-react/notification-bell.js +29 -0
- package/dist/esm/inbox-react/notification-item.js +14 -0
- package/dist/esm/inbox-react/parse.js +85 -0
- package/dist/esm/inbox-react/preference-dialog.js +46 -0
- package/dist/esm/inbox-react/types.js +1 -0
- package/dist/esm/inbox-react/use-inbox-polling.js +88 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/retry.js +9 -0
- package/dist/inbox-react/styles.css +65 -0
- package/dist/types/browser-stub.d.ts +4 -0
- package/dist/types/client.d.ts +94 -0
- package/dist/types/contract.d.ts +182 -0
- package/dist/types/errors.d.ts +29 -0
- package/dist/types/events.d.ts +26 -0
- package/dist/types/idempotency.d.ts +4 -0
- package/dist/types/inbox/browser-stub.d.ts +3 -0
- package/dist/types/inbox/client.d.ts +34 -0
- package/dist/types/inbox/errors.d.ts +13 -0
- package/dist/types/inbox/handler.d.ts +25 -0
- package/dist/types/inbox/index.d.ts +6 -0
- package/dist/types/inbox/origin.d.ts +5 -0
- package/dist/types/inbox/project.d.ts +7 -0
- package/dist/types/inbox/validate.d.ts +25 -0
- package/dist/types/inbox-react/format.d.ts +13 -0
- package/dist/types/inbox-react/inbox-client.d.ts +24 -0
- package/dist/types/inbox-react/inbox-context.d.ts +29 -0
- package/dist/types/inbox-react/inbox-full-list.d.ts +7 -0
- package/dist/types/inbox-react/inbox-list.d.ts +12 -0
- package/dist/types/inbox-react/inbox-panel.d.ts +2 -0
- package/dist/types/inbox-react/inbox-provider.d.ts +18 -0
- package/dist/types/inbox-react/inbox-state.d.ts +70 -0
- package/dist/types/inbox-react/index.d.ts +19 -0
- package/dist/types/inbox-react/labels.d.ts +40 -0
- package/dist/types/inbox-react/notification-bell.d.ts +4 -0
- package/dist/types/inbox-react/notification-item.d.ts +9 -0
- package/dist/types/inbox-react/parse.d.ts +9 -0
- package/dist/types/inbox-react/preference-dialog.d.ts +5 -0
- package/dist/types/inbox-react/types.d.ts +55 -0
- package/dist/types/inbox-react/use-inbox-polling.d.ts +33 -0
- package/dist/types/index.d.ts +6 -0
- package/dist/types/retry.d.ts +13 -0
- package/package.json +34 -4
- package/skills/add-pigeon-notifications/SKILL.md +302 -0
- package/skills/add-pigeon-notifications/references/inbox-contract.instructions.md +208 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { parseErrorCode, parseMarkRead, parsePage, parsePreference, parseReadAll, parseSummary } from './parse.js';
|
|
2
|
+
export const DEFAULT_API_BASE = '/api/communication/inbox';
|
|
3
|
+
/**
|
|
4
|
+
* The browser talks only to the host xApp's own origin (14.2). The base is a path on that origin: an absolute URL,
|
|
5
|
+
* a protocol-relative one or anything with a query is refused.
|
|
6
|
+
*/
|
|
7
|
+
export function normalizeApiBase(apiBase) {
|
|
8
|
+
const base = (apiBase ?? DEFAULT_API_BASE).trim();
|
|
9
|
+
if (!base.startsWith('/') || base.startsWith('//') || /[\\?#\s]/.test(base) || base.includes('://'))
|
|
10
|
+
return null;
|
|
11
|
+
return base.replace(/\/+$/, '') || null;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Every call answers a result and never throws, so an outage cannot break the host page (14.9).
|
|
15
|
+
* The bell shows what this xApp's own sub-tenant sent the person, and the tenant's announcements: there is nothing to choose.
|
|
16
|
+
*/
|
|
17
|
+
export function createInboxBrowserClient(apiBase, fetchImpl) {
|
|
18
|
+
const base = normalizeApiBase(apiBase);
|
|
19
|
+
async function call(method, path, parse, body) {
|
|
20
|
+
if (base === null)
|
|
21
|
+
return { ok: false, code: 'unavailable' };
|
|
22
|
+
try {
|
|
23
|
+
const res = await (fetchImpl ?? fetch)(`${base}${path}`, {
|
|
24
|
+
method,
|
|
25
|
+
credentials: 'same-origin',
|
|
26
|
+
cache: 'no-store',
|
|
27
|
+
headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
|
|
28
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
29
|
+
});
|
|
30
|
+
const json = await res.json().catch(() => null);
|
|
31
|
+
if (!res.ok)
|
|
32
|
+
return { ok: false, code: parseErrorCode(res.status, json) };
|
|
33
|
+
const data = parse(json?.data);
|
|
34
|
+
return data === null ? { ok: false, code: 'unavailable' } : { ok: true, data };
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return { ok: false, code: 'unavailable' };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
summary: () => call('GET', '/summary', parseSummary),
|
|
42
|
+
list(query = {}) {
|
|
43
|
+
const q = new URLSearchParams();
|
|
44
|
+
if (query.limit !== undefined)
|
|
45
|
+
q.set('limit', String(query.limit));
|
|
46
|
+
if (query.cursor)
|
|
47
|
+
q.set('cursor', query.cursor);
|
|
48
|
+
if (query.unreadOnly)
|
|
49
|
+
q.set('unread', 'true');
|
|
50
|
+
const qs = q.toString();
|
|
51
|
+
return call('GET', `/notifications${qs ? `?${qs}` : ''}`, parsePage);
|
|
52
|
+
},
|
|
53
|
+
markRead: (id) => call('POST', `/notifications/${encodeURIComponent(id)}/read`, parseMarkRead, {}),
|
|
54
|
+
markAllRead: (upTo) => call('POST', '/read-all', parseReadAll, upTo ? { upTo } : {}),
|
|
55
|
+
getPreference: () => call('GET', '/preference', parsePreference),
|
|
56
|
+
setPreference: (state) => call('PUT', '/preference', parsePreference, { state }),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { createContext, useContext } from 'react';
|
|
3
|
+
export const InboxContext = createContext(null);
|
|
4
|
+
export function useInbox() {
|
|
5
|
+
const value = useContext(InboxContext);
|
|
6
|
+
if (!value)
|
|
7
|
+
throw new Error('Wrap the inbox components in <InboxProvider>.');
|
|
8
|
+
return value;
|
|
9
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { InboxListView } from './inbox-list.js';
|
|
4
|
+
/**
|
|
5
|
+
* Every notification, a page at a time. The provider shows it in a dialog for "View all"; a host that passes
|
|
6
|
+
* `onViewAll` can place it on a page of its own instead.
|
|
7
|
+
*/
|
|
8
|
+
export function InboxFullList({ maxHeight = 'min(70vh, 640px)' }) {
|
|
9
|
+
return _jsx(InboxListView, { paged: true, maxHeight: maxHeight });
|
|
10
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { Alert, Button, Center, Group, Loader, ScrollArea, Stack, Tabs, Text } from '@mantine/core';
|
|
5
|
+
import { fill } from './format.js';
|
|
6
|
+
import { useInbox } from './inbox-context.js';
|
|
7
|
+
import { canMarkAll } from './inbox-state.js';
|
|
8
|
+
import { NotificationItem } from './notification-item.js';
|
|
9
|
+
/** Tabs, "Mark all as read", the list and its loading, empty and error states. Shared by the panel and the full list. */
|
|
10
|
+
export function InboxListView({ heading, maxHeight, paged = false }) {
|
|
11
|
+
const { state, labels, setTab, markAllRead, loadMore, retry, clearMessage, attachList } = useInbox();
|
|
12
|
+
useEffect(() => attachList(), [attachList]);
|
|
13
|
+
// Relative times move on while the list stays open.
|
|
14
|
+
const [now, setNow] = useState(() => new Date());
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const timer = window.setInterval(() => setNow(new Date()), 60_000);
|
|
17
|
+
return () => window.clearInterval(timer);
|
|
18
|
+
}, []);
|
|
19
|
+
let content;
|
|
20
|
+
if (!state.loaded && state.status === 'error') {
|
|
21
|
+
content = (_jsx(Alert, { color: "red", variant: "light", m: "md", title: labels.loadError, children: _jsx(Button, { variant: "default", size: "sm", mih: 44, onClick: retry, children: labels.retry }) }));
|
|
22
|
+
}
|
|
23
|
+
else if (!state.loaded) {
|
|
24
|
+
content = (_jsx(Center, { mih: 160, role: "status", "aria-label": labels.loading, children: _jsx(Loader, { size: "sm" }) }));
|
|
25
|
+
}
|
|
26
|
+
else if (state.items.length === 0) {
|
|
27
|
+
content = (_jsx(Text, { c: "dimmed", size: "sm", ta: "center", px: "md", py: "xl", children: state.tab === 'unread' ? labels.emptyUnread : labels.emptyAll }));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
content = (_jsxs(_Fragment, { children: [_jsx("ul", { className: "xci-list", children: state.items.map((item) => (_jsx("li", { className: "xci-entry", children: _jsx(NotificationItem, { item: item, now: now }) }, item.id))) }), paged && state.nextCursor && (_jsx(Center, { p: "sm", children: _jsx(Button, { variant: "default", size: "sm", mih: 44, loading: state.loadingMore, onClick: loadMore, children: labels.loadMore }) }))] }));
|
|
31
|
+
}
|
|
32
|
+
const list = (_jsx(ScrollArea.Autosize, { mah: maxHeight, type: "auto", children: content }));
|
|
33
|
+
return (_jsxs(Stack, { gap: 0, children: [_jsxs(Group, { justify: "space-between", wrap: "nowrap", gap: "xs", ps: "md", pe: 4, pt: 4, children: [heading ?? _jsx("span", {}), _jsx(Button, { variant: "subtle", size: "compact-sm", mih: 44, px: "sm", disabled: !canMarkAll(state), onClick: markAllRead, children: labels.markAllRead })] }), _jsxs(Tabs, { value: state.tab, onChange: (tab) => tab && setTab(tab), keepMounted: false, children: [_jsxs(Tabs.List, { px: "xs", children: [_jsx(Tabs.Tab, { value: "all", mih: 44, children: labels.tabAll }), _jsx(Tabs.Tab, { value: "unread", mih: 44, children: state.unreadCount > 0 ? fill(labels.tabUnreadWithCount, { n: state.unreadCount }) : labels.tabUnread })] }), state.actionFailed && (_jsx(Alert, { color: "red", variant: "light", m: "xs", role: "alert", withCloseButton: true, closeButtonLabel: labels.dismiss, onClose: clearMessage, children: labels.actionError })), _jsx(Tabs.Panel, { value: "all", children: list }), _jsx(Tabs.Panel, { value: "unread", children: list })] })] }));
|
|
34
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { Button, Divider, Group, Title } from '@mantine/core';
|
|
4
|
+
import { useInbox } from './inbox-context.js';
|
|
5
|
+
import { InboxListView } from './inbox-list.js';
|
|
6
|
+
/** What the bell opens: the newest notifications, with the ways on to the full list and the preference. */
|
|
7
|
+
export function InboxPanel() {
|
|
8
|
+
const { labels, viewAll, openPreferences } = useInbox();
|
|
9
|
+
return (_jsxs(_Fragment, { children: [_jsx(InboxListView, { maxHeight: 400, heading: _jsx(Title, { order: 2, size: "h5", children: labels.heading }) }), _jsx(Divider, {}), _jsxs(Group, { justify: "space-between", gap: 4, p: 4, children: [_jsx(Button, { variant: "subtle", size: "compact-sm", mih: 44, px: "sm", onClick: viewAll, children: labels.viewAll }), _jsx(Button, { variant: "subtle", size: "compact-sm", mih: 44, px: "sm", onClick: openPreferences, children: labels.preferences })] })] }));
|
|
10
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
|
|
4
|
+
import { Modal } from '@mantine/core';
|
|
5
|
+
import { useReducedMotion } from '@mantine/hooks';
|
|
6
|
+
import { safeLink } from './format.js';
|
|
7
|
+
import { createInboxBrowserClient } from './inbox-client.js';
|
|
8
|
+
import { InboxContext } from './inbox-context.js';
|
|
9
|
+
import { InboxFullList } from './inbox-full-list.js';
|
|
10
|
+
import { canMarkAll, initialInboxState, inboxReducer } from './inbox-state.js';
|
|
11
|
+
import { mergeLabels } from './labels.js';
|
|
12
|
+
import { PreferenceDialog } from './preference-dialog.js';
|
|
13
|
+
import { useInboxPolling } from './use-inbox-polling.js';
|
|
14
|
+
const openInNewTab = (url) => {
|
|
15
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
16
|
+
};
|
|
17
|
+
/** "Mark all as read" changes at most one batch per call; repeat while something remains, within a bound. */
|
|
18
|
+
const READ_ALL_MAX_CALLS = 10;
|
|
19
|
+
const nowIso = () => new Date().toISOString();
|
|
20
|
+
export function InboxProvider({ apiBase, labels, locale, onViewAll, onOpenPreferences, openLink, children }) {
|
|
21
|
+
const client = useMemo(() => createInboxBrowserClient(apiBase), [apiBase]);
|
|
22
|
+
const merged = useMemo(() => mergeLabels(labels), [labels]);
|
|
23
|
+
const [state, dispatch] = useReducer(inboxReducer, initialInboxState);
|
|
24
|
+
const [panelOpened, setPanelOpened] = useState(false);
|
|
25
|
+
const [fullOpened, setFullOpened] = useState(false);
|
|
26
|
+
const [preferencesOpened, setPreferencesOpened] = useState(false);
|
|
27
|
+
const reduceMotion = useReducedMotion();
|
|
28
|
+
const bellRef = useRef(null);
|
|
29
|
+
const latest = useRef(state);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
latest.current = state;
|
|
32
|
+
}, [state]);
|
|
33
|
+
// The tab is also kept here: a list that mounts in the same render as a tab change must load the new tab.
|
|
34
|
+
const tabRef = useRef('all');
|
|
35
|
+
const lists = useRef(0);
|
|
36
|
+
const request = useRef(0);
|
|
37
|
+
const load = useCallback(async (mode, tab, cursor) => {
|
|
38
|
+
const mine = (request.current += 1);
|
|
39
|
+
dispatch({ type: 'loadStarted', mode });
|
|
40
|
+
const res = await client.list({ unreadOnly: tab === 'unread', cursor });
|
|
41
|
+
// An answer for a tab or a list the person has already left is dropped.
|
|
42
|
+
if (mine !== request.current)
|
|
43
|
+
return;
|
|
44
|
+
if (res.ok)
|
|
45
|
+
dispatch({ type: 'pageReceived', page: res.data, mode, at: nowIso() });
|
|
46
|
+
else
|
|
47
|
+
dispatch({ type: 'loadFailed', mode });
|
|
48
|
+
}, [client]);
|
|
49
|
+
const poll = useCallback(async () => {
|
|
50
|
+
const res = await client.summary();
|
|
51
|
+
if (!res.ok)
|
|
52
|
+
return res.code === 'not_signed_in' ? { result: 'stop' } : { result: 'failed' };
|
|
53
|
+
const before = latest.current;
|
|
54
|
+
dispatch({ type: 'summaryReceived', summary: res.data, at: nowIso() });
|
|
55
|
+
const changed = res.data.unreadCount !== before.unreadCount || res.data.newestUnreadAt !== before.newestUnreadAt;
|
|
56
|
+
// A list on screen follows the change (13.3). One that is mid-action is left alone.
|
|
57
|
+
if (changed && lists.current > 0 && before.loaded && before.status === 'idle' && !before.markAll && Object.keys(before.pending).length === 0)
|
|
58
|
+
void load('merge', before.tab);
|
|
59
|
+
return { result: 'ok', pollSeconds: res.data.pollSeconds };
|
|
60
|
+
}, [client, load]);
|
|
61
|
+
useInboxPolling(poll);
|
|
62
|
+
const attachList = useCallback(() => {
|
|
63
|
+
lists.current += 1;
|
|
64
|
+
const shown = latest.current;
|
|
65
|
+
void load(shown.loaded && shown.tab === tabRef.current ? 'merge' : 'replace', tabRef.current);
|
|
66
|
+
return () => {
|
|
67
|
+
lists.current -= 1;
|
|
68
|
+
};
|
|
69
|
+
}, [load]);
|
|
70
|
+
const setTab = useCallback((tab) => {
|
|
71
|
+
if (tab === tabRef.current)
|
|
72
|
+
return;
|
|
73
|
+
tabRef.current = tab;
|
|
74
|
+
dispatch({ type: 'tabChanged', tab });
|
|
75
|
+
void load('replace', tab);
|
|
76
|
+
}, [load]);
|
|
77
|
+
const select = useCallback((item) => {
|
|
78
|
+
const link = safeLink(item.link);
|
|
79
|
+
if (link)
|
|
80
|
+
(openLink ?? openInNewTab)(link);
|
|
81
|
+
if (item.read || latest.current.pending[item.id])
|
|
82
|
+
return;
|
|
83
|
+
dispatch({ type: 'markReadOptimistic', id: item.id, at: nowIso() });
|
|
84
|
+
void client.markRead(item.id).then((res) => {
|
|
85
|
+
if (res.ok)
|
|
86
|
+
dispatch({ type: 'markReadConfirmed', id: item.id, notification: res.data.notification, unreadCount: res.data.unreadCount });
|
|
87
|
+
else
|
|
88
|
+
dispatch({ type: 'markReadFailed', id: item.id });
|
|
89
|
+
});
|
|
90
|
+
}, [client, openLink]);
|
|
91
|
+
const markAllRead = useCallback(() => {
|
|
92
|
+
const before = latest.current;
|
|
93
|
+
if (!canMarkAll(before))
|
|
94
|
+
return;
|
|
95
|
+
// Only what the person could have seen: notifications that arrive later stay unread.
|
|
96
|
+
const upTo = before.newestUnreadAt ?? before.items[0]?.createdAt ?? null;
|
|
97
|
+
dispatch({ type: 'markAllOptimistic', at: nowIso() });
|
|
98
|
+
void (async () => {
|
|
99
|
+
let unreadCount = 0;
|
|
100
|
+
for (let i = 0; i < READ_ALL_MAX_CALLS; i += 1) {
|
|
101
|
+
const res = await client.markAllRead(upTo);
|
|
102
|
+
if (!res.ok) {
|
|
103
|
+
dispatch({ type: 'markAllFailed' });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
unreadCount = res.data.unreadCount;
|
|
107
|
+
if (res.data.remaining === 0)
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
dispatch({ type: 'markAllConfirmed', unreadCount });
|
|
111
|
+
})();
|
|
112
|
+
}, [client]);
|
|
113
|
+
const loadMore = useCallback(() => {
|
|
114
|
+
const s = latest.current;
|
|
115
|
+
if (s.nextCursor && !s.loadingMore)
|
|
116
|
+
void load('append', s.tab, s.nextCursor);
|
|
117
|
+
}, [load]);
|
|
118
|
+
const retry = useCallback(() => void load('replace', latest.current.tab), [load]);
|
|
119
|
+
const clearMessage = useCallback(() => dispatch({ type: 'messageCleared' }), []);
|
|
120
|
+
const openPanel = useCallback(() => setPanelOpened(true), []);
|
|
121
|
+
const closePanel = useCallback(() => setPanelOpened(false), []);
|
|
122
|
+
const viewAll = useCallback(() => {
|
|
123
|
+
setPanelOpened(false);
|
|
124
|
+
// "View all" means all: it never opens on the Unread tab the panel was showing.
|
|
125
|
+
if (tabRef.current !== 'all') {
|
|
126
|
+
tabRef.current = 'all';
|
|
127
|
+
dispatch({ type: 'tabChanged', tab: 'all' });
|
|
128
|
+
}
|
|
129
|
+
if (onViewAll)
|
|
130
|
+
onViewAll();
|
|
131
|
+
else
|
|
132
|
+
setFullOpened(true);
|
|
133
|
+
}, [onViewAll]);
|
|
134
|
+
const openPreferences = useCallback(() => {
|
|
135
|
+
setPanelOpened(false);
|
|
136
|
+
if (onOpenPreferences)
|
|
137
|
+
onOpenPreferences();
|
|
138
|
+
else
|
|
139
|
+
setPreferencesOpened(true);
|
|
140
|
+
}, [onOpenPreferences]);
|
|
141
|
+
// The control that opened a dialog is gone with the panel, so focus goes back to the bell (15.2).
|
|
142
|
+
const focusBell = useCallback(() => {
|
|
143
|
+
window.setTimeout(() => bellRef.current?.focus(), 0);
|
|
144
|
+
}, []);
|
|
145
|
+
const value = useMemo(() => ({ state, labels: merged, locale, client, panelOpened, bellRef, openPanel, closePanel, setTab, select, markAllRead, loadMore, viewAll, openPreferences, retry, clearMessage, attachList }), [state, merged, locale, client, panelOpened, openPanel, closePanel, setTab, select, markAllRead, loadMore, viewAll, openPreferences, retry, clearMessage, attachList]);
|
|
146
|
+
const transition = reduceMotion ? { duration: 0 } : undefined;
|
|
147
|
+
return (_jsxs(InboxContext.Provider, { value: value, children: [children, _jsx(Modal, { opened: fullOpened, onClose: () => {
|
|
148
|
+
setFullOpened(false);
|
|
149
|
+
focusBell();
|
|
150
|
+
}, title: merged.fullListTitle, closeButtonProps: { 'aria-label': merged.close }, size: "lg", returnFocus: false, transitionProps: transition, styles: { body: { padding: 0 } }, children: fullOpened && _jsx(InboxFullList, {}) }), _jsx(PreferenceDialog, { opened: preferencesOpened, onClose: () => {
|
|
151
|
+
setPreferencesOpened(false);
|
|
152
|
+
focusBell();
|
|
153
|
+
} })] }));
|
|
154
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export const initialInboxState = {
|
|
2
|
+
unreadCount: 0, newestUnreadAt: null, items: [], nextCursor: null, tab: 'all', status: 'idle', loaded: false, loadingMore: false, lastSyncedAt: null, pending: {}, markAll: null, actionFailed: false,
|
|
3
|
+
};
|
|
4
|
+
const busy = (s) => s.markAll !== null || Object.keys(s.pending).length > 0;
|
|
5
|
+
const asRead = (n, at) => (n.read ? n : { ...n, read: true, readAt: at });
|
|
6
|
+
/** Newest first by (createdAt, id): true when `a` comes after `b` in the list. */
|
|
7
|
+
const olderThan = (a, b) => a.createdAt < b.createdAt || (a.createdAt === b.createdAt && a.id < b.id);
|
|
8
|
+
function mergePage(s, page, mode) {
|
|
9
|
+
if (mode === 'append') {
|
|
10
|
+
const seen = new Set(s.items.map((n) => n.id));
|
|
11
|
+
return { items: [...s.items, ...page.notifications.filter((n) => !seen.has(n.id))], nextCursor: page.nextCursor };
|
|
12
|
+
}
|
|
13
|
+
const last = page.notifications.at(-1);
|
|
14
|
+
if (mode === 'replace' || !last || page.nextCursor === null)
|
|
15
|
+
return { items: page.notifications, nextCursor: page.nextCursor };
|
|
16
|
+
// merge: the page is the newest part; keep what was already shown below it.
|
|
17
|
+
const fresh = new Set(page.notifications.map((n) => n.id));
|
|
18
|
+
const kept = s.items.filter((n) => !fresh.has(n.id) && olderThan(n, last));
|
|
19
|
+
return kept.length > 0 ? { items: [...page.notifications, ...kept], nextCursor: s.nextCursor } : { items: page.notifications, nextCursor: page.nextCursor };
|
|
20
|
+
}
|
|
21
|
+
export function inboxReducer(s, a) {
|
|
22
|
+
switch (a.type) {
|
|
23
|
+
case 'summaryReceived':
|
|
24
|
+
// While an optimistic change waits for its answer, the count on screen is the newer truth.
|
|
25
|
+
return busy(s) ? { ...s, lastSyncedAt: a.at } : { ...s, unreadCount: a.summary.unreadCount, newestUnreadAt: a.summary.newestUnreadAt, lastSyncedAt: a.at };
|
|
26
|
+
case 'loadStarted':
|
|
27
|
+
if (a.mode === 'append')
|
|
28
|
+
return { ...s, loadingMore: true };
|
|
29
|
+
return { ...s, loadingMore: false, status: s.loaded ? 'refreshing' : 'loading' };
|
|
30
|
+
case 'pageReceived': {
|
|
31
|
+
const merged = mergePage(s, a.page, a.mode);
|
|
32
|
+
// An item whose "mark read" is still in flight stays read, whatever this page says.
|
|
33
|
+
const stillUnread = merged.items.filter((n) => !n.read && (s.pending[n.id] || s.markAll)).length;
|
|
34
|
+
const items = merged.items.map((n) => (s.pending[n.id] || s.markAll ? asRead(n, a.at) : n));
|
|
35
|
+
return {
|
|
36
|
+
...s, items, nextCursor: merged.nextCursor, loaded: true, loadingMore: false, status: 'idle', lastSyncedAt: a.at,
|
|
37
|
+
unreadCount: s.markAll ? s.unreadCount : Math.max(0, a.page.unreadCount - stillUnread),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
case 'loadFailed':
|
|
41
|
+
if (a.mode === 'append')
|
|
42
|
+
return { ...s, loadingMore: false, actionFailed: true };
|
|
43
|
+
return { ...s, loadingMore: false, status: s.loaded ? 'idle' : 'error' };
|
|
44
|
+
case 'tabChanged':
|
|
45
|
+
return a.tab === s.tab ? s : { ...s, tab: a.tab, items: [], nextCursor: null, loaded: false, loadingMore: false, status: 'loading' };
|
|
46
|
+
case 'markReadOptimistic': {
|
|
47
|
+
const item = s.items.find((n) => n.id === a.id);
|
|
48
|
+
if (!item || item.read || s.pending[a.id])
|
|
49
|
+
return s;
|
|
50
|
+
return { ...s, items: s.items.map((n) => (n.id === a.id ? asRead(n, a.at) : n)), unreadCount: Math.max(0, s.unreadCount - 1), pending: { ...s.pending, [a.id]: item } };
|
|
51
|
+
}
|
|
52
|
+
case 'markReadConfirmed': {
|
|
53
|
+
if (!s.pending[a.id])
|
|
54
|
+
return s;
|
|
55
|
+
const { [a.id]: _done, ...pending } = s.pending;
|
|
56
|
+
void _done;
|
|
57
|
+
const settled = Object.keys(pending).length === 0 && !s.markAll;
|
|
58
|
+
return { ...s, pending, items: s.items.map((n) => (n.id === a.id ? a.notification : n)), unreadCount: settled ? a.unreadCount : s.unreadCount };
|
|
59
|
+
}
|
|
60
|
+
case 'markReadFailed': {
|
|
61
|
+
const previous = s.pending[a.id];
|
|
62
|
+
if (!previous)
|
|
63
|
+
return s;
|
|
64
|
+
const { [a.id]: _undone, ...pending } = s.pending;
|
|
65
|
+
void _undone;
|
|
66
|
+
return { ...s, pending, items: s.items.map((n) => (n.id === a.id ? previous : n)), unreadCount: s.unreadCount + 1, actionFailed: true };
|
|
67
|
+
}
|
|
68
|
+
case 'markAllOptimistic':
|
|
69
|
+
if (!canMarkAll(s))
|
|
70
|
+
return s;
|
|
71
|
+
return { ...s, markAll: { items: s.items, unreadCount: s.unreadCount }, items: s.items.map((n) => asRead(n, a.at)), unreadCount: 0 };
|
|
72
|
+
case 'markAllConfirmed':
|
|
73
|
+
return s.markAll ? { ...s, markAll: null, unreadCount: a.unreadCount } : s;
|
|
74
|
+
case 'markAllFailed':
|
|
75
|
+
return s.markAll ? { ...s, markAll: null, items: s.markAll.items, unreadCount: s.markAll.unreadCount, actionFailed: true } : s;
|
|
76
|
+
case 'messageCleared':
|
|
77
|
+
return s.actionFailed ? { ...s, actionFailed: false } : s;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** "Mark all as read" is offered only when something is unread and no such request is running (12.8). */
|
|
81
|
+
export function canMarkAll(s) {
|
|
82
|
+
return s.unreadCount > 0 && s.markAll === null;
|
|
83
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The in-app inbox for an app: a bell with an unread badge, a panel, a full list and the person's preference.
|
|
3
|
+
* The bell shows what this app sent the person, and the tenant's announcements.
|
|
4
|
+
*
|
|
5
|
+
* import { InboxProvider, NotificationBell } from '@xtremax/pigeon/inbox-react';
|
|
6
|
+
* import '@xtremax/pigeon/inbox-react/styles.css';
|
|
7
|
+
*
|
|
8
|
+
* <InboxProvider>
|
|
9
|
+
* <NotificationBell />
|
|
10
|
+
* </InboxProvider>
|
|
11
|
+
*
|
|
12
|
+
* The components call the host app's own routes (default `/api/communication/inbox`), which
|
|
13
|
+
* `createInboxHandler` from `@xtremax/pigeon/inbox` serves. They need React 19, Mantine 8 and Tabler icons. Nothing here holds a key or talks to another origin.
|
|
14
|
+
*/
|
|
15
|
+
export { InboxProvider } from './inbox-provider.js';
|
|
16
|
+
export { NotificationBell } from './notification-bell.js';
|
|
17
|
+
export { InboxFullList } from './inbox-full-list.js';
|
|
18
|
+
export { DEFAULT_INBOX_LABELS } from './labels.js';
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const DEFAULT_INBOX_LABELS = {
|
|
2
|
+
bell: 'Notifications',
|
|
3
|
+
bellWithCount: '{label}, {n} unread',
|
|
4
|
+
heading: 'Notifications',
|
|
5
|
+
tabAll: 'All',
|
|
6
|
+
tabUnread: 'Unread',
|
|
7
|
+
tabUnreadWithCount: 'Unread ({n})',
|
|
8
|
+
markAllRead: 'Mark all as read',
|
|
9
|
+
viewAll: 'View all',
|
|
10
|
+
preferences: 'Notification preferences',
|
|
11
|
+
emptyAll: 'You have no notifications.',
|
|
12
|
+
emptyUnread: 'You have no unread notifications.',
|
|
13
|
+
loading: 'Loading notifications',
|
|
14
|
+
loadError: 'Notifications could not be loaded.',
|
|
15
|
+
retry: 'Try again',
|
|
16
|
+
actionError: 'That did not work. Try again.',
|
|
17
|
+
dismiss: 'Dismiss',
|
|
18
|
+
close: 'Close',
|
|
19
|
+
unread: 'Unread',
|
|
20
|
+
announcement: 'Announcement',
|
|
21
|
+
loadMore: 'Show older notifications',
|
|
22
|
+
newOne: '1 new notification',
|
|
23
|
+
newMany: '{n} new notifications',
|
|
24
|
+
fullListTitle: 'All notifications',
|
|
25
|
+
preferencesTitle: 'Notification preferences',
|
|
26
|
+
preferenceToggle: 'Receive in-app notifications',
|
|
27
|
+
preferenceOn: 'New notifications from this app appear here.',
|
|
28
|
+
preferenceOff: 'New notifications from this app do not appear here. The ones you already have stay. Other apps are not affected.',
|
|
29
|
+
preferenceLoadError: 'Your preference could not be loaded.',
|
|
30
|
+
preferenceSaveError: 'Your choice was not saved. Try again.',
|
|
31
|
+
};
|
|
32
|
+
export function mergeLabels(labels) {
|
|
33
|
+
return { ...DEFAULT_INBOX_LABELS, ...(labels ?? {}) };
|
|
34
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { ActionIcon, Indicator, Popover, VisuallyHidden } from '@mantine/core';
|
|
5
|
+
import { useReducedMotion } from '@mantine/hooks';
|
|
6
|
+
import { IconBell } from '@tabler/icons-react';
|
|
7
|
+
import { badgeText, bellName, fill } from './format.js';
|
|
8
|
+
import { useInbox } from './inbox-context.js';
|
|
9
|
+
import { InboxPanel } from './inbox-panel.js';
|
|
10
|
+
/** The bell with its unread badge. Opens the inbox panel. Place it inside `InboxProvider`. */
|
|
11
|
+
export function NotificationBell({ size = 44 }) {
|
|
12
|
+
const { state, labels, panelOpened, openPanel, closePanel, bellRef } = useInbox();
|
|
13
|
+
const reduceMotion = useReducedMotion();
|
|
14
|
+
const badge = badgeText(state.unreadCount);
|
|
15
|
+
// A rise of the count while the panel is closed is announced, without moving focus (15.4). The first count is not news.
|
|
16
|
+
const [announcement, setAnnouncement] = useState('');
|
|
17
|
+
const known = useRef(null);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (state.lastSyncedAt === null)
|
|
20
|
+
return;
|
|
21
|
+
const before = known.current;
|
|
22
|
+
known.current = state.unreadCount;
|
|
23
|
+
if (before === null || state.unreadCount <= before || panelOpened)
|
|
24
|
+
return;
|
|
25
|
+
const rise = state.unreadCount - before;
|
|
26
|
+
setAnnouncement(rise === 1 ? labels.newOne : fill(labels.newMany, { n: rise }));
|
|
27
|
+
}, [state.unreadCount, state.lastSyncedAt, panelOpened, labels]);
|
|
28
|
+
return (_jsxs(_Fragment, { children: [_jsx(Indicator, { inline: true, label: badge, disabled: !badge, size: 18, offset: 8, children: _jsxs(Popover, { opened: panelOpened, onChange: (opened) => (opened ? openPanel() : closePanel()), position: "bottom-end", shadow: "md", trapFocus: true, returnFocus: true, closeOnEscape: true, withinPortal: true, transitionProps: reduceMotion ? { duration: 0 } : undefined, children: [_jsx(Popover.Target, { children: _jsx(ActionIcon, { ref: bellRef, variant: "subtle", color: "gray", radius: "xl", size: Math.max(44, size), "aria-label": bellName(labels, state.unreadCount), onClick: () => (panelOpened ? closePanel() : openPanel()), children: _jsx(IconBell, { size: 22, stroke: 1.75, "aria-hidden": "true" }) }) }), _jsx(Popover.Dropdown, { p: 0, style: { width: 'min(380px, calc(100vw - 16px))' }, children: _jsx(InboxPanel, {}) })] }) }), _jsx(VisuallyHidden, { role: "status", "aria-live": "polite", children: announcement })] }));
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Badge, Text, UnstyledButton, VisuallyHidden } from '@mantine/core';
|
|
4
|
+
import { exactTime, relativeTime } from './format.js';
|
|
5
|
+
import { useInbox } from './inbox-context.js';
|
|
6
|
+
/**
|
|
7
|
+
* One notification. The whole row is one button: selecting it marks the notification read and opens its link when
|
|
8
|
+
* it has a safe one. Title and body are text nodes, never markup (12.14).
|
|
9
|
+
*/
|
|
10
|
+
export function NotificationItem({ item, now }) {
|
|
11
|
+
const { labels, locale, select } = useInbox();
|
|
12
|
+
const unread = !item.read;
|
|
13
|
+
return (_jsxs(UnstyledButton, { className: "xci-row", onClick: () => select(item), "data-unread": unread || undefined, children: [_jsx("span", { className: "xci-dot", "data-unread": unread || undefined, "aria-hidden": "true" }), _jsxs("span", { className: "xci-content", children: [unread && _jsxs(VisuallyHidden, { children: [labels.unread, "."] }), _jsxs("span", { className: "xci-meta", children: [_jsx(Text, { component: "span", size: "xs", c: "dimmed", truncate: true, children: item.source.name }), item.kind === 'announcement' && (_jsx(Badge, { component: "span", size: "xs", variant: "light", style: { flex: 'none' }, children: labels.announcement }))] }), _jsx(Text, { component: "span", className: "xci-line", size: "sm", fw: unread ? 700 : 400, lineClamp: 2, children: item.title }), _jsx(Text, { component: "span", className: "xci-line", size: "sm", c: "dimmed", lineClamp: 3, children: item.body }), _jsx(Text, { component: "time", className: "xci-line", size: "xs", c: "dimmed", dateTime: item.createdAt, title: exactTime(item.createdAt, locale), children: relativeTime(item.createdAt, now, locale) })] })] }));
|
|
14
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { INBOX_ERROR_CODES } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Tolerant parsing (17.4): each parser takes only the fields it knows and ignores the rest at any depth, so a field
|
|
4
|
+
* added later changes nothing here. A value that lacks a known field gives `null`, never a half-filled object.
|
|
5
|
+
*/
|
|
6
|
+
const obj = (v) => (typeof v === 'object' && v !== null && !Array.isArray(v) ? v : null);
|
|
7
|
+
const str = (v) => (typeof v === 'string' ? v : null);
|
|
8
|
+
const strOrNull = (v) => (v === null ? null : typeof v === 'string' ? v : undefined);
|
|
9
|
+
const count = (v) => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null);
|
|
10
|
+
export function parseNotification(v) {
|
|
11
|
+
const o = obj(v);
|
|
12
|
+
const source = obj(o?.source);
|
|
13
|
+
if (!o || !source)
|
|
14
|
+
return null;
|
|
15
|
+
const id = str(o.id);
|
|
16
|
+
const title = str(o.title);
|
|
17
|
+
const body = str(o.body);
|
|
18
|
+
const createdAt = str(o.createdAt);
|
|
19
|
+
const link = strOrNull(o.link);
|
|
20
|
+
const readAt = strOrNull(o.readAt);
|
|
21
|
+
const name = str(source.name);
|
|
22
|
+
if (id === null || title === null || body === null || createdAt === null || link === undefined || readAt === undefined || name === null)
|
|
23
|
+
return null;
|
|
24
|
+
if (o.kind !== 'notification' && o.kind !== 'announcement')
|
|
25
|
+
return null;
|
|
26
|
+
if (source.type !== 'xapp' && source.type !== 'tenant')
|
|
27
|
+
return null;
|
|
28
|
+
if (typeof o.read !== 'boolean')
|
|
29
|
+
return null;
|
|
30
|
+
return { id, kind: o.kind, title, body, link, source: { type: source.type, name }, createdAt, read: o.read, readAt };
|
|
31
|
+
}
|
|
32
|
+
export function parseSummary(v) {
|
|
33
|
+
const o = obj(v);
|
|
34
|
+
if (!o)
|
|
35
|
+
return null;
|
|
36
|
+
const unreadCount = count(o.unreadCount);
|
|
37
|
+
const newestUnreadAt = strOrNull(o.newestUnreadAt);
|
|
38
|
+
const pollSeconds = count(o.pollSeconds);
|
|
39
|
+
return unreadCount === null || newestUnreadAt === undefined || pollSeconds === null ? null : { unreadCount, newestUnreadAt, pollSeconds };
|
|
40
|
+
}
|
|
41
|
+
export function parsePage(v) {
|
|
42
|
+
const o = obj(v);
|
|
43
|
+
if (!o || !Array.isArray(o.notifications))
|
|
44
|
+
return null;
|
|
45
|
+
const notifications = [];
|
|
46
|
+
for (const raw of o.notifications) {
|
|
47
|
+
const n = parseNotification(raw);
|
|
48
|
+
if (!n)
|
|
49
|
+
return null;
|
|
50
|
+
notifications.push(n);
|
|
51
|
+
}
|
|
52
|
+
const nextCursor = strOrNull(o.nextCursor);
|
|
53
|
+
const unreadCount = count(o.unreadCount);
|
|
54
|
+
return nextCursor === undefined || unreadCount === null ? null : { notifications, nextCursor, unreadCount };
|
|
55
|
+
}
|
|
56
|
+
export function parseMarkRead(v) {
|
|
57
|
+
const o = obj(v);
|
|
58
|
+
const notification = parseNotification(o?.notification);
|
|
59
|
+
const unreadCount = count(o?.unreadCount);
|
|
60
|
+
return notification && unreadCount !== null ? { notification, unreadCount } : null;
|
|
61
|
+
}
|
|
62
|
+
export function parseReadAll(v) {
|
|
63
|
+
const o = obj(v);
|
|
64
|
+
const marked = count(o?.marked);
|
|
65
|
+
const remaining = count(o?.remaining);
|
|
66
|
+
const unreadCount = count(o?.unreadCount);
|
|
67
|
+
return marked === null || remaining === null || unreadCount === null ? null : { marked, remaining, unreadCount };
|
|
68
|
+
}
|
|
69
|
+
export function parsePreference(v) {
|
|
70
|
+
const o = obj(v);
|
|
71
|
+
if (!o)
|
|
72
|
+
return null;
|
|
73
|
+
const changedAt = strOrNull(o.changedAt);
|
|
74
|
+
if (o.state !== 'opted_in' && o.state !== 'opted_out' && o.state !== 'not_set')
|
|
75
|
+
return null;
|
|
76
|
+
return changedAt === undefined || typeof o.delivers !== 'boolean' ? null : { state: o.state, changedAt, delivers: o.delivers };
|
|
77
|
+
}
|
|
78
|
+
/** The code of an error body, or the nearest one for the status. Anything unknown is `unavailable`. */
|
|
79
|
+
export function parseErrorCode(status, body) {
|
|
80
|
+
const code = obj(obj(body)?.error)?.code;
|
|
81
|
+
if (typeof code === 'string' && INBOX_ERROR_CODES.includes(code))
|
|
82
|
+
return code;
|
|
83
|
+
const byStatus = { 401: 'not_signed_in', 403: 'not_permitted', 404: 'not_found', 422: 'invalid_input', 429: 'rate_limited' };
|
|
84
|
+
return byStatus[status] ?? 'unavailable';
|
|
85
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
import { Alert, Button, Center, Loader, Modal, Stack, Switch } from '@mantine/core';
|
|
5
|
+
import { useReducedMotion } from '@mantine/hooks';
|
|
6
|
+
import { useInbox } from './inbox-context.js';
|
|
7
|
+
/** The person's own choice for this app's in-app notifications. Other apps are not affected. */
|
|
8
|
+
export function PreferenceDialog({ opened, onClose }) {
|
|
9
|
+
const { client, labels } = useInbox();
|
|
10
|
+
const reduceMotion = useReducedMotion();
|
|
11
|
+
const [preference, setPreference] = useState(null);
|
|
12
|
+
const [status, setStatus] = useState('loading');
|
|
13
|
+
const [saving, setSaving] = useState(false);
|
|
14
|
+
const [saveFailed, setSaveFailed] = useState(false);
|
|
15
|
+
const load = useCallback(async () => {
|
|
16
|
+
setStatus('loading');
|
|
17
|
+
const res = await client.getPreference();
|
|
18
|
+
if (res.ok)
|
|
19
|
+
setPreference(res.data);
|
|
20
|
+
setStatus(res.ok ? 'ready' : 'error');
|
|
21
|
+
}, [client]);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!opened)
|
|
24
|
+
return;
|
|
25
|
+
setSaveFailed(false);
|
|
26
|
+
void load();
|
|
27
|
+
}, [opened, load]);
|
|
28
|
+
// The switch moves at once; a failure moves it back and says so.
|
|
29
|
+
const change = async (on) => {
|
|
30
|
+
if (!preference || saving)
|
|
31
|
+
return;
|
|
32
|
+
const before = preference;
|
|
33
|
+
setSaveFailed(false);
|
|
34
|
+
setSaving(true);
|
|
35
|
+
setPreference({ ...preference, state: on ? 'opted_in' : 'opted_out', delivers: on });
|
|
36
|
+
const res = await client.setPreference(on ? 'opted_in' : 'opted_out');
|
|
37
|
+
setSaving(false);
|
|
38
|
+
if (res.ok)
|
|
39
|
+
setPreference(res.data);
|
|
40
|
+
else {
|
|
41
|
+
setPreference(before);
|
|
42
|
+
setSaveFailed(true);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
return (_jsxs(Modal, { opened: opened, onClose: onClose, title: labels.preferencesTitle, closeButtonProps: { 'aria-label': labels.close }, returnFocus: false, transitionProps: reduceMotion ? { duration: 0 } : undefined, children: [status === 'loading' && (_jsx(Center, { mih: 96, role: "status", "aria-label": labels.loading, children: _jsx(Loader, { size: "sm" }) })), status === 'error' && (_jsx(Alert, { color: "red", variant: "light", title: labels.preferenceLoadError, children: _jsx(Button, { variant: "default", size: "sm", mih: 44, onClick: () => void load(), children: labels.retry }) })), status === 'ready' && preference && (_jsxs(Stack, { gap: "sm", children: [_jsx(Switch, { label: labels.preferenceToggle, description: preference.delivers ? labels.preferenceOn : labels.preferenceOff, checked: preference.state === 'not_set' ? preference.delivers : preference.state === 'opted_in', onChange: (event) => void change(event.currentTarget.checked) }), saveFailed && (_jsx(Alert, { color: "red", variant: "light", role: "alert", children: labels.preferenceSaveError }))] }))] }));
|
|
46
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const INBOX_ERROR_CODES = ['not_signed_in', 'not_permitted', 'not_found', 'invalid_input', 'rate_limited', 'unavailable'];
|