@tinvar/react 0.0.0-stage → 0.4.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.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +168 -4
  3. package/src/action-server.js +41 -0
  4. package/src/action.d.ts +68 -0
  5. package/src/action.js +167 -0
  6. package/src/bootstrap.js +153 -0
  7. package/src/channel.d.ts +46 -0
  8. package/src/channel.js +132 -0
  9. package/src/client-modules.d.ts +8 -0
  10. package/src/client-modules.js +88 -0
  11. package/src/client.d.ts +36 -0
  12. package/src/client.js +42 -0
  13. package/src/compose-route.d.ts +33 -0
  14. package/src/compose-route.js +107 -0
  15. package/src/csrf.d.ts +11 -0
  16. package/src/csrf.js +49 -0
  17. package/src/deferred-boundary.d.ts +61 -0
  18. package/src/deferred-boundary.js +646 -0
  19. package/src/deferred-groups.d.ts +24 -0
  20. package/src/deferred-groups.js +198 -0
  21. package/src/deferred-revalidation.d.ts +37 -0
  22. package/src/deferred-revalidation.js +108 -0
  23. package/src/deferred-triggers.d.ts +26 -0
  24. package/src/deferred-triggers.js +172 -0
  25. package/src/deferred.d.ts +24 -0
  26. package/src/deferred.jsx +100 -0
  27. package/src/document-load.js +95 -0
  28. package/src/endpoint.d.ts +87 -0
  29. package/src/endpoint.js +304 -0
  30. package/src/form-data.js +219 -0
  31. package/src/form.d.ts +177 -0
  32. package/src/form.js +615 -0
  33. package/src/forms.d.ts +4 -0
  34. package/src/forms.js +10 -0
  35. package/src/fragment-client.d.ts +70 -0
  36. package/src/fragment-client.js +510 -0
  37. package/src/head.d.ts +85 -0
  38. package/src/head.js +360 -0
  39. package/src/inbox-view.d.ts +17 -0
  40. package/src/inbox-view.js +105 -0
  41. package/src/inbox.d.ts +108 -0
  42. package/src/inbox.js +293 -0
  43. package/src/index.combined.d.ts +31 -0
  44. package/src/index.d.ts +5 -0
  45. package/src/index.js +29 -0
  46. package/src/index.server.d.ts +4 -0
  47. package/src/index.server.js +8 -0
  48. package/src/infinite-scroll.d.ts +108 -0
  49. package/src/infinite-scroll.js +715 -0
  50. package/src/layout-props.d.ts +30 -0
  51. package/src/layout-props.js +143 -0
  52. package/src/link-click.d.ts +3 -0
  53. package/src/link-click.js +68 -0
  54. package/src/link.d.ts +63 -0
  55. package/src/link.js +274 -0
  56. package/src/live.d.ts +65 -0
  57. package/src/live.js +977 -0
  58. package/src/merge-slot.d.ts +41 -0
  59. package/src/merge-slot.js +253 -0
  60. package/src/merge.d.ts +111 -0
  61. package/src/merge.js +149 -0
  62. package/src/once-slot.d.ts +14 -0
  63. package/src/once-slot.js +75 -0
  64. package/src/once-store.d.ts +26 -0
  65. package/src/once-store.js +211 -0
  66. package/src/once.d.ts +44 -0
  67. package/src/once.js +65 -0
  68. package/src/optimistic.d.ts +14 -0
  69. package/src/optimistic.js +159 -0
  70. package/src/optional.d.ts +10 -0
  71. package/src/optional.js +31 -0
  72. package/src/page-root.d.ts +56 -0
  73. package/src/page-root.js +455 -0
  74. package/src/payload.d.ts +15 -0
  75. package/src/payload.js +101 -0
  76. package/src/poll.d.ts +55 -0
  77. package/src/poll.js +224 -0
  78. package/src/precognition.js +190 -0
  79. package/src/prefetch-cache.d.ts +46 -0
  80. package/src/prefetch-cache.js +256 -0
  81. package/src/progress.d.ts +38 -0
  82. package/src/progress.js +243 -0
  83. package/src/remember-store.d.ts +50 -0
  84. package/src/remember-store.js +486 -0
  85. package/src/rescue.d.ts +10 -0
  86. package/src/rescue.js +30 -0
  87. package/src/resumable.js +822 -0
  88. package/src/resume-store.js +364 -0
  89. package/src/route.d.ts +50 -0
  90. package/src/route.js +227 -0
  91. package/src/router-hooks.js +234 -0
  92. package/src/router.d.ts +586 -0
  93. package/src/router.js +3555 -0
  94. package/src/segment.d.ts +36 -0
  95. package/src/segment.js +116 -0
  96. package/src/server.d.ts +71 -0
  97. package/src/server.js +242 -0
  98. package/src/submit.js +333 -0
  99. package/src/telemetry.d.ts +71 -0
  100. package/src/telemetry.js +851 -0
  101. package/src/testing.d.ts +89 -0
  102. package/src/testing.js +634 -0
  103. package/src/transfer.d.ts +48 -0
  104. package/src/transfer.js +135 -0
  105. package/src/transport.js +390 -0
  106. package/src/upload.d.ts +342 -0
  107. package/src/upload.js +1240 -0
  108. package/src/use-channel.d.ts +21 -0
  109. package/src/use-channel.js +51 -0
  110. package/src/use-form.d.ts +86 -0
  111. package/src/use-form.js +432 -0
  112. package/src/use-http.d.ts +56 -0
  113. package/src/use-http.js +248 -0
  114. package/src/use-inbox.d.ts +41 -0
  115. package/src/use-inbox.js +201 -0
  116. package/src/use-remember.d.ts +10 -0
  117. package/src/use-remember.js +132 -0
  118. package/src/when-mounted.d.ts +15 -0
  119. package/src/when-mounted.js +27 -0
  120. package/README.md +0 -3
package/src/head.js ADDED
@@ -0,0 +1,360 @@
1
+ 'use client';
2
+
3
+ // The document's title, meta tags and links, from any component.
4
+ //
5
+ // tinvar-spec.md, "Operations and tooling": "React 19 hoists <title> and
6
+ // <meta> from components; add key-based dedupe, one title, a global title
7
+ // template, and PHP-rendered fallback tags marked for replacement."
8
+ //
9
+ // React hoists every <title>, <meta> and <link> a component renders into
10
+ // <head>, all of them: a layout and its page each naming a title leaves two,
11
+ // and the browser shows whichever comes first; each naming a canonical URL
12
+ // leaves two, and a crawler believes neither. So the tags go through these
13
+ // components instead, which agree among themselves on one per key:
14
+ //
15
+ // - the one deepest in the route wins -- the page over its layouts, read
16
+ // from the <Segment> it sits in, whatever order they render in -- and
17
+ // among equals, the one mounted last;
18
+ // - <Title> applies the provider's template, and the provider's default
19
+ // stands under every title, so a page that names none still has one;
20
+ // - a <Meta> is keyed by its name, property or http-equiv, or `headKey`;
21
+ // - a <HeadLink> by what makes two links the same link: see linkKey().
22
+ //
23
+ // A raw <title>, written without <Title>, takes part too, at the document's
24
+ // depth: on the server the renderer keeps one title of all of them, and in
25
+ // the browser the provider keeps the winner first in <head>, where the
26
+ // browser reads a document's title from (arrangeTitles()). A raw <link>
27
+ // takes part on the server alone, at the document's depth and keyed as
28
+ // <HeadLink> keys it, unless it is one of React's resources; in the browser
29
+ // it is React's to hoist and remove, so a link that changes with the page
30
+ // belongs in a <HeadLink>.
31
+ //
32
+ // On the server every tag renders, marked with its key and depth, and the
33
+ // renderer keeps one of each key in the document's head (packages/renderer
34
+ // src/head.js), dropping the PHP-rendered fallbacks any of them replaces. In
35
+ // the browser the same agreement is reached once they mount, and the losers
36
+ // render nothing.
37
+
38
+ import {
39
+ createContext,
40
+ createElement,
41
+ useContext,
42
+ useId,
43
+ useLayoutEffect,
44
+ useMemo,
45
+ useState,
46
+ useSyncExternalStore,
47
+ } from 'react';
48
+ import { useSegmentDepth } from './segment.js';
49
+
50
+ /** Marks a tag PHP rendered for a component's to replace (see Tinvar\Protocol\HeadTag). */
51
+ export const FALLBACK_ATTRIBUTE = 'data-tinvar-head';
52
+
53
+ /** What a fallback is marked with. */
54
+ export const FALLBACK = 'fallback';
55
+
56
+ /** The key a tag is deduplicated by, on the element. */
57
+ export const KEY_ATTRIBUTE = 'data-tinvar-key';
58
+
59
+ /** Its depth in the route, on the element. */
60
+ export const DEPTH_ATTRIBUTE = 'data-tinvar-depth';
61
+
62
+ /** The key every title shares: there is one title. */
63
+ export const TITLE_KEY = 'title';
64
+
65
+ /**
66
+ * The key a meta tag is deduplicated by.
67
+ *
68
+ * @param {Record<string, unknown>} props
69
+ * @returns {string | null} null for a tag with nothing to key it by, which is never deduplicated
70
+ */
71
+ export function metaKey(props) {
72
+ if (typeof props.headKey === 'string' && props.headKey !== '') return props.headKey;
73
+ if (typeof props.name === 'string') return `name:${props.name}`;
74
+ if (typeof props.property === 'string') return `property:${props.property}`;
75
+ if (typeof props.httpEquiv === 'string') return `http-equiv:${props.httpEquiv.toLowerCase()}`;
76
+ if (props.charSet !== undefined) return 'charset';
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Rels whose links are the page's icon at a size: a deeper one stands in
82
+ * for a shallower one of the same size and media, whatever its href.
83
+ */
84
+ const ICON_RELS = new Set([
85
+ 'icon',
86
+ 'shortcut icon',
87
+ 'apple-touch-icon',
88
+ 'apple-touch-icon-precomposed',
89
+ 'mask-icon',
90
+ ]);
91
+
92
+ /**
93
+ * The key a link is deduplicated by.
94
+ *
95
+ * - `headKey`, given: the application's own.
96
+ * - canonical: the rel alone. A document has one canonical URL, so the
97
+ * page's replaces its layout's, whatever either names.
98
+ * - alternate with an hreflang: the rel and the language -- one
99
+ * translation of the page per language.
100
+ * - an icon: the rel, its sizes, its media and its type -- one icon per
101
+ * size, per colour scheme, per format. A browser picks among icons of
102
+ * different types by what it can draw, so an SVG and a PNG fallback
103
+ * with no sizes are two icons, not one replacing the other.
104
+ * - anything else: the rel and the href. Two links to the same thing for
105
+ * the same reason are one link; a preload, a preconnect, a feed or a
106
+ * manifest named twice -- by a layout and a page -- is written once.
107
+ *
108
+ * A stylesheet is never keyed: React's own resources dedupe stylesheets by
109
+ * href and order them by precedence, and a <HeadLink rel="stylesheet">
110
+ * renders React's <link> exactly as given (see HeadLink).
111
+ *
112
+ * @param {Record<string, unknown>} props
113
+ * @returns {string | null} null for a link with nothing to key it by, which is never deduplicated
114
+ */
115
+ export function linkKey(props) {
116
+ if (typeof props.headKey === 'string' && props.headKey !== '') return `link:${props.headKey}`;
117
+ const rel = typeof props.rel === 'string' ? props.rel.trim().toLowerCase().replace(/\s+/g, ' ') : '';
118
+ if (rel === '' || rel === 'stylesheet') return null;
119
+ if (rel === 'canonical') return 'link:canonical';
120
+
121
+ const hrefLang = props.hrefLang ?? props.hreflang;
122
+ if (rel === 'alternate' && typeof hrefLang === 'string' && hrefLang !== '') {
123
+ return `link:alternate:${hrefLang.toLowerCase()}`;
124
+ }
125
+ if (ICON_RELS.has(rel)) {
126
+ const sizes = typeof props.sizes === 'string' ? props.sizes.trim().toLowerCase() : '';
127
+ const media = typeof props.media === 'string' ? props.media.trim() : '';
128
+ const type = typeof props.type === 'string' ? props.type.trim().toLowerCase() : '';
129
+ return `link:${rel}:${sizes}:${media}:${type}`;
130
+ }
131
+ return typeof props.href === 'string' && props.href !== '' ? `link:${rel}:${props.href}` : null;
132
+ }
133
+
134
+ /**
135
+ * The tags mounted in the browser, and which of each key wins.
136
+ *
137
+ * @returns {{
138
+ * register(id: string, key: string, depth: number): () => void,
139
+ * wins(id: string): boolean,
140
+ * subscribe(listener: () => void): () => void,
141
+ * }}
142
+ */
143
+ export function createHeadStore() {
144
+ /** @type {Map<string, { key: string, depth: number, order: number }>} */
145
+ const entries = new Map();
146
+ const listeners = new Set();
147
+ let order = 0;
148
+
149
+ const notify = () => {
150
+ for (const listener of listeners) listener();
151
+ };
152
+
153
+ return {
154
+ register(id, key, depth) {
155
+ entries.set(id, { key, depth, order: order++ });
156
+ notify();
157
+ return () => {
158
+ entries.delete(id);
159
+ notify();
160
+ };
161
+ },
162
+ wins(id) {
163
+ const mine = entries.get(id);
164
+ // Not registered yet -- the render before its effect -- or not in a
165
+ // provider's view at all: it renders, as the server did.
166
+ if (mine === undefined) return true;
167
+ for (const [other, entry] of entries) {
168
+ if (other === id || entry.key !== mine.key) continue;
169
+ if (entry.depth > mine.depth || (entry.depth === mine.depth && entry.order > mine.order))
170
+ return false;
171
+ }
172
+ return true;
173
+ },
174
+ subscribe(listener) {
175
+ listeners.add(listener);
176
+ return () => listeners.delete(listener);
177
+ },
178
+ };
179
+ }
180
+
181
+ /**
182
+ * @typedef {{ store: ReturnType<typeof createHeadStore> | null, titleTemplate: string | null }} HeadContextValue
183
+ */
184
+ const HeadContext = createContext(/** @type {HeadContextValue} */ ({ store: null, titleTemplate: null }));
185
+
186
+ /**
187
+ * The title template and default for the page, around the document.
188
+ *
189
+ * @param {{ titleTemplate?: string, defaultTitle?: string, children?: unknown }} props `titleTemplate`
190
+ * holds `%s` where a <Title>'s text goes; `defaultTitle` is the title when no
191
+ * component names one, used as it is
192
+ */
193
+ export function HeadProvider({ titleTemplate, defaultTitle, children }) {
194
+ // One store for the provider's life: the document's, kept across every
195
+ // page the router mounts into it.
196
+ const [store] = useState(createHeadStore);
197
+ const value = useMemo(() => ({ store, titleTemplate: titleTemplate ?? null }), [store, titleTemplate]);
198
+
199
+ // A raw <title> is React's to hoist, and in the browser React puts each
200
+ // newly mounted title ahead of the others -- so a page's raw title showed
201
+ // over its <Title>, or a layout's raw title over the page's. The winner is
202
+ // kept first instead, whenever the store's tags change or the head does.
203
+ useLayoutEffect(() => {
204
+ if (typeof document === 'undefined' || typeof MutationObserver === 'undefined') return undefined;
205
+ const head = document.head;
206
+ if (head === null) return undefined;
207
+ const arrange = () => arrangeTitles(head);
208
+ const observer = new MutationObserver(arrange);
209
+ observer.observe(head, { childList: true });
210
+ const unsubscribe = store.subscribe(arrange);
211
+ arrange();
212
+ return () => {
213
+ observer.disconnect();
214
+ unsubscribe();
215
+ };
216
+ }, [store]);
217
+
218
+ return createElement(
219
+ HeadContext.Provider,
220
+ { value },
221
+ defaultTitle === undefined
222
+ ? null
223
+ : createElement(HeadTag, { kind: 'title', depth: -1, text: defaultTitle }),
224
+ children,
225
+ );
226
+ }
227
+
228
+ /**
229
+ * The document's title. Its text goes through the provider's template.
230
+ *
231
+ * @param {{ children: string | number | readonly (string | number)[] }} props
232
+ */
233
+ export function Title({ children }) {
234
+ const { titleTemplate } = useContext(HeadContext);
235
+ // React hoists a <title> with exactly one text child, so the text is one
236
+ // string however it was written.
237
+ const text = Array.isArray(children) ? children.join('') : String(children);
238
+ // Given by a function, so the text goes in as written: given as a
239
+ // string, `$'` or `$&` in a product's name was a replacement pattern.
240
+ const shown = titleTemplate === null ? text : titleTemplate.replace('%s', () => text);
241
+
242
+ return createElement(HeadTag, { kind: 'title', depth: useSegmentDepth(), text: shown });
243
+ }
244
+
245
+ /**
246
+ * A meta tag, one per name, property or http-equiv -- or per `headKey`,
247
+ * for a key of your own.
248
+ *
249
+ * @param {Record<string, unknown> & { headKey?: string }} props
250
+ */
251
+ export function Meta(props) {
252
+ return createElement(HeadTag, { kind: 'meta', depth: useSegmentDepth(), props });
253
+ }
254
+
255
+ /**
256
+ * A link in the document's head, one per key (linkKey()): one canonical
257
+ * URL, one icon per size and type, one alternate per language, and no link written
258
+ * twice -- or per `headKey`, for a key of your own.
259
+ *
260
+ * A stylesheet goes to React as it is, unkeyed and unmarked: React already
261
+ * keeps one of each href, orders them by `precedence`, and holds the page
262
+ * until they have loaded, none of which a link that gives way to another
263
+ * could do.
264
+ *
265
+ * @param {Record<string, unknown> & { rel: string, headKey?: string }} props
266
+ */
267
+ export function HeadLink(props) {
268
+ const depth = useSegmentDepth();
269
+ if (typeof props.rel === 'string' && props.rel.trim().toLowerCase() === 'stylesheet') {
270
+ const { headKey: _ignored, ...attributes } = props;
271
+ return createElement('link', attributes);
272
+ }
273
+ return createElement(HeadTag, { kind: 'link', depth, props });
274
+ }
275
+
276
+ /**
277
+ * One tag, rendered only while it wins its key.
278
+ *
279
+ * @param {{ kind: 'title' | 'meta' | 'link', depth: number, text?: string, props?: Record<string, unknown> }} props
280
+ */
281
+ function HeadTag({ kind, depth, text, props }) {
282
+ const { store } = useContext(HeadContext);
283
+ const id = useId();
284
+ const key = kind === 'title' ? TITLE_KEY : kind === 'link' ? linkKey(props ?? {}) : metaKey(props ?? {});
285
+
286
+ useLayoutEffect(() => {
287
+ if (store === null || key === null) return undefined;
288
+ return store.register(id, key, depth);
289
+ }, [store, id, key, depth]);
290
+
291
+ // The server and the hydrating render show every tag, as the document
292
+ // has them; the renderer already kept one of each key there.
293
+ const wins = useSyncExternalStore(
294
+ store?.subscribe ?? noSubscription,
295
+ () => store === null || key === null || store.wins(id),
296
+ () => true,
297
+ );
298
+
299
+ // A title that has just come to win -- the default once the page's went --
300
+ // is put first as it mounts, before the browser paints; a raw title is
301
+ // put in its place by the provider's observer, a microtask later.
302
+ useLayoutEffect(() => {
303
+ if (kind === 'title' && wins && store !== null && typeof document !== 'undefined' && document.head) {
304
+ arrangeTitles(document.head);
305
+ }
306
+ }, [kind, wins, store, text]);
307
+
308
+ if (!wins) return null;
309
+
310
+ const marks = { [DEPTH_ATTRIBUTE]: String(depth), ...(key === null ? {} : { [KEY_ATTRIBUTE]: key }) };
311
+ if (kind === 'title') return createElement('title', marks, text);
312
+
313
+ const { headKey: _ignored, ...attributes } = props ?? {};
314
+ return createElement(kind, { ...attributes, ...marks });
315
+ }
316
+
317
+ const noSubscription = () => () => {};
318
+
319
+ /**
320
+ * Puts the title that wins first in $head, where the browser reads the
321
+ * document's title from, as the renderer resolves them on the server: the
322
+ * deepest, a raw <title> at the document's depth (0), a PHP fallback below
323
+ * everything, and among equals the newest -- the first in the head, since
324
+ * React inserts each title it mounts ahead of the others.
325
+ *
326
+ * Moved, never removed: every title in the head is React's, which removes
327
+ * it itself when its component unmounts, from wherever it is. The losers
328
+ * stay in the head, after the winner, where they change nothing the
329
+ * browser shows.
330
+ *
331
+ * @param {HTMLHeadElement} head
332
+ * @returns {Element | null} the winner, or null for a head with no title
333
+ */
334
+ export function arrangeTitles(head) {
335
+ const titles = [...head.querySelectorAll('title')].filter((title) => title.parentNode === head);
336
+ if (titles.length < 2) return titles[0] ?? null;
337
+
338
+ let winner = titles[0];
339
+ let best = depthOf(winner);
340
+ for (const title of titles.slice(1)) {
341
+ const depth = depthOf(title);
342
+ if (depth > best) {
343
+ winner = title;
344
+ best = depth;
345
+ }
346
+ }
347
+
348
+ // Only when it is not first already: a move is a mutation the observer
349
+ // hears, and this would otherwise answer it for ever.
350
+ if (titles[0] !== winner) head.insertBefore(winner, titles[0]);
351
+ return winner;
352
+ }
353
+
354
+ /** @param {Element} title */
355
+ function depthOf(title) {
356
+ if (title.getAttribute(FALLBACK_ATTRIBUTE) === FALLBACK) return -Infinity;
357
+ if (!title.hasAttribute(KEY_ATTRIBUTE)) return 0;
358
+ const depth = Number(title.getAttribute(DEPTH_ATTRIBUTE));
359
+ return Number.isFinite(depth) ? depth : 0;
360
+ }
@@ -0,0 +1,17 @@
1
+ import type { ReactElement } from 'react';
2
+ import type { InboxClient, InboxEntry } from './inbox.js';
3
+
4
+ export interface InboxProps<T extends string = string> {
5
+ client: InboxClient<T>;
6
+ /** The bell's name, and the panel's: "Notifications" by default. */
7
+ label?: string;
8
+ /** What an empty inbox says. */
9
+ empty?: string;
10
+ className?: string;
11
+ limit?: number;
12
+ /** Called when an entry is opened, after it is marked read; without it, its path is navigated to. */
13
+ onOpen?: (entry: InboxEntry<T>) => void;
14
+ }
15
+
16
+ /** A bell and the user's notifications, unstyled: every part carries `data-inbox` to style it by. */
17
+ export declare function Inbox<T extends string>(props: InboxProps<T>): ReactElement;
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ // <Inbox>: a bell and the user's notifications, unstyled (ADR 0155).
4
+ //
5
+ // <Inbox client={inbox.customer} onOpen={(entry) => navigate(entry.url)} />
6
+ //
7
+ // A button saying how many are unread, which opens a list of the newest
8
+ // entries -- each its title and body as text, never markup, read when it
9
+ // is opened -- with "Mark all read" and, while there are more, "Show
10
+ // more". Everything carries a `data-inbox` attribute to style it by, and
11
+ // the button its count as `data-unread`. An application with a design
12
+ // system of its own builds its bell on useInbox() instead.
13
+
14
+ import { createElement as h, useId, useState } from 'react';
15
+ import { sitePath } from './inbox.js';
16
+ import { useInbox } from './use-inbox.js';
17
+
18
+ /**
19
+ * @param {{
20
+ * client: ReturnType<typeof import('./inbox.js').inboxClient>,
21
+ * label?: string,
22
+ * empty?: string,
23
+ * className?: string,
24
+ * limit?: number,
25
+ * onOpen?: (entry: { id: string, url: string | null }) => void,
26
+ * }} props
27
+ */
28
+ export function Inbox({ client, label = 'Notifications', empty = 'Nothing yet.', className, limit, onOpen }) {
29
+ const inbox = useInbox(client, limit === undefined ? {} : { limit });
30
+ const [open, setOpen] = useState(false);
31
+ const panel = useId();
32
+
33
+ /** @param {{ id: string, url: string | null, readAt: string | null }} entry */
34
+ const openEntry = (entry) => {
35
+ if (entry.readAt === null) void inbox.markRead([entry.id]);
36
+ if (onOpen) {
37
+ onOpen(entry);
38
+ } else {
39
+ // A path on this site alone: `/\\host` and `/\t/host` lead off it as
40
+ // `//host` does.
41
+ const path = sitePath(entry.url);
42
+ if (path !== null) globalThis.location?.assign(path);
43
+ }
44
+ };
45
+
46
+ return h(
47
+ 'div',
48
+ { className, 'data-inbox': 'root' },
49
+ h(
50
+ 'button',
51
+ {
52
+ type: 'button',
53
+ 'data-inbox': 'bell',
54
+ 'data-unread': inbox.unread,
55
+ 'aria-expanded': open,
56
+ 'aria-controls': panel,
57
+ 'aria-label': inbox.unread > 0 ? `${label}, ${inbox.unread} unread` : label,
58
+ onClick: () => setOpen((was) => !was),
59
+ },
60
+ label,
61
+ inbox.unread > 0
62
+ ? h('span', { 'data-inbox': 'count', 'aria-hidden': true }, String(inbox.unread))
63
+ : null,
64
+ ),
65
+ open
66
+ ? h(
67
+ 'section',
68
+ { id: panel, 'data-inbox': 'panel', 'aria-label': label },
69
+ inbox.unread > 0
70
+ ? h(
71
+ 'button',
72
+ { type: 'button', 'data-inbox': 'mark-all', onClick: () => void inbox.markAllRead() },
73
+ 'Mark all read',
74
+ )
75
+ : null,
76
+ inbox.entries.length === 0 && !inbox.loading
77
+ ? h('p', { 'data-inbox': 'empty' }, empty)
78
+ : h(
79
+ 'ul',
80
+ { 'data-inbox': 'list' },
81
+ inbox.entries.map((entry) =>
82
+ h(
83
+ 'li',
84
+ { key: entry.id, 'data-inbox': 'entry', 'data-read': entry.readAt !== null },
85
+ h(
86
+ 'button',
87
+ { type: 'button', 'data-inbox': 'open', onClick: () => openEntry(entry) },
88
+ h('strong', null, entry.title),
89
+ entry.body === '' ? null : h('span', null, entry.body),
90
+ h('time', { dateTime: entry.createdAt }, new Date(entry.createdAt).toLocaleString()),
91
+ ),
92
+ ),
93
+ ),
94
+ ),
95
+ inbox.hasMore
96
+ ? h(
97
+ 'button',
98
+ { type: 'button', 'data-inbox': 'more', onClick: () => void inbox.loadMore() },
99
+ 'Show more',
100
+ )
101
+ : null,
102
+ )
103
+ : null,
104
+ );
105
+ }
package/src/inbox.d.ts ADDED
@@ -0,0 +1,108 @@
1
+ /** A signed-in user's in-app inbox (ADR 0155). */
2
+
3
+ import type { ListenOptions } from './channel.js';
4
+
5
+ export declare const INBOX_PATH: '/_inbox/';
6
+
7
+ /** The channels a notification goes out on. */
8
+ export type NotificationChannel = 'mail' | 'push' | 'inbox';
9
+
10
+ /** What codegen writes for each #[Notification]: the channels it sends on. */
11
+ export interface NotificationShape {
12
+ channels: NotificationChannel;
13
+ }
14
+
15
+ /** One entry: text to draw as text, and a path on this site. */
16
+ export interface InboxEntry<T extends string = string> {
17
+ id: string;
18
+ type: T;
19
+ title: string;
20
+ body: string;
21
+ url: string | null;
22
+ /** ISO 8601. */
23
+ createdAt: string;
24
+ readAt: string | null;
25
+ }
26
+
27
+ export interface InboxPage<T extends string = string> {
28
+ entries: InboxEntry<T>[];
29
+ unread: number;
30
+ /** The id the next page goes on from, or null for the last page. */
31
+ next: string | null;
32
+ }
33
+
34
+ /** What the inbox's personal channel says when the feed changes. */
35
+ export interface InboxUpdate<T extends string = string> {
36
+ unread: number;
37
+ entry: InboxEntry<T> | null;
38
+ read: string[];
39
+ allRead: boolean;
40
+ }
41
+
42
+ export interface NotificationPreference<T extends string = string> {
43
+ type: T;
44
+ label: string;
45
+ description: string;
46
+ channels: NotificationChannel[];
47
+ enabled: NotificationChannel[];
48
+ }
49
+
50
+ export interface NotificationPreferences<T extends string = string> {
51
+ types: NotificationPreference<T>[];
52
+ }
53
+
54
+ export interface NotificationChoice<T extends string = string> {
55
+ type: T;
56
+ channel: NotificationChannel;
57
+ enabled: boolean;
58
+ }
59
+
60
+ /** $url where it is a path on this site, else null: never `//host`, `/\\host` or a control character. */
61
+ export declare function sitePath(url: unknown): string | null;
62
+
63
+ /** An entry as the server writes one, its link kept only on this site; null for anything else. */
64
+ export declare function readEntry(value: unknown): InboxEntry | null;
65
+
66
+ /** An update as the inbox's channel says one; null for anything else. */
67
+ export declare function readUpdate(value: unknown): InboxUpdate | null;
68
+
69
+ /** A call the server refused, or that never got an answer (status 0). */
70
+ export declare class InboxError extends Error {
71
+ readonly inbox: string;
72
+ readonly status: number;
73
+ readonly body: unknown;
74
+ }
75
+
76
+ export interface InboxClient<T extends string = string> {
77
+ readonly name: string;
78
+ /** Its personal channel: `inbox.{name}`. */
79
+ readonly channel: string;
80
+ page(page?: { before?: string | null; limit?: number }): Promise<InboxPage<T>>;
81
+ markRead(ids: string[]): Promise<{ unread: number }>;
82
+ markAllRead(): Promise<{ unread: number }>;
83
+ preferences(): Promise<NotificationPreferences<T>>;
84
+ choose(choices: NotificationChoice<T>[]): Promise<NotificationPreferences<T>>;
85
+ /** Hears the feed change until the returned function is called. */
86
+ listen(
87
+ onUpdate: (update: InboxUpdate<T>) => void,
88
+ options?: Pick<ListenOptions, 'onError' | 'onGap'>,
89
+ ): () => void;
90
+ }
91
+
92
+ export interface InboxClientOptions {
93
+ fetch?: typeof fetch;
94
+ document?: { cookie?: string } | null;
95
+ /** How the personal channel is listened to: the channel runtime's listen(), unless a test hands in its own. */
96
+ listen?: typeof import('./channel.js').listen;
97
+ }
98
+
99
+ export declare function inboxClient<T extends string = string>(
100
+ name: string,
101
+ options?: InboxClientOptions,
102
+ ): InboxClient<T>;
103
+
104
+ /** Builds the typed `inbox` codegen exports: a client for each #[Inbox], by name. */
105
+ export declare function inboxes<M extends { [K in keyof M]: NotificationShape }, N extends string>(
106
+ names: N[],
107
+ options?: InboxClientOptions,
108
+ ): { [K in N]: InboxClient<keyof M & string> };