@xtremax/pigeon 0.0.0-stage → 0.3.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +56 -2
  3. package/dist/cjs/browser-stub.js +10 -0
  4. package/dist/cjs/client.js +112 -0
  5. package/dist/cjs/contract.js +5 -0
  6. package/dist/cjs/errors.js +48 -0
  7. package/dist/cjs/events.js +62 -0
  8. package/dist/cjs/idempotency.js +14 -0
  9. package/dist/cjs/inbox/browser-stub.js +10 -0
  10. package/dist/cjs/inbox/client.js +39 -0
  11. package/dist/cjs/inbox/errors.js +44 -0
  12. package/dist/cjs/inbox/handler.js +127 -0
  13. package/dist/cjs/inbox/index.js +15 -0
  14. package/dist/cjs/inbox/origin.js +22 -0
  15. package/dist/cjs/inbox/project.js +84 -0
  16. package/dist/cjs/inbox/validate.js +59 -0
  17. package/dist/cjs/inbox-react/format.js +72 -0
  18. package/dist/cjs/inbox-react/inbox-client.js +63 -0
  19. package/dist/cjs/inbox-react/inbox-context.js +13 -0
  20. package/dist/cjs/inbox-react/inbox-full-list.js +13 -0
  21. package/dist/cjs/inbox-react/inbox-list.js +37 -0
  22. package/dist/cjs/inbox-react/inbox-panel.js +13 -0
  23. package/dist/cjs/inbox-react/inbox-provider.js +157 -0
  24. package/dist/cjs/inbox-react/inbox-state.js +88 -0
  25. package/dist/cjs/inbox-react/index.js +25 -0
  26. package/dist/cjs/inbox-react/labels.js +38 -0
  27. package/dist/cjs/inbox-react/notification-bell.js +32 -0
  28. package/dist/cjs/inbox-react/notification-item.js +17 -0
  29. package/dist/cjs/inbox-react/parse.js +94 -0
  30. package/dist/cjs/inbox-react/preference-dialog.js +49 -0
  31. package/dist/cjs/inbox-react/types.js +4 -0
  32. package/dist/cjs/inbox-react/use-inbox-polling.js +94 -0
  33. package/dist/cjs/index.js +14 -0
  34. package/dist/cjs/package.json +1 -0
  35. package/dist/cjs/retry.js +14 -0
  36. package/dist/esm/browser-stub.js +6 -0
  37. package/dist/esm/client.js +107 -0
  38. package/dist/esm/contract.js +2 -0
  39. package/dist/esm/errors.js +42 -0
  40. package/dist/esm/events.js +57 -0
  41. package/dist/esm/idempotency.js +10 -0
  42. package/dist/esm/inbox/browser-stub.js +7 -0
  43. package/dist/esm/inbox/client.js +35 -0
  44. package/dist/esm/inbox/errors.js +38 -0
  45. package/dist/esm/inbox/handler.js +124 -0
  46. package/dist/esm/inbox/index.js +5 -0
  47. package/dist/esm/inbox/origin.js +19 -0
  48. package/dist/esm/inbox/project.js +76 -0
  49. package/dist/esm/inbox/validate.js +49 -0
  50. package/dist/esm/inbox-react/format.js +64 -0
  51. package/dist/esm/inbox-react/inbox-client.js +58 -0
  52. package/dist/esm/inbox-react/inbox-context.js +9 -0
  53. package/dist/esm/inbox-react/inbox-full-list.js +10 -0
  54. package/dist/esm/inbox-react/inbox-list.js +34 -0
  55. package/dist/esm/inbox-react/inbox-panel.js +10 -0
  56. package/dist/esm/inbox-react/inbox-provider.js +154 -0
  57. package/dist/esm/inbox-react/inbox-state.js +83 -0
  58. package/dist/esm/inbox-react/index.js +18 -0
  59. package/dist/esm/inbox-react/labels.js +34 -0
  60. package/dist/esm/inbox-react/notification-bell.js +29 -0
  61. package/dist/esm/inbox-react/notification-item.js +14 -0
  62. package/dist/esm/inbox-react/parse.js +85 -0
  63. package/dist/esm/inbox-react/preference-dialog.js +46 -0
  64. package/dist/esm/inbox-react/types.js +1 -0
  65. package/dist/esm/inbox-react/use-inbox-polling.js +88 -0
  66. package/dist/esm/index.js +4 -0
  67. package/dist/esm/retry.js +9 -0
  68. package/dist/inbox-react/styles.css +65 -0
  69. package/dist/types/browser-stub.d.ts +4 -0
  70. package/dist/types/client.d.ts +94 -0
  71. package/dist/types/contract.d.ts +182 -0
  72. package/dist/types/errors.d.ts +29 -0
  73. package/dist/types/events.d.ts +26 -0
  74. package/dist/types/idempotency.d.ts +4 -0
  75. package/dist/types/inbox/browser-stub.d.ts +3 -0
  76. package/dist/types/inbox/client.d.ts +34 -0
  77. package/dist/types/inbox/errors.d.ts +13 -0
  78. package/dist/types/inbox/handler.d.ts +25 -0
  79. package/dist/types/inbox/index.d.ts +6 -0
  80. package/dist/types/inbox/origin.d.ts +5 -0
  81. package/dist/types/inbox/project.d.ts +7 -0
  82. package/dist/types/inbox/validate.d.ts +25 -0
  83. package/dist/types/inbox-react/format.d.ts +13 -0
  84. package/dist/types/inbox-react/inbox-client.d.ts +24 -0
  85. package/dist/types/inbox-react/inbox-context.d.ts +29 -0
  86. package/dist/types/inbox-react/inbox-full-list.d.ts +7 -0
  87. package/dist/types/inbox-react/inbox-list.d.ts +12 -0
  88. package/dist/types/inbox-react/inbox-panel.d.ts +2 -0
  89. package/dist/types/inbox-react/inbox-provider.d.ts +18 -0
  90. package/dist/types/inbox-react/inbox-state.d.ts +70 -0
  91. package/dist/types/inbox-react/index.d.ts +19 -0
  92. package/dist/types/inbox-react/labels.d.ts +40 -0
  93. package/dist/types/inbox-react/notification-bell.d.ts +4 -0
  94. package/dist/types/inbox-react/notification-item.d.ts +9 -0
  95. package/dist/types/inbox-react/parse.d.ts +9 -0
  96. package/dist/types/inbox-react/preference-dialog.d.ts +5 -0
  97. package/dist/types/inbox-react/types.d.ts +55 -0
  98. package/dist/types/inbox-react/use-inbox-polling.d.ts +33 -0
  99. package/dist/types/index.d.ts +6 -0
  100. package/dist/types/retry.d.ts +13 -0
  101. package/package.json +33 -4
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.projectNotification = projectNotification;
4
+ exports.projectSummary = projectSummary;
5
+ exports.projectPage = projectPage;
6
+ exports.projectMarkRead = projectMarkRead;
7
+ exports.projectReadAll = projectReadAll;
8
+ exports.projectPreference = projectPreference;
9
+ /**
10
+ * Allowlist projection: only the documented fields of IB-2 leave the host, so nothing the Gateway adds later reaches a
11
+ * browser by accident. A value of the wrong shape gives `null`, which the handler answers as `unavailable`.
12
+ */
13
+ const obj = (v) => (typeof v === 'object' && v !== null && !Array.isArray(v) ? v : null);
14
+ const str = (v) => (typeof v === 'string' ? v : null);
15
+ const strOrNull = (v) => (v === null ? null : typeof v === 'string' ? v : undefined);
16
+ const count = (v) => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null);
17
+ function projectNotification(v) {
18
+ const o = obj(v);
19
+ const source = obj(o?.source);
20
+ if (!o || !source)
21
+ return null;
22
+ const id = str(o.id);
23
+ const title = str(o.title);
24
+ const body = str(o.body);
25
+ const createdAt = str(o.createdAt);
26
+ const link = strOrNull(o.link);
27
+ const readAt = strOrNull(o.readAt);
28
+ const name = str(source.name);
29
+ if (id === null || title === null || body === null || createdAt === null || link === undefined || readAt === undefined || name === null)
30
+ return null;
31
+ if (o.kind !== 'notification' && o.kind !== 'announcement')
32
+ return null;
33
+ if (source.type !== 'xapp' && source.type !== 'tenant')
34
+ return null;
35
+ if (typeof o.read !== 'boolean')
36
+ return null;
37
+ return { id, kind: o.kind, title, body, link, source: { type: source.type, name }, createdAt, read: o.read, readAt };
38
+ }
39
+ function projectSummary(v) {
40
+ const o = obj(v);
41
+ if (!o)
42
+ return null;
43
+ const unreadCount = count(o.unreadCount);
44
+ const newestUnreadAt = strOrNull(o.newestUnreadAt);
45
+ const pollSeconds = count(o.pollSeconds);
46
+ return unreadCount === null || newestUnreadAt === undefined || pollSeconds === null ? null : { unreadCount, newestUnreadAt, pollSeconds };
47
+ }
48
+ function projectPage(v) {
49
+ const o = obj(v);
50
+ if (!o || !Array.isArray(o.notifications))
51
+ return null;
52
+ const notifications = [];
53
+ for (const raw of o.notifications) {
54
+ const n = projectNotification(raw);
55
+ if (!n)
56
+ return null;
57
+ notifications.push(n);
58
+ }
59
+ const nextCursor = strOrNull(o.nextCursor);
60
+ const unreadCount = count(o.unreadCount);
61
+ return nextCursor === undefined || unreadCount === null ? null : { notifications, nextCursor, unreadCount };
62
+ }
63
+ function projectMarkRead(v) {
64
+ const o = obj(v);
65
+ const notification = projectNotification(o?.notification);
66
+ const unreadCount = count(o?.unreadCount);
67
+ return notification && unreadCount !== null ? { notification, unreadCount } : null;
68
+ }
69
+ function projectReadAll(v) {
70
+ const o = obj(v);
71
+ const marked = count(o?.marked);
72
+ const remaining = count(o?.remaining);
73
+ const unreadCount = count(o?.unreadCount);
74
+ return marked === null || remaining === null || unreadCount === null ? null : { marked, remaining, unreadCount };
75
+ }
76
+ function projectPreference(v) {
77
+ const o = obj(v);
78
+ if (!o)
79
+ return null;
80
+ const changedAt = strOrNull(o.changedAt);
81
+ if (o.state !== 'opted_in' && o.state !== 'opted_out' && o.state !== 'not_set')
82
+ return null;
83
+ return changedAt === undefined || typeof o.delivers !== 'boolean' ? null : { state: o.state, changedAt, delivers: o.delivers };
84
+ }
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.INBOX_LIMITS = void 0;
4
+ exports.parseLimit = parseLimit;
5
+ exports.parseCursor = parseCursor;
6
+ exports.parseUnread = parseUnread;
7
+ exports.parseId = parseId;
8
+ exports.parseUpTo = parseUpTo;
9
+ exports.parseState = parseState;
10
+ exports.parseBody = parseBody;
11
+ exports.INBOX_LIMITS = { pageMin: 1, pageMax: 100, cursorMax: 128, idMax: 64, upToMax: 40, bodyBytes: 1024, personMax: 128 };
12
+ const ok = (value) => ({ ok: true, value });
13
+ const bad = (field) => ({ ok: false, field });
14
+ function parseLimit(raw) {
15
+ if (raw === null)
16
+ return ok(undefined);
17
+ if (!/^\d{1,3}$/.test(raw))
18
+ return bad('limit');
19
+ const n = Number(raw);
20
+ return n >= exports.INBOX_LIMITS.pageMin && n <= exports.INBOX_LIMITS.pageMax ? ok(n) : bad('limit');
21
+ }
22
+ function parseCursor(raw) {
23
+ if (raw === null)
24
+ return ok(undefined);
25
+ return raw.length > 0 && raw.length <= exports.INBOX_LIMITS.cursorMax && /^[A-Za-z0-9_-]+$/.test(raw) ? ok(raw) : bad('cursor');
26
+ }
27
+ function parseUnread(raw) {
28
+ if (raw === null || raw === 'false')
29
+ return ok(false);
30
+ return raw === 'true' ? ok(true) : bad('unread');
31
+ }
32
+ function parseId(raw) {
33
+ return raw.length > 0 && raw.length <= exports.INBOX_LIMITS.idMax && /^[A-Za-z0-9_-]+$/.test(raw) ? ok(raw) : bad('id');
34
+ }
35
+ function parseUpTo(raw) {
36
+ if (raw === undefined || raw === null)
37
+ return ok(undefined);
38
+ if (typeof raw !== 'string' || raw.length > exports.INBOX_LIMITS.upToMax || !/^\d{4}-\d{2}-\d{2}T[\d:.]+(Z|[+-]\d{2}:\d{2})$/.test(raw))
39
+ return bad('upTo');
40
+ const t = Date.parse(raw);
41
+ return Number.isFinite(t) ? ok(new Date(t).toISOString()) : bad('upTo');
42
+ }
43
+ function parseState(raw) {
44
+ return raw === 'opted_in' || raw === 'opted_out' ? ok(raw) : bad('state');
45
+ }
46
+ /** A JSON object of at most 1024 bytes. An empty body is an empty object. */
47
+ function parseBody(text) {
48
+ if (new TextEncoder().encode(text).length > exports.INBOX_LIMITS.bodyBytes)
49
+ return bad('body');
50
+ if (text.trim() === '')
51
+ return ok({});
52
+ try {
53
+ const v = JSON.parse(text);
54
+ return typeof v === 'object' && v !== null && !Array.isArray(v) ? ok(v) : bad('body');
55
+ }
56
+ catch {
57
+ return bad('body');
58
+ }
59
+ }
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fill = fill;
4
+ exports.badgeText = badgeText;
5
+ exports.bellName = bellName;
6
+ exports.safeLink = safeLink;
7
+ exports.relativeTime = relativeTime;
8
+ exports.exactTime = exactTime;
9
+ /** Fills `{name}` placeholders. Values are text, never markup. */
10
+ function fill(template, values) {
11
+ return template.replace(/\{(\w+)\}/g, (whole, key) => (key in values ? String(values[key]) : whole));
12
+ }
13
+ /** Nothing for 0, the number for 1 to 99, "99+" above (12.1). */
14
+ function badgeText(count) {
15
+ if (!Number.isFinite(count) || count < 1)
16
+ return '';
17
+ return count > 99 ? '99+' : String(Math.floor(count));
18
+ }
19
+ /** The bell's accessible name: "{label}, {n} unread", or the label alone when nothing is unread (15.3). */
20
+ function bellName(labels, count) {
21
+ return count > 0 ? fill(labels.bellWithCount, { label: labels.bell, n: count }) : labels.bell;
22
+ }
23
+ const LINK_MAX = 2048;
24
+ /** Accepts exactly what the inbox stores: an absolute https URL without credentials, at most 2048 characters (12.15). */
25
+ function safeLink(link) {
26
+ if (!link || link.length > LINK_MAX)
27
+ return null;
28
+ try {
29
+ const u = new URL(link);
30
+ return u.protocol === 'https:' && !u.username && !u.password ? u.toString() : null;
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
36
+ const MINUTE = 60;
37
+ const HOUR = 3600;
38
+ const DAY = 86400;
39
+ /** "now", "5 minutes ago", "yesterday", then a short date after a week. A time in the future (clock skew) reads as now. */
40
+ function relativeTime(iso, now, locale) {
41
+ const then = Date.parse(iso);
42
+ if (!Number.isFinite(then))
43
+ return '';
44
+ const seconds = Math.max(0, Math.floor((now.getTime() - then) / 1000));
45
+ try {
46
+ const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
47
+ if (seconds < MINUTE)
48
+ return rtf.format(0, 'second');
49
+ if (seconds < HOUR)
50
+ return rtf.format(-Math.floor(seconds / MINUTE), 'minute');
51
+ if (seconds < DAY)
52
+ return rtf.format(-Math.floor(seconds / HOUR), 'hour');
53
+ if (seconds < 7 * DAY)
54
+ return rtf.format(-Math.floor(seconds / DAY), 'day');
55
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(new Date(then));
56
+ }
57
+ catch {
58
+ return new Date(then).toISOString().slice(0, 10);
59
+ }
60
+ }
61
+ /** The full date and time, for the `title` of a relative time. */
62
+ function exactTime(iso, locale) {
63
+ const then = Date.parse(iso);
64
+ if (!Number.isFinite(then))
65
+ return '';
66
+ try {
67
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'full', timeStyle: 'short' }).format(new Date(then));
68
+ }
69
+ catch {
70
+ return new Date(then).toISOString();
71
+ }
72
+ }
@@ -0,0 +1,63 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DEFAULT_API_BASE = void 0;
4
+ exports.normalizeApiBase = normalizeApiBase;
5
+ exports.createInboxBrowserClient = createInboxBrowserClient;
6
+ const parse_1 = require("./parse");
7
+ exports.DEFAULT_API_BASE = '/api/communication/inbox';
8
+ /**
9
+ * The browser talks only to the host xApp's own origin (14.2). The base is a path on that origin: an absolute URL,
10
+ * a protocol-relative one or anything with a query is refused.
11
+ */
12
+ function normalizeApiBase(apiBase) {
13
+ const base = (apiBase ?? exports.DEFAULT_API_BASE).trim();
14
+ if (!base.startsWith('/') || base.startsWith('//') || /[\\?#\s]/.test(base) || base.includes('://'))
15
+ return null;
16
+ return base.replace(/\/+$/, '') || null;
17
+ }
18
+ /**
19
+ * Every call answers a result and never throws, so an outage cannot break the host page (14.9).
20
+ * The bell shows what this xApp's own sub-tenant sent the person, and the tenant's announcements: there is nothing to choose.
21
+ */
22
+ function createInboxBrowserClient(apiBase, fetchImpl) {
23
+ const base = normalizeApiBase(apiBase);
24
+ async function call(method, path, parse, body) {
25
+ if (base === null)
26
+ return { ok: false, code: 'unavailable' };
27
+ try {
28
+ const res = await (fetchImpl ?? fetch)(`${base}${path}`, {
29
+ method,
30
+ credentials: 'same-origin',
31
+ cache: 'no-store',
32
+ headers: { Accept: 'application/json', ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) },
33
+ body: body === undefined ? undefined : JSON.stringify(body),
34
+ });
35
+ const json = await res.json().catch(() => null);
36
+ if (!res.ok)
37
+ return { ok: false, code: (0, parse_1.parseErrorCode)(res.status, json) };
38
+ const data = parse(json?.data);
39
+ return data === null ? { ok: false, code: 'unavailable' } : { ok: true, data };
40
+ }
41
+ catch {
42
+ return { ok: false, code: 'unavailable' };
43
+ }
44
+ }
45
+ return {
46
+ summary: () => call('GET', '/summary', parse_1.parseSummary),
47
+ list(query = {}) {
48
+ const q = new URLSearchParams();
49
+ if (query.limit !== undefined)
50
+ q.set('limit', String(query.limit));
51
+ if (query.cursor)
52
+ q.set('cursor', query.cursor);
53
+ if (query.unreadOnly)
54
+ q.set('unread', 'true');
55
+ const qs = q.toString();
56
+ return call('GET', `/notifications${qs ? `?${qs}` : ''}`, parse_1.parsePage);
57
+ },
58
+ markRead: (id) => call('POST', `/notifications/${encodeURIComponent(id)}/read`, parse_1.parseMarkRead, {}),
59
+ markAllRead: (upTo) => call('POST', '/read-all', parse_1.parseReadAll, upTo ? { upTo } : {}),
60
+ getPreference: () => call('GET', '/preference', parse_1.parsePreference),
61
+ setPreference: (state) => call('PUT', '/preference', parse_1.parsePreference, { state }),
62
+ };
63
+ }
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.InboxContext = void 0;
5
+ exports.useInbox = useInbox;
6
+ const react_1 = require("react");
7
+ exports.InboxContext = (0, react_1.createContext)(null);
8
+ function useInbox() {
9
+ const value = (0, react_1.useContext)(exports.InboxContext);
10
+ if (!value)
11
+ throw new Error('Wrap the inbox components in <InboxProvider>.');
12
+ return value;
13
+ }
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.InboxFullList = InboxFullList;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ const inbox_list_1 = require("./inbox-list");
7
+ /**
8
+ * Every notification, a page at a time. The provider shows it in a dialog for "View all"; a host that passes
9
+ * `onViewAll` can place it on a page of its own instead.
10
+ */
11
+ function InboxFullList({ maxHeight = 'min(70vh, 640px)' }) {
12
+ return (0, jsx_runtime_1.jsx)(inbox_list_1.InboxListView, { paged: true, maxHeight: maxHeight });
13
+ }
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.InboxListView = InboxListView;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ const react_1 = require("react");
7
+ const core_1 = require("@mantine/core");
8
+ const format_1 = require("./format");
9
+ const inbox_context_1 = require("./inbox-context");
10
+ const inbox_state_1 = require("./inbox-state");
11
+ const notification_item_1 = require("./notification-item");
12
+ /** Tabs, "Mark all as read", the list and its loading, empty and error states. Shared by the panel and the full list. */
13
+ function InboxListView({ heading, maxHeight, paged = false }) {
14
+ const { state, labels, setTab, markAllRead, loadMore, retry, clearMessage, attachList } = (0, inbox_context_1.useInbox)();
15
+ (0, react_1.useEffect)(() => attachList(), [attachList]);
16
+ // Relative times move on while the list stays open.
17
+ const [now, setNow] = (0, react_1.useState)(() => new Date());
18
+ (0, react_1.useEffect)(() => {
19
+ const timer = window.setInterval(() => setNow(new Date()), 60_000);
20
+ return () => window.clearInterval(timer);
21
+ }, []);
22
+ let content;
23
+ if (!state.loaded && state.status === 'error') {
24
+ content = ((0, jsx_runtime_1.jsx)(core_1.Alert, { color: "red", variant: "light", m: "md", title: labels.loadError, children: (0, jsx_runtime_1.jsx)(core_1.Button, { variant: "default", size: "sm", mih: 44, onClick: retry, children: labels.retry }) }));
25
+ }
26
+ else if (!state.loaded) {
27
+ content = ((0, jsx_runtime_1.jsx)(core_1.Center, { mih: 160, role: "status", "aria-label": labels.loading, children: (0, jsx_runtime_1.jsx)(core_1.Loader, { size: "sm" }) }));
28
+ }
29
+ else if (state.items.length === 0) {
30
+ content = ((0, jsx_runtime_1.jsx)(core_1.Text, { c: "dimmed", size: "sm", ta: "center", px: "md", py: "xl", children: state.tab === 'unread' ? labels.emptyUnread : labels.emptyAll }));
31
+ }
32
+ else {
33
+ content = ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("ul", { className: "xci-list", children: state.items.map((item) => ((0, jsx_runtime_1.jsx)("li", { className: "xci-entry", children: (0, jsx_runtime_1.jsx)(notification_item_1.NotificationItem, { item: item, now: now }) }, item.id))) }), paged && state.nextCursor && ((0, jsx_runtime_1.jsx)(core_1.Center, { p: "sm", children: (0, jsx_runtime_1.jsx)(core_1.Button, { variant: "default", size: "sm", mih: 44, loading: state.loadingMore, onClick: loadMore, children: labels.loadMore }) }))] }));
34
+ }
35
+ const list = ((0, jsx_runtime_1.jsx)(core_1.ScrollArea.Autosize, { mah: maxHeight, type: "auto", children: content }));
36
+ return ((0, jsx_runtime_1.jsxs)(core_1.Stack, { gap: 0, children: [(0, jsx_runtime_1.jsxs)(core_1.Group, { justify: "space-between", wrap: "nowrap", gap: "xs", ps: "md", pe: 4, pt: 4, children: [heading ?? (0, jsx_runtime_1.jsx)("span", {}), (0, jsx_runtime_1.jsx)(core_1.Button, { variant: "subtle", size: "compact-sm", mih: 44, px: "sm", disabled: !(0, inbox_state_1.canMarkAll)(state), onClick: markAllRead, children: labels.markAllRead })] }), (0, jsx_runtime_1.jsxs)(core_1.Tabs, { value: state.tab, onChange: (tab) => tab && setTab(tab), keepMounted: false, children: [(0, jsx_runtime_1.jsxs)(core_1.Tabs.List, { px: "xs", children: [(0, jsx_runtime_1.jsx)(core_1.Tabs.Tab, { value: "all", mih: 44, children: labels.tabAll }), (0, jsx_runtime_1.jsx)(core_1.Tabs.Tab, { value: "unread", mih: 44, children: state.unreadCount > 0 ? (0, format_1.fill)(labels.tabUnreadWithCount, { n: state.unreadCount }) : labels.tabUnread })] }), state.actionFailed && ((0, jsx_runtime_1.jsx)(core_1.Alert, { color: "red", variant: "light", m: "xs", role: "alert", withCloseButton: true, closeButtonLabel: labels.dismiss, onClose: clearMessage, children: labels.actionError })), (0, jsx_runtime_1.jsx)(core_1.Tabs.Panel, { value: "all", children: list }), (0, jsx_runtime_1.jsx)(core_1.Tabs.Panel, { value: "unread", children: list })] })] }));
37
+ }
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.InboxPanel = InboxPanel;
5
+ const jsx_runtime_1 = require("react/jsx-runtime");
6
+ const core_1 = require("@mantine/core");
7
+ const inbox_context_1 = require("./inbox-context");
8
+ const inbox_list_1 = require("./inbox-list");
9
+ /** What the bell opens: the newest notifications, with the ways on to the full list and the preference. */
10
+ function InboxPanel() {
11
+ const { labels, viewAll, openPreferences } = (0, inbox_context_1.useInbox)();
12
+ return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)(inbox_list_1.InboxListView, { maxHeight: 400, heading: (0, jsx_runtime_1.jsx)(core_1.Title, { order: 2, size: "h5", children: labels.heading }) }), (0, jsx_runtime_1.jsx)(core_1.Divider, {}), (0, jsx_runtime_1.jsxs)(core_1.Group, { justify: "space-between", gap: 4, p: 4, children: [(0, jsx_runtime_1.jsx)(core_1.Button, { variant: "subtle", size: "compact-sm", mih: 44, px: "sm", onClick: viewAll, children: labels.viewAll }), (0, jsx_runtime_1.jsx)(core_1.Button, { variant: "subtle", size: "compact-sm", mih: 44, px: "sm", onClick: openPreferences, children: labels.preferences })] })] }));
13
+ }
@@ -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; } });