@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.
- package/CHANGELOG.md +17 -0
- package/README.md +56 -2
- 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 +33 -4
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { TRANSPORT, createCommunicationClient } from '../client.js';
|
|
2
|
+
/** `X-Communication-Person` is base64url of the UTF-8 person id, so any identifier travels safely in a header. */
|
|
3
|
+
export function personHeader(personId) {
|
|
4
|
+
let binary = '';
|
|
5
|
+
for (const byte of new TextEncoder().encode(personId))
|
|
6
|
+
binary += String.fromCharCode(byte);
|
|
7
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
8
|
+
}
|
|
9
|
+
const isClient = (v) => TRANSPORT in v;
|
|
10
|
+
/**
|
|
11
|
+
* The in-app inbox of one person. Pass an existing client to share its key and settings, or the same options as
|
|
12
|
+
* `createCommunicationClient`. The key needs the scopes `inbox:read` and `inbox:write`.
|
|
13
|
+
*/
|
|
14
|
+
export function createInboxClient(client = {}) {
|
|
15
|
+
const request = (isClient(client) ? client : createCommunicationClient(client))[TRANSPORT];
|
|
16
|
+
const call = (method, path, personId, body, read, options = {}) => request(method, path, body, { headers: { 'X-Communication-Person': personHeader(personId) }, retry: options.retry ?? read, timeoutMs: options.timeoutMs }).then((r) => r.data);
|
|
17
|
+
return {
|
|
18
|
+
summary: (personId, options) => call('GET', '/inbox/summary', personId, undefined, true, options),
|
|
19
|
+
list(personId, query = {}, options) {
|
|
20
|
+
const q = new URLSearchParams();
|
|
21
|
+
if (query.limit !== undefined)
|
|
22
|
+
q.set('limit', String(query.limit));
|
|
23
|
+
if (query.cursor)
|
|
24
|
+
q.set('cursor', query.cursor);
|
|
25
|
+
if (query.unreadOnly)
|
|
26
|
+
q.set('unread', 'true');
|
|
27
|
+
const qs = q.toString();
|
|
28
|
+
return call('GET', `/inbox/notifications${qs ? `?${qs}` : ''}`, personId, undefined, true, options);
|
|
29
|
+
},
|
|
30
|
+
markRead: (personId, id, options) => call('POST', `/inbox/notifications/${encodeURIComponent(id)}/read`, personId, {}, false, options),
|
|
31
|
+
markAllRead: (personId, input = {}, options) => call('POST', '/inbox/read-all', personId, input.upTo ? { upTo: input.upTo } : {}, false, options),
|
|
32
|
+
getPreference: (personId, options) => call('GET', '/inbox/preference', personId, undefined, true, options),
|
|
33
|
+
setPreference: (personId, state, options) => call('PUT', '/inbox/preference', personId, { state }, false, options),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { CommunicationError } from '../errors.js';
|
|
2
|
+
/** The six answers a browser can get from the host's inbox routes. The messages are fixed: nothing from upstream is repeated. */
|
|
3
|
+
export const HOST_ERRORS = {
|
|
4
|
+
not_signed_in: { status: 401, message: 'Sign in to see your notifications.' },
|
|
5
|
+
not_permitted: { status: 403, message: 'This request is not allowed.' },
|
|
6
|
+
not_found: { status: 404, message: 'This notification is not available.' },
|
|
7
|
+
invalid_input: { status: 422, message: 'The request is not valid.' },
|
|
8
|
+
rate_limited: { status: 429, message: 'Too many requests. Try again in a moment.' },
|
|
9
|
+
unavailable: { status: 503, message: 'Notifications are not available right now.' },
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Total: every failure is one of the six codes. A revoked or misconfigured key, a timeout and anything unknown are
|
|
13
|
+
* `unavailable`, because the person in the browser cannot fix them and must not be told about them.
|
|
14
|
+
*/
|
|
15
|
+
export function hostCodeOf(error) {
|
|
16
|
+
if (!(error instanceof CommunicationError))
|
|
17
|
+
return 'unavailable';
|
|
18
|
+
switch (error.code) {
|
|
19
|
+
case 'forbidden':
|
|
20
|
+
return 'not_permitted';
|
|
21
|
+
case 'not_found':
|
|
22
|
+
return 'not_found';
|
|
23
|
+
case 'validation_failed':
|
|
24
|
+
return 'invalid_input';
|
|
25
|
+
case 'rate_limited':
|
|
26
|
+
return 'rate_limited';
|
|
27
|
+
default:
|
|
28
|
+
return 'unavailable';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const HEADERS = { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' };
|
|
32
|
+
export function hostError(code, field) {
|
|
33
|
+
const body = { error: { code, message: HOST_ERRORS[code].message, ...(field ? { field } : {}) } };
|
|
34
|
+
return new Response(JSON.stringify(body), { status: HOST_ERRORS[code].status, headers: HEADERS });
|
|
35
|
+
}
|
|
36
|
+
export function hostData(data) {
|
|
37
|
+
return new Response(JSON.stringify({ data }), { status: 200, headers: HEADERS });
|
|
38
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createInboxClient } from './client.js';
|
|
2
|
+
import { hostCodeOf, hostData, hostError } from './errors.js';
|
|
3
|
+
import { originAllowed } from './origin.js';
|
|
4
|
+
import { projectMarkRead, projectPage, projectPreference, projectReadAll, projectSummary } from './project.js';
|
|
5
|
+
import { INBOX_LIMITS, parseBody, parseCursor, parseId, parseLimit, parseState, parseUnread, parseUpTo } from './validate.js';
|
|
6
|
+
/** The six operations of IB-8, and nothing else. */
|
|
7
|
+
function match(method, pathname, base) {
|
|
8
|
+
if (pathname !== base && !pathname.startsWith(`${base}/`))
|
|
9
|
+
return null;
|
|
10
|
+
const rest = pathname.slice(base.length).replace(/\/+$/, '');
|
|
11
|
+
if (method === 'GET' && rest === '/summary')
|
|
12
|
+
return { name: 'summary', write: false };
|
|
13
|
+
if (method === 'GET' && rest === '/notifications')
|
|
14
|
+
return { name: 'list', write: false };
|
|
15
|
+
if (method === 'GET' && rest === '/preference')
|
|
16
|
+
return { name: 'getPreference', write: false };
|
|
17
|
+
if (method === 'PUT' && rest === '/preference')
|
|
18
|
+
return { name: 'setPreference', write: true };
|
|
19
|
+
if (method === 'POST' && rest === '/read-all')
|
|
20
|
+
return { name: 'readAll', write: true };
|
|
21
|
+
const read = method === 'POST' ? /^\/notifications\/([^/]+)\/read$/.exec(rest) : null;
|
|
22
|
+
if (read)
|
|
23
|
+
return { name: 'markRead', write: true, id: read[1] };
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The host xApp's browser-facing inbox routes, in one tested function. Mount it on a catch-all route:
|
|
28
|
+
*
|
|
29
|
+
* const handler = createInboxHandler({ resolvePerson });
|
|
30
|
+
* export { handler as GET, handler as POST, handler as PUT };
|
|
31
|
+
*
|
|
32
|
+
* The person comes only from `resolvePerson`. The host stores nothing. Without `client`, the key and the address are
|
|
33
|
+
* read on the first request, so a missing one answers `unavailable` and never stops the host's build.
|
|
34
|
+
*/
|
|
35
|
+
export function createInboxHandler(options) {
|
|
36
|
+
const base = (options.basePath ?? '/api/communication/inbox').replace(/\/+$/, '');
|
|
37
|
+
const call = { retry: false, timeoutMs: options.timeoutMs ?? 8000 };
|
|
38
|
+
let made;
|
|
39
|
+
const clientOf = () => options.client ?? (made ??= createInboxClient());
|
|
40
|
+
return async function inboxHandler(request) {
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(request.url);
|
|
43
|
+
const op = match(request.method.toUpperCase(), url.pathname, base);
|
|
44
|
+
if (!op)
|
|
45
|
+
return hostError('not_found');
|
|
46
|
+
if (op.write && !originAllowed(request, options.allowedOrigins))
|
|
47
|
+
return hostError('not_permitted');
|
|
48
|
+
const person = await options.resolvePerson(request);
|
|
49
|
+
if (typeof person !== 'string' || person.length === 0 || person.length > INBOX_LIMITS.personMax)
|
|
50
|
+
return hostError('not_signed_in');
|
|
51
|
+
const client = clientOf();
|
|
52
|
+
switch (op.name) {
|
|
53
|
+
case 'summary':
|
|
54
|
+
return answer(projectSummary(await client.summary(person, call)));
|
|
55
|
+
case 'list': {
|
|
56
|
+
const limit = parseLimit(url.searchParams.get('limit'));
|
|
57
|
+
if (!limit.ok)
|
|
58
|
+
return hostError('invalid_input', limit.field);
|
|
59
|
+
const cursor = parseCursor(url.searchParams.get('cursor'));
|
|
60
|
+
if (!cursor.ok)
|
|
61
|
+
return hostError('invalid_input', cursor.field);
|
|
62
|
+
const unread = parseUnread(url.searchParams.get('unread'));
|
|
63
|
+
if (!unread.ok)
|
|
64
|
+
return hostError('invalid_input', unread.field);
|
|
65
|
+
return answer(projectPage(await client.list(person, { limit: limit.value, cursor: cursor.value, unreadOnly: unread.value }, call)));
|
|
66
|
+
}
|
|
67
|
+
case 'markRead': {
|
|
68
|
+
let raw;
|
|
69
|
+
try {
|
|
70
|
+
raw = decodeURIComponent(op.id);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return hostError('invalid_input', 'id');
|
|
74
|
+
}
|
|
75
|
+
const id = parseId(raw);
|
|
76
|
+
if (!id.ok)
|
|
77
|
+
return hostError('invalid_input', id.field);
|
|
78
|
+
return answer(projectMarkRead(await client.markRead(person, id.value, call)));
|
|
79
|
+
}
|
|
80
|
+
case 'readAll': {
|
|
81
|
+
const body = parseBody(await request.text());
|
|
82
|
+
if (!body.ok)
|
|
83
|
+
return hostError('invalid_input', body.field);
|
|
84
|
+
const upTo = parseUpTo(body.value.upTo);
|
|
85
|
+
if (!upTo.ok)
|
|
86
|
+
return hostError('invalid_input', upTo.field);
|
|
87
|
+
return answer(projectReadAll(await client.markAllRead(person, { upTo: upTo.value }, call)));
|
|
88
|
+
}
|
|
89
|
+
case 'getPreference':
|
|
90
|
+
return answer(projectPreference(await client.getPreference(person, call)));
|
|
91
|
+
case 'setPreference': {
|
|
92
|
+
const body = parseBody(await request.text());
|
|
93
|
+
if (!body.ok)
|
|
94
|
+
return hostError('invalid_input', body.field);
|
|
95
|
+
const state = parseState(body.value.state);
|
|
96
|
+
if (!state.ok)
|
|
97
|
+
return hostError('invalid_input', state.field);
|
|
98
|
+
return answer(projectPreference(await client.setPreference(person, state.value, call)));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
const code = hostCodeOf(error);
|
|
104
|
+
if (code === 'unavailable')
|
|
105
|
+
report(error);
|
|
106
|
+
return hostError(code);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
function answer(data) {
|
|
110
|
+
if (data === null) {
|
|
111
|
+
report(new Error('The inbox answer did not have the documented shape'));
|
|
112
|
+
return hostError('unavailable');
|
|
113
|
+
}
|
|
114
|
+
return hostData(data);
|
|
115
|
+
}
|
|
116
|
+
function report(error) {
|
|
117
|
+
try {
|
|
118
|
+
options.onError?.(error);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
// A failing logger must not change the answer.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A request that changes something must come from the host's own pages (14.5): the browser says `Sec-Fetch-Site: same-origin`,
|
|
3
|
+
* or the `Origin` header equals an allowed origin. Without `allowedOrigins`, the only allowed origin is the request's own.
|
|
4
|
+
*/
|
|
5
|
+
export function originAllowed(request, allowedOrigins) {
|
|
6
|
+
if (request.headers.get('sec-fetch-site') === 'same-origin')
|
|
7
|
+
return true;
|
|
8
|
+
const origin = request.headers.get('origin');
|
|
9
|
+
if (!origin)
|
|
10
|
+
return false;
|
|
11
|
+
let allowed;
|
|
12
|
+
try {
|
|
13
|
+
allowed = allowedOrigins && allowedOrigins.length > 0 ? allowedOrigins : [new URL(request.url).origin];
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return allowed.includes(origin);
|
|
19
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Allowlist projection: only the documented fields of IB-2 leave the host, so nothing the Gateway adds later reaches a
|
|
3
|
+
* browser by accident. A value of the wrong shape gives `null`, which the handler answers as `unavailable`.
|
|
4
|
+
*/
|
|
5
|
+
const obj = (v) => (typeof v === 'object' && v !== null && !Array.isArray(v) ? v : null);
|
|
6
|
+
const str = (v) => (typeof v === 'string' ? v : null);
|
|
7
|
+
const strOrNull = (v) => (v === null ? null : typeof v === 'string' ? v : undefined);
|
|
8
|
+
const count = (v) => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v : null);
|
|
9
|
+
export function projectNotification(v) {
|
|
10
|
+
const o = obj(v);
|
|
11
|
+
const source = obj(o?.source);
|
|
12
|
+
if (!o || !source)
|
|
13
|
+
return null;
|
|
14
|
+
const id = str(o.id);
|
|
15
|
+
const title = str(o.title);
|
|
16
|
+
const body = str(o.body);
|
|
17
|
+
const createdAt = str(o.createdAt);
|
|
18
|
+
const link = strOrNull(o.link);
|
|
19
|
+
const readAt = strOrNull(o.readAt);
|
|
20
|
+
const name = str(source.name);
|
|
21
|
+
if (id === null || title === null || body === null || createdAt === null || link === undefined || readAt === undefined || name === null)
|
|
22
|
+
return null;
|
|
23
|
+
if (o.kind !== 'notification' && o.kind !== 'announcement')
|
|
24
|
+
return null;
|
|
25
|
+
if (source.type !== 'xapp' && source.type !== 'tenant')
|
|
26
|
+
return null;
|
|
27
|
+
if (typeof o.read !== 'boolean')
|
|
28
|
+
return null;
|
|
29
|
+
return { id, kind: o.kind, title, body, link, source: { type: source.type, name }, createdAt, read: o.read, readAt };
|
|
30
|
+
}
|
|
31
|
+
export function projectSummary(v) {
|
|
32
|
+
const o = obj(v);
|
|
33
|
+
if (!o)
|
|
34
|
+
return null;
|
|
35
|
+
const unreadCount = count(o.unreadCount);
|
|
36
|
+
const newestUnreadAt = strOrNull(o.newestUnreadAt);
|
|
37
|
+
const pollSeconds = count(o.pollSeconds);
|
|
38
|
+
return unreadCount === null || newestUnreadAt === undefined || pollSeconds === null ? null : { unreadCount, newestUnreadAt, pollSeconds };
|
|
39
|
+
}
|
|
40
|
+
export function projectPage(v) {
|
|
41
|
+
const o = obj(v);
|
|
42
|
+
if (!o || !Array.isArray(o.notifications))
|
|
43
|
+
return null;
|
|
44
|
+
const notifications = [];
|
|
45
|
+
for (const raw of o.notifications) {
|
|
46
|
+
const n = projectNotification(raw);
|
|
47
|
+
if (!n)
|
|
48
|
+
return null;
|
|
49
|
+
notifications.push(n);
|
|
50
|
+
}
|
|
51
|
+
const nextCursor = strOrNull(o.nextCursor);
|
|
52
|
+
const unreadCount = count(o.unreadCount);
|
|
53
|
+
return nextCursor === undefined || unreadCount === null ? null : { notifications, nextCursor, unreadCount };
|
|
54
|
+
}
|
|
55
|
+
export function projectMarkRead(v) {
|
|
56
|
+
const o = obj(v);
|
|
57
|
+
const notification = projectNotification(o?.notification);
|
|
58
|
+
const unreadCount = count(o?.unreadCount);
|
|
59
|
+
return notification && unreadCount !== null ? { notification, unreadCount } : null;
|
|
60
|
+
}
|
|
61
|
+
export function projectReadAll(v) {
|
|
62
|
+
const o = obj(v);
|
|
63
|
+
const marked = count(o?.marked);
|
|
64
|
+
const remaining = count(o?.remaining);
|
|
65
|
+
const unreadCount = count(o?.unreadCount);
|
|
66
|
+
return marked === null || remaining === null || unreadCount === null ? null : { marked, remaining, unreadCount };
|
|
67
|
+
}
|
|
68
|
+
export function projectPreference(v) {
|
|
69
|
+
const o = obj(v);
|
|
70
|
+
if (!o)
|
|
71
|
+
return null;
|
|
72
|
+
const changedAt = strOrNull(o.changedAt);
|
|
73
|
+
if (o.state !== 'opted_in' && o.state !== 'opted_out' && o.state !== 'not_set')
|
|
74
|
+
return null;
|
|
75
|
+
return changedAt === undefined || typeof o.delivers !== 'boolean' ? null : { state: o.state, changedAt, delivers: o.delivers };
|
|
76
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const INBOX_LIMITS = { pageMin: 1, pageMax: 100, cursorMax: 128, idMax: 64, upToMax: 40, bodyBytes: 1024, personMax: 128 };
|
|
2
|
+
const ok = (value) => ({ ok: true, value });
|
|
3
|
+
const bad = (field) => ({ ok: false, field });
|
|
4
|
+
export function parseLimit(raw) {
|
|
5
|
+
if (raw === null)
|
|
6
|
+
return ok(undefined);
|
|
7
|
+
if (!/^\d{1,3}$/.test(raw))
|
|
8
|
+
return bad('limit');
|
|
9
|
+
const n = Number(raw);
|
|
10
|
+
return n >= INBOX_LIMITS.pageMin && n <= INBOX_LIMITS.pageMax ? ok(n) : bad('limit');
|
|
11
|
+
}
|
|
12
|
+
export function parseCursor(raw) {
|
|
13
|
+
if (raw === null)
|
|
14
|
+
return ok(undefined);
|
|
15
|
+
return raw.length > 0 && raw.length <= INBOX_LIMITS.cursorMax && /^[A-Za-z0-9_-]+$/.test(raw) ? ok(raw) : bad('cursor');
|
|
16
|
+
}
|
|
17
|
+
export function parseUnread(raw) {
|
|
18
|
+
if (raw === null || raw === 'false')
|
|
19
|
+
return ok(false);
|
|
20
|
+
return raw === 'true' ? ok(true) : bad('unread');
|
|
21
|
+
}
|
|
22
|
+
export function parseId(raw) {
|
|
23
|
+
return raw.length > 0 && raw.length <= INBOX_LIMITS.idMax && /^[A-Za-z0-9_-]+$/.test(raw) ? ok(raw) : bad('id');
|
|
24
|
+
}
|
|
25
|
+
export function parseUpTo(raw) {
|
|
26
|
+
if (raw === undefined || raw === null)
|
|
27
|
+
return ok(undefined);
|
|
28
|
+
if (typeof raw !== 'string' || raw.length > INBOX_LIMITS.upToMax || !/^\d{4}-\d{2}-\d{2}T[\d:.]+(Z|[+-]\d{2}:\d{2})$/.test(raw))
|
|
29
|
+
return bad('upTo');
|
|
30
|
+
const t = Date.parse(raw);
|
|
31
|
+
return Number.isFinite(t) ? ok(new Date(t).toISOString()) : bad('upTo');
|
|
32
|
+
}
|
|
33
|
+
export function parseState(raw) {
|
|
34
|
+
return raw === 'opted_in' || raw === 'opted_out' ? ok(raw) : bad('state');
|
|
35
|
+
}
|
|
36
|
+
/** A JSON object of at most 1024 bytes. An empty body is an empty object. */
|
|
37
|
+
export function parseBody(text) {
|
|
38
|
+
if (new TextEncoder().encode(text).length > INBOX_LIMITS.bodyBytes)
|
|
39
|
+
return bad('body');
|
|
40
|
+
if (text.trim() === '')
|
|
41
|
+
return ok({});
|
|
42
|
+
try {
|
|
43
|
+
const v = JSON.parse(text);
|
|
44
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v) ? ok(v) : bad('body');
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return bad('body');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Fills `{name}` placeholders. Values are text, never markup. */
|
|
2
|
+
export function fill(template, values) {
|
|
3
|
+
return template.replace(/\{(\w+)\}/g, (whole, key) => (key in values ? String(values[key]) : whole));
|
|
4
|
+
}
|
|
5
|
+
/** Nothing for 0, the number for 1 to 99, "99+" above (12.1). */
|
|
6
|
+
export function badgeText(count) {
|
|
7
|
+
if (!Number.isFinite(count) || count < 1)
|
|
8
|
+
return '';
|
|
9
|
+
return count > 99 ? '99+' : String(Math.floor(count));
|
|
10
|
+
}
|
|
11
|
+
/** The bell's accessible name: "{label}, {n} unread", or the label alone when nothing is unread (15.3). */
|
|
12
|
+
export function bellName(labels, count) {
|
|
13
|
+
return count > 0 ? fill(labels.bellWithCount, { label: labels.bell, n: count }) : labels.bell;
|
|
14
|
+
}
|
|
15
|
+
const LINK_MAX = 2048;
|
|
16
|
+
/** Accepts exactly what the inbox stores: an absolute https URL without credentials, at most 2048 characters (12.15). */
|
|
17
|
+
export function safeLink(link) {
|
|
18
|
+
if (!link || link.length > LINK_MAX)
|
|
19
|
+
return null;
|
|
20
|
+
try {
|
|
21
|
+
const u = new URL(link);
|
|
22
|
+
return u.protocol === 'https:' && !u.username && !u.password ? u.toString() : null;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const MINUTE = 60;
|
|
29
|
+
const HOUR = 3600;
|
|
30
|
+
const DAY = 86400;
|
|
31
|
+
/** "now", "5 minutes ago", "yesterday", then a short date after a week. A time in the future (clock skew) reads as now. */
|
|
32
|
+
export function relativeTime(iso, now, locale) {
|
|
33
|
+
const then = Date.parse(iso);
|
|
34
|
+
if (!Number.isFinite(then))
|
|
35
|
+
return '';
|
|
36
|
+
const seconds = Math.max(0, Math.floor((now.getTime() - then) / 1000));
|
|
37
|
+
try {
|
|
38
|
+
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
|
|
39
|
+
if (seconds < MINUTE)
|
|
40
|
+
return rtf.format(0, 'second');
|
|
41
|
+
if (seconds < HOUR)
|
|
42
|
+
return rtf.format(-Math.floor(seconds / MINUTE), 'minute');
|
|
43
|
+
if (seconds < DAY)
|
|
44
|
+
return rtf.format(-Math.floor(seconds / HOUR), 'hour');
|
|
45
|
+
if (seconds < 7 * DAY)
|
|
46
|
+
return rtf.format(-Math.floor(seconds / DAY), 'day');
|
|
47
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(new Date(then));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return new Date(then).toISOString().slice(0, 10);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** The full date and time, for the `title` of a relative time. */
|
|
54
|
+
export function exactTime(iso, locale) {
|
|
55
|
+
const then = Date.parse(iso);
|
|
56
|
+
if (!Number.isFinite(then))
|
|
57
|
+
return '';
|
|
58
|
+
try {
|
|
59
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: 'full', timeStyle: 'short' }).format(new Date(then));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return new Date(then).toISOString();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -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
|
+
}
|