@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,157 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
'use client';
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.InboxProvider = InboxProvider;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
const react_1 = require("react");
|
|
7
|
+
const core_1 = require("@mantine/core");
|
|
8
|
+
const hooks_1 = require("@mantine/hooks");
|
|
9
|
+
const format_1 = require("./format");
|
|
10
|
+
const inbox_client_1 = require("./inbox-client");
|
|
11
|
+
const inbox_context_1 = require("./inbox-context");
|
|
12
|
+
const inbox_full_list_1 = require("./inbox-full-list");
|
|
13
|
+
const inbox_state_1 = require("./inbox-state");
|
|
14
|
+
const labels_1 = require("./labels");
|
|
15
|
+
const preference_dialog_1 = require("./preference-dialog");
|
|
16
|
+
const use_inbox_polling_1 = require("./use-inbox-polling");
|
|
17
|
+
const openInNewTab = (url) => {
|
|
18
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
19
|
+
};
|
|
20
|
+
/** "Mark all as read" changes at most one batch per call; repeat while something remains, within a bound. */
|
|
21
|
+
const READ_ALL_MAX_CALLS = 10;
|
|
22
|
+
const nowIso = () => new Date().toISOString();
|
|
23
|
+
function InboxProvider({ apiBase, labels, locale, onViewAll, onOpenPreferences, openLink, children }) {
|
|
24
|
+
const client = (0, react_1.useMemo)(() => (0, inbox_client_1.createInboxBrowserClient)(apiBase), [apiBase]);
|
|
25
|
+
const merged = (0, react_1.useMemo)(() => (0, labels_1.mergeLabels)(labels), [labels]);
|
|
26
|
+
const [state, dispatch] = (0, react_1.useReducer)(inbox_state_1.inboxReducer, inbox_state_1.initialInboxState);
|
|
27
|
+
const [panelOpened, setPanelOpened] = (0, react_1.useState)(false);
|
|
28
|
+
const [fullOpened, setFullOpened] = (0, react_1.useState)(false);
|
|
29
|
+
const [preferencesOpened, setPreferencesOpened] = (0, react_1.useState)(false);
|
|
30
|
+
const reduceMotion = (0, hooks_1.useReducedMotion)();
|
|
31
|
+
const bellRef = (0, react_1.useRef)(null);
|
|
32
|
+
const latest = (0, react_1.useRef)(state);
|
|
33
|
+
(0, react_1.useEffect)(() => {
|
|
34
|
+
latest.current = state;
|
|
35
|
+
}, [state]);
|
|
36
|
+
// The tab is also kept here: a list that mounts in the same render as a tab change must load the new tab.
|
|
37
|
+
const tabRef = (0, react_1.useRef)('all');
|
|
38
|
+
const lists = (0, react_1.useRef)(0);
|
|
39
|
+
const request = (0, react_1.useRef)(0);
|
|
40
|
+
const load = (0, react_1.useCallback)(async (mode, tab, cursor) => {
|
|
41
|
+
const mine = (request.current += 1);
|
|
42
|
+
dispatch({ type: 'loadStarted', mode });
|
|
43
|
+
const res = await client.list({ unreadOnly: tab === 'unread', cursor });
|
|
44
|
+
// An answer for a tab or a list the person has already left is dropped.
|
|
45
|
+
if (mine !== request.current)
|
|
46
|
+
return;
|
|
47
|
+
if (res.ok)
|
|
48
|
+
dispatch({ type: 'pageReceived', page: res.data, mode, at: nowIso() });
|
|
49
|
+
else
|
|
50
|
+
dispatch({ type: 'loadFailed', mode });
|
|
51
|
+
}, [client]);
|
|
52
|
+
const poll = (0, react_1.useCallback)(async () => {
|
|
53
|
+
const res = await client.summary();
|
|
54
|
+
if (!res.ok)
|
|
55
|
+
return res.code === 'not_signed_in' ? { result: 'stop' } : { result: 'failed' };
|
|
56
|
+
const before = latest.current;
|
|
57
|
+
dispatch({ type: 'summaryReceived', summary: res.data, at: nowIso() });
|
|
58
|
+
const changed = res.data.unreadCount !== before.unreadCount || res.data.newestUnreadAt !== before.newestUnreadAt;
|
|
59
|
+
// A list on screen follows the change (13.3). One that is mid-action is left alone.
|
|
60
|
+
if (changed && lists.current > 0 && before.loaded && before.status === 'idle' && !before.markAll && Object.keys(before.pending).length === 0)
|
|
61
|
+
void load('merge', before.tab);
|
|
62
|
+
return { result: 'ok', pollSeconds: res.data.pollSeconds };
|
|
63
|
+
}, [client, load]);
|
|
64
|
+
(0, use_inbox_polling_1.useInboxPolling)(poll);
|
|
65
|
+
const attachList = (0, react_1.useCallback)(() => {
|
|
66
|
+
lists.current += 1;
|
|
67
|
+
const shown = latest.current;
|
|
68
|
+
void load(shown.loaded && shown.tab === tabRef.current ? 'merge' : 'replace', tabRef.current);
|
|
69
|
+
return () => {
|
|
70
|
+
lists.current -= 1;
|
|
71
|
+
};
|
|
72
|
+
}, [load]);
|
|
73
|
+
const setTab = (0, react_1.useCallback)((tab) => {
|
|
74
|
+
if (tab === tabRef.current)
|
|
75
|
+
return;
|
|
76
|
+
tabRef.current = tab;
|
|
77
|
+
dispatch({ type: 'tabChanged', tab });
|
|
78
|
+
void load('replace', tab);
|
|
79
|
+
}, [load]);
|
|
80
|
+
const select = (0, react_1.useCallback)((item) => {
|
|
81
|
+
const link = (0, format_1.safeLink)(item.link);
|
|
82
|
+
if (link)
|
|
83
|
+
(openLink ?? openInNewTab)(link);
|
|
84
|
+
if (item.read || latest.current.pending[item.id])
|
|
85
|
+
return;
|
|
86
|
+
dispatch({ type: 'markReadOptimistic', id: item.id, at: nowIso() });
|
|
87
|
+
void client.markRead(item.id).then((res) => {
|
|
88
|
+
if (res.ok)
|
|
89
|
+
dispatch({ type: 'markReadConfirmed', id: item.id, notification: res.data.notification, unreadCount: res.data.unreadCount });
|
|
90
|
+
else
|
|
91
|
+
dispatch({ type: 'markReadFailed', id: item.id });
|
|
92
|
+
});
|
|
93
|
+
}, [client, openLink]);
|
|
94
|
+
const markAllRead = (0, react_1.useCallback)(() => {
|
|
95
|
+
const before = latest.current;
|
|
96
|
+
if (!(0, inbox_state_1.canMarkAll)(before))
|
|
97
|
+
return;
|
|
98
|
+
// Only what the person could have seen: notifications that arrive later stay unread.
|
|
99
|
+
const upTo = before.newestUnreadAt ?? before.items[0]?.createdAt ?? null;
|
|
100
|
+
dispatch({ type: 'markAllOptimistic', at: nowIso() });
|
|
101
|
+
void (async () => {
|
|
102
|
+
let unreadCount = 0;
|
|
103
|
+
for (let i = 0; i < READ_ALL_MAX_CALLS; i += 1) {
|
|
104
|
+
const res = await client.markAllRead(upTo);
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
dispatch({ type: 'markAllFailed' });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
unreadCount = res.data.unreadCount;
|
|
110
|
+
if (res.data.remaining === 0)
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
dispatch({ type: 'markAllConfirmed', unreadCount });
|
|
114
|
+
})();
|
|
115
|
+
}, [client]);
|
|
116
|
+
const loadMore = (0, react_1.useCallback)(() => {
|
|
117
|
+
const s = latest.current;
|
|
118
|
+
if (s.nextCursor && !s.loadingMore)
|
|
119
|
+
void load('append', s.tab, s.nextCursor);
|
|
120
|
+
}, [load]);
|
|
121
|
+
const retry = (0, react_1.useCallback)(() => void load('replace', latest.current.tab), [load]);
|
|
122
|
+
const clearMessage = (0, react_1.useCallback)(() => dispatch({ type: 'messageCleared' }), []);
|
|
123
|
+
const openPanel = (0, react_1.useCallback)(() => setPanelOpened(true), []);
|
|
124
|
+
const closePanel = (0, react_1.useCallback)(() => setPanelOpened(false), []);
|
|
125
|
+
const viewAll = (0, react_1.useCallback)(() => {
|
|
126
|
+
setPanelOpened(false);
|
|
127
|
+
// "View all" means all: it never opens on the Unread tab the panel was showing.
|
|
128
|
+
if (tabRef.current !== 'all') {
|
|
129
|
+
tabRef.current = 'all';
|
|
130
|
+
dispatch({ type: 'tabChanged', tab: 'all' });
|
|
131
|
+
}
|
|
132
|
+
if (onViewAll)
|
|
133
|
+
onViewAll();
|
|
134
|
+
else
|
|
135
|
+
setFullOpened(true);
|
|
136
|
+
}, [onViewAll]);
|
|
137
|
+
const openPreferences = (0, react_1.useCallback)(() => {
|
|
138
|
+
setPanelOpened(false);
|
|
139
|
+
if (onOpenPreferences)
|
|
140
|
+
onOpenPreferences();
|
|
141
|
+
else
|
|
142
|
+
setPreferencesOpened(true);
|
|
143
|
+
}, [onOpenPreferences]);
|
|
144
|
+
// The control that opened a dialog is gone with the panel, so focus goes back to the bell (15.2).
|
|
145
|
+
const focusBell = (0, react_1.useCallback)(() => {
|
|
146
|
+
window.setTimeout(() => bellRef.current?.focus(), 0);
|
|
147
|
+
}, []);
|
|
148
|
+
const value = (0, react_1.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]);
|
|
149
|
+
const transition = reduceMotion ? { duration: 0 } : undefined;
|
|
150
|
+
return ((0, jsx_runtime_1.jsxs)(inbox_context_1.InboxContext.Provider, { value: value, children: [children, (0, jsx_runtime_1.jsx)(core_1.Modal, { opened: fullOpened, onClose: () => {
|
|
151
|
+
setFullOpened(false);
|
|
152
|
+
focusBell();
|
|
153
|
+
}, title: merged.fullListTitle, closeButtonProps: { 'aria-label': merged.close }, size: "lg", returnFocus: false, transitionProps: transition, styles: { body: { padding: 0 } }, children: fullOpened && (0, jsx_runtime_1.jsx)(inbox_full_list_1.InboxFullList, {}) }), (0, jsx_runtime_1.jsx)(preference_dialog_1.PreferenceDialog, { opened: preferencesOpened, onClose: () => {
|
|
154
|
+
setPreferencesOpened(false);
|
|
155
|
+
focusBell();
|
|
156
|
+
} })] }));
|
|
157
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.initialInboxState = void 0;
|
|
4
|
+
exports.inboxReducer = inboxReducer;
|
|
5
|
+
exports.canMarkAll = canMarkAll;
|
|
6
|
+
exports.initialInboxState = {
|
|
7
|
+
unreadCount: 0, newestUnreadAt: null, items: [], nextCursor: null, tab: 'all', status: 'idle', loaded: false, loadingMore: false, lastSyncedAt: null, pending: {}, markAll: null, actionFailed: false,
|
|
8
|
+
};
|
|
9
|
+
const busy = (s) => s.markAll !== null || Object.keys(s.pending).length > 0;
|
|
10
|
+
const asRead = (n, at) => (n.read ? n : { ...n, read: true, readAt: at });
|
|
11
|
+
/** Newest first by (createdAt, id): true when `a` comes after `b` in the list. */
|
|
12
|
+
const olderThan = (a, b) => a.createdAt < b.createdAt || (a.createdAt === b.createdAt && a.id < b.id);
|
|
13
|
+
function mergePage(s, page, mode) {
|
|
14
|
+
if (mode === 'append') {
|
|
15
|
+
const seen = new Set(s.items.map((n) => n.id));
|
|
16
|
+
return { items: [...s.items, ...page.notifications.filter((n) => !seen.has(n.id))], nextCursor: page.nextCursor };
|
|
17
|
+
}
|
|
18
|
+
const last = page.notifications.at(-1);
|
|
19
|
+
if (mode === 'replace' || !last || page.nextCursor === null)
|
|
20
|
+
return { items: page.notifications, nextCursor: page.nextCursor };
|
|
21
|
+
// merge: the page is the newest part; keep what was already shown below it.
|
|
22
|
+
const fresh = new Set(page.notifications.map((n) => n.id));
|
|
23
|
+
const kept = s.items.filter((n) => !fresh.has(n.id) && olderThan(n, last));
|
|
24
|
+
return kept.length > 0 ? { items: [...page.notifications, ...kept], nextCursor: s.nextCursor } : { items: page.notifications, nextCursor: page.nextCursor };
|
|
25
|
+
}
|
|
26
|
+
function inboxReducer(s, a) {
|
|
27
|
+
switch (a.type) {
|
|
28
|
+
case 'summaryReceived':
|
|
29
|
+
// While an optimistic change waits for its answer, the count on screen is the newer truth.
|
|
30
|
+
return busy(s) ? { ...s, lastSyncedAt: a.at } : { ...s, unreadCount: a.summary.unreadCount, newestUnreadAt: a.summary.newestUnreadAt, lastSyncedAt: a.at };
|
|
31
|
+
case 'loadStarted':
|
|
32
|
+
if (a.mode === 'append')
|
|
33
|
+
return { ...s, loadingMore: true };
|
|
34
|
+
return { ...s, loadingMore: false, status: s.loaded ? 'refreshing' : 'loading' };
|
|
35
|
+
case 'pageReceived': {
|
|
36
|
+
const merged = mergePage(s, a.page, a.mode);
|
|
37
|
+
// An item whose "mark read" is still in flight stays read, whatever this page says.
|
|
38
|
+
const stillUnread = merged.items.filter((n) => !n.read && (s.pending[n.id] || s.markAll)).length;
|
|
39
|
+
const items = merged.items.map((n) => (s.pending[n.id] || s.markAll ? asRead(n, a.at) : n));
|
|
40
|
+
return {
|
|
41
|
+
...s, items, nextCursor: merged.nextCursor, loaded: true, loadingMore: false, status: 'idle', lastSyncedAt: a.at,
|
|
42
|
+
unreadCount: s.markAll ? s.unreadCount : Math.max(0, a.page.unreadCount - stillUnread),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
case 'loadFailed':
|
|
46
|
+
if (a.mode === 'append')
|
|
47
|
+
return { ...s, loadingMore: false, actionFailed: true };
|
|
48
|
+
return { ...s, loadingMore: false, status: s.loaded ? 'idle' : 'error' };
|
|
49
|
+
case 'tabChanged':
|
|
50
|
+
return a.tab === s.tab ? s : { ...s, tab: a.tab, items: [], nextCursor: null, loaded: false, loadingMore: false, status: 'loading' };
|
|
51
|
+
case 'markReadOptimistic': {
|
|
52
|
+
const item = s.items.find((n) => n.id === a.id);
|
|
53
|
+
if (!item || item.read || s.pending[a.id])
|
|
54
|
+
return s;
|
|
55
|
+
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 } };
|
|
56
|
+
}
|
|
57
|
+
case 'markReadConfirmed': {
|
|
58
|
+
if (!s.pending[a.id])
|
|
59
|
+
return s;
|
|
60
|
+
const { [a.id]: _done, ...pending } = s.pending;
|
|
61
|
+
void _done;
|
|
62
|
+
const settled = Object.keys(pending).length === 0 && !s.markAll;
|
|
63
|
+
return { ...s, pending, items: s.items.map((n) => (n.id === a.id ? a.notification : n)), unreadCount: settled ? a.unreadCount : s.unreadCount };
|
|
64
|
+
}
|
|
65
|
+
case 'markReadFailed': {
|
|
66
|
+
const previous = s.pending[a.id];
|
|
67
|
+
if (!previous)
|
|
68
|
+
return s;
|
|
69
|
+
const { [a.id]: _undone, ...pending } = s.pending;
|
|
70
|
+
void _undone;
|
|
71
|
+
return { ...s, pending, items: s.items.map((n) => (n.id === a.id ? previous : n)), unreadCount: s.unreadCount + 1, actionFailed: true };
|
|
72
|
+
}
|
|
73
|
+
case 'markAllOptimistic':
|
|
74
|
+
if (!canMarkAll(s))
|
|
75
|
+
return s;
|
|
76
|
+
return { ...s, markAll: { items: s.items, unreadCount: s.unreadCount }, items: s.items.map((n) => asRead(n, a.at)), unreadCount: 0 };
|
|
77
|
+
case 'markAllConfirmed':
|
|
78
|
+
return s.markAll ? { ...s, markAll: null, unreadCount: a.unreadCount } : s;
|
|
79
|
+
case 'markAllFailed':
|
|
80
|
+
return s.markAll ? { ...s, markAll: null, items: s.markAll.items, unreadCount: s.markAll.unreadCount, actionFailed: true } : s;
|
|
81
|
+
case 'messageCleared':
|
|
82
|
+
return s.actionFailed ? { ...s, actionFailed: false } : s;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** "Mark all as read" is offered only when something is unread and no such request is running (12.8). */
|
|
86
|
+
function canMarkAll(s) {
|
|
87
|
+
return s.unreadCount > 0 && s.markAll === null;
|
|
88
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_INBOX_LABELS = exports.InboxFullList = exports.NotificationBell = exports.InboxProvider = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* The in-app inbox for an app: a bell with an unread badge, a panel, a full list and the person's preference.
|
|
6
|
+
* The bell shows what this app sent the person, and the tenant's announcements.
|
|
7
|
+
*
|
|
8
|
+
* import { InboxProvider, NotificationBell } from '@xtremax/pigeon/inbox-react';
|
|
9
|
+
* import '@xtremax/pigeon/inbox-react/styles.css';
|
|
10
|
+
*
|
|
11
|
+
* <InboxProvider>
|
|
12
|
+
* <NotificationBell />
|
|
13
|
+
* </InboxProvider>
|
|
14
|
+
*
|
|
15
|
+
* The components call the host app's own routes (default `/api/communication/inbox`), which
|
|
16
|
+
* `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.
|
|
17
|
+
*/
|
|
18
|
+
var inbox_provider_1 = require("./inbox-provider");
|
|
19
|
+
Object.defineProperty(exports, "InboxProvider", { enumerable: true, get: function () { return inbox_provider_1.InboxProvider; } });
|
|
20
|
+
var notification_bell_1 = require("./notification-bell");
|
|
21
|
+
Object.defineProperty(exports, "NotificationBell", { enumerable: true, get: function () { return notification_bell_1.NotificationBell; } });
|
|
22
|
+
var inbox_full_list_1 = require("./inbox-full-list");
|
|
23
|
+
Object.defineProperty(exports, "InboxFullList", { enumerable: true, get: function () { return inbox_full_list_1.InboxFullList; } });
|
|
24
|
+
var labels_1 = require("./labels");
|
|
25
|
+
Object.defineProperty(exports, "DEFAULT_INBOX_LABELS", { enumerable: true, get: function () { return labels_1.DEFAULT_INBOX_LABELS; } });
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_INBOX_LABELS = void 0;
|
|
4
|
+
exports.mergeLabels = mergeLabels;
|
|
5
|
+
exports.DEFAULT_INBOX_LABELS = {
|
|
6
|
+
bell: 'Notifications',
|
|
7
|
+
bellWithCount: '{label}, {n} unread',
|
|
8
|
+
heading: 'Notifications',
|
|
9
|
+
tabAll: 'All',
|
|
10
|
+
tabUnread: 'Unread',
|
|
11
|
+
tabUnreadWithCount: 'Unread ({n})',
|
|
12
|
+
markAllRead: 'Mark all as read',
|
|
13
|
+
viewAll: 'View all',
|
|
14
|
+
preferences: 'Notification preferences',
|
|
15
|
+
emptyAll: 'You have no notifications.',
|
|
16
|
+
emptyUnread: 'You have no unread notifications.',
|
|
17
|
+
loading: 'Loading notifications',
|
|
18
|
+
loadError: 'Notifications could not be loaded.',
|
|
19
|
+
retry: 'Try again',
|
|
20
|
+
actionError: 'That did not work. Try again.',
|
|
21
|
+
dismiss: 'Dismiss',
|
|
22
|
+
close: 'Close',
|
|
23
|
+
unread: 'Unread',
|
|
24
|
+
announcement: 'Announcement',
|
|
25
|
+
loadMore: 'Show older notifications',
|
|
26
|
+
newOne: '1 new notification',
|
|
27
|
+
newMany: '{n} new notifications',
|
|
28
|
+
fullListTitle: 'All notifications',
|
|
29
|
+
preferencesTitle: 'Notification preferences',
|
|
30
|
+
preferenceToggle: 'Receive in-app notifications',
|
|
31
|
+
preferenceOn: 'New notifications from this app appear here.',
|
|
32
|
+
preferenceOff: 'New notifications from this app do not appear here. The ones you already have stay. Other apps are not affected.',
|
|
33
|
+
preferenceLoadError: 'Your preference could not be loaded.',
|
|
34
|
+
preferenceSaveError: 'Your choice was not saved. Try again.',
|
|
35
|
+
};
|
|
36
|
+
function mergeLabels(labels) {
|
|
37
|
+
return { ...exports.DEFAULT_INBOX_LABELS, ...(labels ?? {}) };
|
|
38
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
'use client';
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.NotificationBell = NotificationBell;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
const react_1 = require("react");
|
|
7
|
+
const core_1 = require("@mantine/core");
|
|
8
|
+
const hooks_1 = require("@mantine/hooks");
|
|
9
|
+
const icons_react_1 = require("@tabler/icons-react");
|
|
10
|
+
const format_1 = require("./format");
|
|
11
|
+
const inbox_context_1 = require("./inbox-context");
|
|
12
|
+
const inbox_panel_1 = require("./inbox-panel");
|
|
13
|
+
/** The bell with its unread badge. Opens the inbox panel. Place it inside `InboxProvider`. */
|
|
14
|
+
function NotificationBell({ size = 44 }) {
|
|
15
|
+
const { state, labels, panelOpened, openPanel, closePanel, bellRef } = (0, inbox_context_1.useInbox)();
|
|
16
|
+
const reduceMotion = (0, hooks_1.useReducedMotion)();
|
|
17
|
+
const badge = (0, format_1.badgeText)(state.unreadCount);
|
|
18
|
+
// A rise of the count while the panel is closed is announced, without moving focus (15.4). The first count is not news.
|
|
19
|
+
const [announcement, setAnnouncement] = (0, react_1.useState)('');
|
|
20
|
+
const known = (0, react_1.useRef)(null);
|
|
21
|
+
(0, react_1.useEffect)(() => {
|
|
22
|
+
if (state.lastSyncedAt === null)
|
|
23
|
+
return;
|
|
24
|
+
const before = known.current;
|
|
25
|
+
known.current = state.unreadCount;
|
|
26
|
+
if (before === null || state.unreadCount <= before || panelOpened)
|
|
27
|
+
return;
|
|
28
|
+
const rise = state.unreadCount - before;
|
|
29
|
+
setAnnouncement(rise === 1 ? labels.newOne : (0, format_1.fill)(labels.newMany, { n: rise }));
|
|
30
|
+
}, [state.unreadCount, state.lastSyncedAt, panelOpened, labels]);
|
|
31
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(core_1.Indicator, { inline: true, label: badge, disabled: !badge, size: 18, offset: 8, children: (0, jsx_runtime_1.jsxs)(core_1.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: [(0, jsx_runtime_1.jsx)(core_1.Popover.Target, { children: (0, jsx_runtime_1.jsx)(core_1.ActionIcon, { ref: bellRef, variant: "subtle", color: "gray", radius: "xl", size: Math.max(44, size), "aria-label": (0, format_1.bellName)(labels, state.unreadCount), onClick: () => (panelOpened ? closePanel() : openPanel()), children: (0, jsx_runtime_1.jsx)(icons_react_1.IconBell, { size: 22, stroke: 1.75, "aria-hidden": "true" }) }) }), (0, jsx_runtime_1.jsx)(core_1.Popover.Dropdown, { p: 0, style: { width: 'min(380px, calc(100vw - 16px))' }, children: (0, jsx_runtime_1.jsx)(inbox_panel_1.InboxPanel, {}) })] }) }), (0, jsx_runtime_1.jsx)(core_1.VisuallyHidden, { role: "status", "aria-live": "polite", children: announcement })] }));
|
|
32
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
'use client';
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.NotificationItem = NotificationItem;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
const core_1 = require("@mantine/core");
|
|
7
|
+
const format_1 = require("./format");
|
|
8
|
+
const inbox_context_1 = require("./inbox-context");
|
|
9
|
+
/**
|
|
10
|
+
* One notification. The whole row is one button: selecting it marks the notification read and opens its link when
|
|
11
|
+
* it has a safe one. Title and body are text nodes, never markup (12.14).
|
|
12
|
+
*/
|
|
13
|
+
function NotificationItem({ item, now }) {
|
|
14
|
+
const { labels, locale, select } = (0, inbox_context_1.useInbox)();
|
|
15
|
+
const unread = !item.read;
|
|
16
|
+
return ((0, jsx_runtime_1.jsxs)(core_1.UnstyledButton, { className: "xci-row", onClick: () => select(item), "data-unread": unread || undefined, children: [(0, jsx_runtime_1.jsx)("span", { className: "xci-dot", "data-unread": unread || undefined, "aria-hidden": "true" }), (0, jsx_runtime_1.jsxs)("span", { className: "xci-content", children: [unread && (0, jsx_runtime_1.jsxs)(core_1.VisuallyHidden, { children: [labels.unread, "."] }), (0, jsx_runtime_1.jsxs)("span", { className: "xci-meta", children: [(0, jsx_runtime_1.jsx)(core_1.Text, { component: "span", size: "xs", c: "dimmed", truncate: true, children: item.source.name }), item.kind === 'announcement' && ((0, jsx_runtime_1.jsx)(core_1.Badge, { component: "span", size: "xs", variant: "light", style: { flex: 'none' }, children: labels.announcement }))] }), (0, jsx_runtime_1.jsx)(core_1.Text, { component: "span", className: "xci-line", size: "sm", fw: unread ? 700 : 400, lineClamp: 2, children: item.title }), (0, jsx_runtime_1.jsx)(core_1.Text, { component: "span", className: "xci-line", size: "sm", c: "dimmed", lineClamp: 3, children: item.body }), (0, jsx_runtime_1.jsx)(core_1.Text, { component: "time", className: "xci-line", size: "xs", c: "dimmed", dateTime: item.createdAt, title: (0, format_1.exactTime)(item.createdAt, locale), children: (0, format_1.relativeTime)(item.createdAt, now, locale) })] })] }));
|
|
17
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseNotification = parseNotification;
|
|
4
|
+
exports.parseSummary = parseSummary;
|
|
5
|
+
exports.parsePage = parsePage;
|
|
6
|
+
exports.parseMarkRead = parseMarkRead;
|
|
7
|
+
exports.parseReadAll = parseReadAll;
|
|
8
|
+
exports.parsePreference = parsePreference;
|
|
9
|
+
exports.parseErrorCode = parseErrorCode;
|
|
10
|
+
const types_1 = require("./types");
|
|
11
|
+
/**
|
|
12
|
+
* Tolerant parsing (17.4): each parser takes only the fields it knows and ignores the rest at any depth, so a field
|
|
13
|
+
* added later changes nothing here. A value that lacks a known field gives `null`, never a half-filled object.
|
|
14
|
+
*/
|
|
15
|
+
const obj = (v) => (typeof v === 'object' && v !== null && !Array.isArray(v) ? v : null);
|
|
16
|
+
const str = (v) => (typeof v === 'string' ? v : null);
|
|
17
|
+
const strOrNull = (v) => (v === null ? null : typeof v === 'string' ? v : undefined);
|
|
18
|
+
const count = (v) => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null);
|
|
19
|
+
function parseNotification(v) {
|
|
20
|
+
const o = obj(v);
|
|
21
|
+
const source = obj(o?.source);
|
|
22
|
+
if (!o || !source)
|
|
23
|
+
return null;
|
|
24
|
+
const id = str(o.id);
|
|
25
|
+
const title = str(o.title);
|
|
26
|
+
const body = str(o.body);
|
|
27
|
+
const createdAt = str(o.createdAt);
|
|
28
|
+
const link = strOrNull(o.link);
|
|
29
|
+
const readAt = strOrNull(o.readAt);
|
|
30
|
+
const name = str(source.name);
|
|
31
|
+
if (id === null || title === null || body === null || createdAt === null || link === undefined || readAt === undefined || name === null)
|
|
32
|
+
return null;
|
|
33
|
+
if (o.kind !== 'notification' && o.kind !== 'announcement')
|
|
34
|
+
return null;
|
|
35
|
+
if (source.type !== 'xapp' && source.type !== 'tenant')
|
|
36
|
+
return null;
|
|
37
|
+
if (typeof o.read !== 'boolean')
|
|
38
|
+
return null;
|
|
39
|
+
return { id, kind: o.kind, title, body, link, source: { type: source.type, name }, createdAt, read: o.read, readAt };
|
|
40
|
+
}
|
|
41
|
+
function parseSummary(v) {
|
|
42
|
+
const o = obj(v);
|
|
43
|
+
if (!o)
|
|
44
|
+
return null;
|
|
45
|
+
const unreadCount = count(o.unreadCount);
|
|
46
|
+
const newestUnreadAt = strOrNull(o.newestUnreadAt);
|
|
47
|
+
const pollSeconds = count(o.pollSeconds);
|
|
48
|
+
return unreadCount === null || newestUnreadAt === undefined || pollSeconds === null ? null : { unreadCount, newestUnreadAt, pollSeconds };
|
|
49
|
+
}
|
|
50
|
+
function parsePage(v) {
|
|
51
|
+
const o = obj(v);
|
|
52
|
+
if (!o || !Array.isArray(o.notifications))
|
|
53
|
+
return null;
|
|
54
|
+
const notifications = [];
|
|
55
|
+
for (const raw of o.notifications) {
|
|
56
|
+
const n = parseNotification(raw);
|
|
57
|
+
if (!n)
|
|
58
|
+
return null;
|
|
59
|
+
notifications.push(n);
|
|
60
|
+
}
|
|
61
|
+
const nextCursor = strOrNull(o.nextCursor);
|
|
62
|
+
const unreadCount = count(o.unreadCount);
|
|
63
|
+
return nextCursor === undefined || unreadCount === null ? null : { notifications, nextCursor, unreadCount };
|
|
64
|
+
}
|
|
65
|
+
function parseMarkRead(v) {
|
|
66
|
+
const o = obj(v);
|
|
67
|
+
const notification = parseNotification(o?.notification);
|
|
68
|
+
const unreadCount = count(o?.unreadCount);
|
|
69
|
+
return notification && unreadCount !== null ? { notification, unreadCount } : null;
|
|
70
|
+
}
|
|
71
|
+
function parseReadAll(v) {
|
|
72
|
+
const o = obj(v);
|
|
73
|
+
const marked = count(o?.marked);
|
|
74
|
+
const remaining = count(o?.remaining);
|
|
75
|
+
const unreadCount = count(o?.unreadCount);
|
|
76
|
+
return marked === null || remaining === null || unreadCount === null ? null : { marked, remaining, unreadCount };
|
|
77
|
+
}
|
|
78
|
+
function parsePreference(v) {
|
|
79
|
+
const o = obj(v);
|
|
80
|
+
if (!o)
|
|
81
|
+
return null;
|
|
82
|
+
const changedAt = strOrNull(o.changedAt);
|
|
83
|
+
if (o.state !== 'opted_in' && o.state !== 'opted_out' && o.state !== 'not_set')
|
|
84
|
+
return null;
|
|
85
|
+
return changedAt === undefined || typeof o.delivers !== 'boolean' ? null : { state: o.state, changedAt, delivers: o.delivers };
|
|
86
|
+
}
|
|
87
|
+
/** The code of an error body, or the nearest one for the status. Anything unknown is `unavailable`. */
|
|
88
|
+
function parseErrorCode(status, body) {
|
|
89
|
+
const code = obj(obj(body)?.error)?.code;
|
|
90
|
+
if (typeof code === 'string' && types_1.INBOX_ERROR_CODES.includes(code))
|
|
91
|
+
return code;
|
|
92
|
+
const byStatus = { 401: 'not_signed_in', 403: 'not_permitted', 404: 'not_found', 422: 'invalid_input', 429: 'rate_limited' };
|
|
93
|
+
return byStatus[status] ?? 'unavailable';
|
|
94
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
'use client';
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.PreferenceDialog = PreferenceDialog;
|
|
5
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
6
|
+
const react_1 = require("react");
|
|
7
|
+
const core_1 = require("@mantine/core");
|
|
8
|
+
const hooks_1 = require("@mantine/hooks");
|
|
9
|
+
const inbox_context_1 = require("./inbox-context");
|
|
10
|
+
/** The person's own choice for this app's in-app notifications. Other apps are not affected. */
|
|
11
|
+
function PreferenceDialog({ opened, onClose }) {
|
|
12
|
+
const { client, labels } = (0, inbox_context_1.useInbox)();
|
|
13
|
+
const reduceMotion = (0, hooks_1.useReducedMotion)();
|
|
14
|
+
const [preference, setPreference] = (0, react_1.useState)(null);
|
|
15
|
+
const [status, setStatus] = (0, react_1.useState)('loading');
|
|
16
|
+
const [saving, setSaving] = (0, react_1.useState)(false);
|
|
17
|
+
const [saveFailed, setSaveFailed] = (0, react_1.useState)(false);
|
|
18
|
+
const load = (0, react_1.useCallback)(async () => {
|
|
19
|
+
setStatus('loading');
|
|
20
|
+
const res = await client.getPreference();
|
|
21
|
+
if (res.ok)
|
|
22
|
+
setPreference(res.data);
|
|
23
|
+
setStatus(res.ok ? 'ready' : 'error');
|
|
24
|
+
}, [client]);
|
|
25
|
+
(0, react_1.useEffect)(() => {
|
|
26
|
+
if (!opened)
|
|
27
|
+
return;
|
|
28
|
+
setSaveFailed(false);
|
|
29
|
+
void load();
|
|
30
|
+
}, [opened, load]);
|
|
31
|
+
// The switch moves at once; a failure moves it back and says so.
|
|
32
|
+
const change = async (on) => {
|
|
33
|
+
if (!preference || saving)
|
|
34
|
+
return;
|
|
35
|
+
const before = preference;
|
|
36
|
+
setSaveFailed(false);
|
|
37
|
+
setSaving(true);
|
|
38
|
+
setPreference({ ...preference, state: on ? 'opted_in' : 'opted_out', delivers: on });
|
|
39
|
+
const res = await client.setPreference(on ? 'opted_in' : 'opted_out');
|
|
40
|
+
setSaving(false);
|
|
41
|
+
if (res.ok)
|
|
42
|
+
setPreference(res.data);
|
|
43
|
+
else {
|
|
44
|
+
setPreference(before);
|
|
45
|
+
setSaveFailed(true);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
return ((0, jsx_runtime_1.jsxs)(core_1.Modal, { opened: opened, onClose: onClose, title: labels.preferencesTitle, closeButtonProps: { 'aria-label': labels.close }, returnFocus: false, transitionProps: reduceMotion ? { duration: 0 } : undefined, children: [status === 'loading' && ((0, jsx_runtime_1.jsx)(core_1.Center, { mih: 96, role: "status", "aria-label": labels.loading, children: (0, jsx_runtime_1.jsx)(core_1.Loader, { size: "sm" }) })), status === 'error' && ((0, jsx_runtime_1.jsx)(core_1.Alert, { color: "red", variant: "light", title: labels.preferenceLoadError, children: (0, jsx_runtime_1.jsx)(core_1.Button, { variant: "default", size: "sm", mih: 44, onClick: () => void load(), children: labels.retry }) })), status === 'ready' && preference && ((0, jsx_runtime_1.jsxs)(core_1.Stack, { gap: "sm", children: [(0, jsx_runtime_1.jsx)(core_1.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 && ((0, jsx_runtime_1.jsx)(core_1.Alert, { color: "red", variant: "light", role: "alert", children: labels.preferenceSaveError }))] }))] }));
|
|
49
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.POLL_CAP_SECONDS = exports.POLL_FLOOR_SECONDS = void 0;
|
|
4
|
+
exports.nextPollDelay = nextPollDelay;
|
|
5
|
+
exports.startPolling = startPolling;
|
|
6
|
+
exports.useInboxPolling = useInboxPolling;
|
|
7
|
+
const react_1 = require("react");
|
|
8
|
+
exports.POLL_FLOOR_SECONDS = 15;
|
|
9
|
+
exports.POLL_CAP_SECONDS = 300;
|
|
10
|
+
/**
|
|
11
|
+
* Seconds until the next poll. After a success: the recommended interval, at least 15. After failures:
|
|
12
|
+
* `min(300, base * 2^failures)` with plus or minus 20 percent jitter, still between 15 and 300.
|
|
13
|
+
*/
|
|
14
|
+
function nextPollDelay(failures, recommendedSeconds, random = Math.random) {
|
|
15
|
+
const base = Math.max(exports.POLL_FLOOR_SECONDS, Number.isFinite(recommendedSeconds) ? recommendedSeconds : 60);
|
|
16
|
+
if (failures <= 0)
|
|
17
|
+
return base;
|
|
18
|
+
const backoff = Math.min(exports.POLL_CAP_SECONDS, base * 2 ** Math.min(failures, 16));
|
|
19
|
+
const jitter = 0.8 + 0.4 * Math.min(1, Math.max(0, random()));
|
|
20
|
+
return Math.min(exports.POLL_CAP_SECONDS, Math.max(exports.POLL_FLOOR_SECONDS, backoff * jitter));
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Polls now, then after each answer waits `nextPollDelay`. Pauses while the page is hidden and polls at once when it
|
|
24
|
+
* is visible again. `stop` (nobody is signed in) ends polling silently. Returns a function that stops it.
|
|
25
|
+
*/
|
|
26
|
+
function startPolling(poll, env) {
|
|
27
|
+
let stopped = false;
|
|
28
|
+
let running = false;
|
|
29
|
+
let failures = 0;
|
|
30
|
+
let recommended = 60;
|
|
31
|
+
let timer = null;
|
|
32
|
+
const tick = async () => {
|
|
33
|
+
if (stopped || running || !env.isVisible())
|
|
34
|
+
return;
|
|
35
|
+
running = true;
|
|
36
|
+
let outcome;
|
|
37
|
+
try {
|
|
38
|
+
outcome = await poll();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
outcome = { result: 'failed' };
|
|
42
|
+
}
|
|
43
|
+
running = false;
|
|
44
|
+
if (stopped)
|
|
45
|
+
return;
|
|
46
|
+
if (outcome.result === 'stop') {
|
|
47
|
+
stopped = true;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (outcome.result === 'ok') {
|
|
51
|
+
failures = 0;
|
|
52
|
+
recommended = outcome.pollSeconds;
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
failures += 1;
|
|
56
|
+
timer = env.setTimer(() => void tick(), nextPollDelay(failures, recommended, env.random) * 1000);
|
|
57
|
+
};
|
|
58
|
+
const unsubscribe = env.onVisibilityChange(() => {
|
|
59
|
+
if (stopped)
|
|
60
|
+
return;
|
|
61
|
+
env.clearTimer(timer);
|
|
62
|
+
timer = null;
|
|
63
|
+
if (env.isVisible())
|
|
64
|
+
void tick();
|
|
65
|
+
});
|
|
66
|
+
void tick();
|
|
67
|
+
return () => {
|
|
68
|
+
stopped = true;
|
|
69
|
+
env.clearTimer(timer);
|
|
70
|
+
unsubscribe();
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const browserEnvironment = () => ({
|
|
74
|
+
isVisible: () => document.visibilityState !== 'hidden',
|
|
75
|
+
onVisibilityChange: (listener) => {
|
|
76
|
+
document.addEventListener('visibilitychange', listener);
|
|
77
|
+
return () => document.removeEventListener('visibilitychange', listener);
|
|
78
|
+
},
|
|
79
|
+
setTimer: (fn, ms) => window.setTimeout(fn, ms),
|
|
80
|
+
clearTimer: (handle) => window.clearTimeout(handle),
|
|
81
|
+
random: Math.random,
|
|
82
|
+
});
|
|
83
|
+
/** Runs `poll` for as long as the component is mounted and `enabled` is true. `poll` may change between renders. */
|
|
84
|
+
function useInboxPolling(poll, enabled = true) {
|
|
85
|
+
const latest = (0, react_1.useRef)(poll);
|
|
86
|
+
(0, react_1.useEffect)(() => {
|
|
87
|
+
latest.current = poll;
|
|
88
|
+
}, [poll]);
|
|
89
|
+
(0, react_1.useEffect)(() => {
|
|
90
|
+
if (!enabled)
|
|
91
|
+
return undefined;
|
|
92
|
+
return startPolling(() => latest.current(), browserEnvironment());
|
|
93
|
+
}, [enabled]);
|
|
94
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_RETRY = exports.newIdempotencyKey = exports.deriveIdempotencyKey = exports.verifyEventSignature = exports.createEventHandler = exports.CommunicationError = exports.createCommunicationClient = void 0;
|
|
4
|
+
var client_1 = require("./client");
|
|
5
|
+
Object.defineProperty(exports, "createCommunicationClient", { enumerable: true, get: function () { return client_1.createCommunicationClient; } });
|
|
6
|
+
Object.defineProperty(exports, "CommunicationError", { enumerable: true, get: function () { return client_1.CommunicationError; } });
|
|
7
|
+
var events_1 = require("./events");
|
|
8
|
+
Object.defineProperty(exports, "createEventHandler", { enumerable: true, get: function () { return events_1.createEventHandler; } });
|
|
9
|
+
Object.defineProperty(exports, "verifyEventSignature", { enumerable: true, get: function () { return events_1.verifyEventSignature; } });
|
|
10
|
+
var idempotency_1 = require("./idempotency");
|
|
11
|
+
Object.defineProperty(exports, "deriveIdempotencyKey", { enumerable: true, get: function () { return idempotency_1.deriveIdempotencyKey; } });
|
|
12
|
+
Object.defineProperty(exports, "newIdempotencyKey", { enumerable: true, get: function () { return idempotency_1.newIdempotencyKey; } });
|
|
13
|
+
var retry_1 = require("./retry");
|
|
14
|
+
Object.defineProperty(exports, "DEFAULT_RETRY", { enumerable: true, get: function () { return retry_1.DEFAULT_RETRY; } });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isRetryableStatus = exports.DEFAULT_RETRY = void 0;
|
|
4
|
+
exports.backoffMs = backoffMs;
|
|
5
|
+
exports.DEFAULT_RETRY = { maxAttempts: 3, baseDelayMs: 250, capDelayMs: 5000, totalTimeoutMs: 25_000, perAttemptTimeoutMs: 10_000 };
|
|
6
|
+
/** Exponential backoff with full jitter, honouring Retry-After when the server sent one. */
|
|
7
|
+
function backoffMs(attempt, policy, retryAfterSeconds, random = Math.random) {
|
|
8
|
+
const exp = Math.min(policy.capDelayMs, policy.baseDelayMs * 2 ** (attempt - 1));
|
|
9
|
+
const jittered = Math.round(exp * (0.5 + random() * 0.5));
|
|
10
|
+
return retryAfterSeconds ? Math.min(policy.capDelayMs, Math.max(jittered, retryAfterSeconds * 1000)) : jittered;
|
|
11
|
+
}
|
|
12
|
+
/** Only transient failures are retried: network errors, timeouts, 429 and 5xx. Client errors never are. */
|
|
13
|
+
const isRetryableStatus = (status) => status === 429 || (status >= 500 && status !== 501);
|
|
14
|
+
exports.isRetryableStatus = isRetryableStatus;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { CommunicationError } from './errors.js';
|
|
2
|
+
/** Selected by the `browser` export condition so a secret key can never be bundled into client code. */
|
|
3
|
+
export function createCommunicationClient() {
|
|
4
|
+
throw new CommunicationError({ code: 'server_only', message: 'The Communication client must run on the server.' });
|
|
5
|
+
}
|
|
6
|
+
export { CommunicationError };
|