pi-roundtable-web 0.25.0 → 0.26.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/README.md +165 -12
- package/dist/account-zk4efb87.css +102 -0
- package/dist/chunk-account-ghq105t4.js +1 -0
- package/dist/chunk-action-menu-me0g9qsc.js +1 -0
- package/dist/{chunk-agent-0pm9p3nn.js → chunk-agent-rdv7ekrt.js} +1 -1
- package/dist/chunk-arranged-rooms-81rqhx9x.js +1 -0
- package/dist/chunk-character-creator-bd426yjw.js +1 -0
- package/dist/chunk-codeeditor-ajhgd33t.js +2 -0
- package/dist/chunk-confirm-p3mts8mx.js +1 -0
- package/dist/{chunk-connectors-anhghy3k.js → chunk-connectors-bkv3xn83.js} +1 -1
- package/dist/chunk-conversations-0e0vbxwr.js +1 -0
- package/dist/{chunk-drag-q4fx9b22.js → chunk-drag-peyb8t0k.js} +1 -1
- package/dist/{chunk-entry-he1embgp.js → chunk-entry-mv7q8br0.js} +1 -1
- package/dist/chunk-folder-list-era3tksh.js +1 -0
- package/dist/chunk-folds-hvws0bd5.js +1 -0
- package/dist/chunk-gate-hc7dd0fx.js +1 -0
- package/dist/chunk-group-scene-dkbpmbh5.js +1 -0
- package/dist/chunk-group-table-fqmxgdfw.js +1 -0
- package/dist/chunk-host-icons-4xde9ntk.js +1 -0
- package/dist/chunk-host-picture-014yzk20.js +1 -0
- package/dist/chunk-host-state-t56e5sdy.js +1 -0
- package/dist/chunk-host-ui-p1zy9nmp.js +1 -0
- package/dist/chunk-index-0fmmvgee.js +2 -0
- package/dist/chunk-index-0q15ysrb.js +1 -0
- package/dist/chunk-index-0xh24y25.js +2 -0
- package/dist/chunk-index-2yebd9tb.js +426 -0
- package/dist/chunk-index-4t0r3t3b.js +2 -0
- package/dist/chunk-index-5yzfrf7b.js +138 -0
- package/dist/{chunk-index-d5abvq7v.js → chunk-index-6twb7ccd.js} +2 -2
- package/dist/chunk-index-73gh7kgr.js +920 -0
- package/dist/chunk-index-7xffskfa.js +8 -0
- package/dist/chunk-index-aqw7cy5j.js +1 -0
- package/dist/chunk-index-b6ca02e8.js +2 -0
- package/dist/chunk-index-b8nvhcx5.js +2 -0
- package/dist/{chunk-index-bvfqjh77.js → chunk-index-cvbdf2ca.js} +2 -2
- package/dist/chunk-index-czdketze.js +2 -0
- package/dist/{chunk-index-9jqcskb4.js → chunk-index-dt9775ka.js} +5 -5
- package/dist/chunk-index-eg55mqqe.js +1 -0
- package/dist/chunk-index-gr6e0hse.js +2 -0
- package/dist/chunk-index-gyary7np.js +13 -0
- package/dist/chunk-index-j214tczc.js +1 -0
- package/dist/chunk-index-jd6632ef.js +2 -0
- package/dist/chunk-index-jw7dtshr.js +2 -0
- package/dist/{chunk-index-krtk6nn2.js → chunk-index-k7t14rwa.js} +2 -2
- package/dist/chunk-index-kwcx62wb.js +4 -0
- package/dist/chunk-index-m7fvw92f.js +2 -0
- package/dist/chunk-index-mmg788a1.js +2 -0
- package/dist/chunk-index-mq0dacwr.js +2 -0
- package/dist/chunk-index-p53ak25v.js +2 -0
- package/dist/{chunk-index-27qca4mr.js → chunk-index-pk1yxkv4.js} +2 -2
- package/dist/{chunk-index-9b1kt7qw.css → chunk-index-rbcz9jd4.css} +1 -1
- package/dist/{chunk-index-s681nny9.js → chunk-index-rmxbdmn2.js} +2 -2
- package/dist/chunk-index-rnxj3z8k.js +2 -0
- package/dist/chunk-index-snzzfhk3.js +2 -0
- package/dist/chunk-index-v3bmr0nw.js +2 -0
- package/dist/chunk-index-vcyzwtk2.js +2 -0
- package/dist/{chunk-index-zfd00hwb.js → chunk-index-w2j3njb6.js} +2 -2
- package/dist/chunk-index-w31c4yjv.js +2 -0
- package/dist/chunk-index-wrm8w165.js +2 -0
- package/dist/chunk-index-xh0mg9cw.js +2 -0
- package/dist/chunk-index-ybtvvx85.js +2 -0
- package/dist/chunk-index-yzsgsvnp.js +2 -0
- package/dist/{chunk-index-18gxy79n.js → chunk-index-z9x4cnzf.js} +2 -2
- package/dist/chunk-index-zrvg23jd.js +2 -0
- package/dist/chunk-index-zynq0b0w.js +50 -0
- package/dist/chunk-lang-css-88v3hp3d.js +1 -0
- package/dist/{chunk-lang-dockerfile-d5mzxs60.js → chunk-lang-dockerfile-k728ewsf.js} +1 -1
- package/dist/{chunk-lang-go-830fmhba.js → chunk-lang-go-wjwgay4v.js} +3 -3
- package/dist/chunk-lang-html-07d47es4.js +1 -0
- package/dist/chunk-lang-javascript-fmhhgzwd.js +1 -0
- package/dist/{chunk-lang-json-5eaarz7j.js → chunk-lang-json-7gd89mvs.js} +1 -1
- package/dist/chunk-lang-markdown-b6qbe8bt.js +6 -0
- package/dist/{chunk-lang-python-fjw84w10.js → chunk-lang-python-3v56y31b.js} +2 -2
- package/dist/{chunk-lang-rust-5rbp2zdk.js → chunk-lang-rust-kh0z74d1.js} +1 -1
- package/dist/{chunk-lang-shell-vzagpym3.js → chunk-lang-shell-9zv0zvbx.js} +1 -1
- package/dist/{chunk-lang-sql-0c2w9c2y.js → chunk-lang-sql-yw6bf3t7.js} +2 -2
- package/dist/{chunk-lang-toml-yr9fv6eb.js → chunk-lang-toml-tg1r96nq.js} +1 -1
- package/dist/chunk-lang-typescript-etjjvr2g.js +1 -0
- package/dist/{chunk-lang-xml-eze08p42.js → chunk-lang-xml-ev1mndd4.js} +1 -1
- package/dist/{chunk-lang-yaml-c4b6ft3y.js → chunk-lang-yaml-fz5qpb3r.js} +1 -1
- package/dist/chunk-layout-actions-48ecgtkg.js +1 -0
- package/dist/{chunk-menu-items-jrwxycr1.js → chunk-menu-items-1epy3h0t.js} +2 -2
- package/dist/chunk-mode-toggle-fxmfxsa4.js +1 -0
- package/dist/chunk-note-row-kd0fy7ee.js +75 -0
- package/dist/chunk-notes-yb8f057c.js +1 -0
- package/dist/chunk-notice-bell-wr4f1rph.js +1 -0
- package/dist/chunk-notices-x07da5bb.js +1 -0
- package/dist/chunk-onboarding-csrkbh9z.js +1 -0
- package/dist/chunk-overview-avc2d30t.js +1 -0
- package/dist/chunk-palette-9nhp95bz.js +1 -0
- package/dist/chunk-placement-items-q694dpgk.js +1 -0
- package/dist/chunk-profile-6qemf3m7.js +1 -0
- package/dist/chunk-prompt-history-7enzp2p3.js +1 -0
- package/dist/chunk-prompts-nv6mrn0q.js +1 -0
- package/dist/chunk-room-confirms-zz7n61y7.js +1 -0
- package/dist/chunk-room-menu-rr6nzap1.js +1 -0
- package/dist/chunk-rooms-menu-button-zbtwj47d.js +1 -0
- package/dist/chunk-schedules-rw57nr0m.js +1 -0
- package/dist/{chunk-seat-menu-jv4s7hmj.js → chunk-seat-menu-ys5v9abz.js} +1 -1
- package/dist/chunk-sheet-hosts-jhvaacst.js +1 -0
- package/dist/chunk-skill-editor-sz8an8qr.js +8 -0
- package/dist/chunk-skills-tnr1h16h.js +1 -0
- package/dist/chunk-switcher-list-17enhrse.js +1 -0
- package/dist/chunk-tab-dot-r6brdbmw.js +1 -0
- package/dist/chunk-table-r12cja6p.js +1 -0
- package/dist/chunk-table-seats-g3t392zz.js +1 -0
- package/dist/{chunk-table2d-4sje1fr0.js → chunk-table2d-p3ezsdcj.js} +1 -1
- package/dist/{chunk-table3d-h64skazp.js → chunk-table3d-4x2n2b00.js} +23 -23
- package/dist/chunk-thread-actions-qc6ezkha.js +1 -0
- package/dist/chunk-transcript-n2s6dher.js +1 -0
- package/dist/chunk-where-f3scdydc.js +1 -0
- package/dist/chunk-workspace-xs023bxm.js +1 -0
- package/dist/chunk-zh-tw-efvmk3fd.js +1 -0
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/package.json +5 -4
- package/src/api-types.ts +356 -4
- package/src/app/app.ts +201 -32
- package/src/app/desk.ts +47 -22
- package/src/app/files.ts +73 -7
- package/src/app/presence.ts +2 -2
- package/src/app/protocol.ts +63 -1
- package/src/app/room-log.ts +153 -14
- package/src/app/rooms.ts +39 -0
- package/src/app/routes.ts +168 -28
- package/src/app/socket.ts +349 -81
- package/src/app/sockets.ts +108 -20
- package/src/app/surface.ts +72 -36
- package/src/app/threads.ts +31 -2
- package/src/app/uploads.ts +33 -7
- package/src/auth/authenticator.ts +77 -8
- package/src/auth/login-page.ts +4 -2
- package/src/auth/next.ts +52 -0
- package/src/auth/oidc-pages.ts +62 -0
- package/src/auth/oidc.ts +649 -0
- package/src/auth/sessions.ts +193 -0
- package/src/authz.ts +173 -0
- package/src/console-api.ts +122 -25
- package/src/console-server.ts +102 -12
- package/src/gate.ts +90 -0
- package/src/host/access.ts +161 -0
- package/src/host/cards.ts +112 -0
- package/src/host/context.ts +188 -0
- package/src/host/forms.ts +240 -0
- package/src/host/frames.ts +349 -0
- package/src/host/host-app.ts +178 -0
- package/src/host/host-context.ts +49 -0
- package/src/host/host-routes.ts +396 -0
- package/src/host/pages.ts +266 -0
- package/src/host/reads.ts +155 -0
- package/src/host/retention.ts +41 -0
- package/src/host/room-views.ts +137 -0
- package/src/host/rooms-service.ts +429 -0
- package/src/host/stored-json.ts +13 -0
- package/src/host/views.ts +106 -0
- package/src/i18n/zh-tw.ts +139 -0
- package/src/index.ts +15 -1
- package/src/notices/api.ts +43 -0
- package/src/notices/mount.ts +292 -0
- package/src/notices/service.ts +294 -0
- package/src/notices/store.ts +298 -0
- package/src/notices/urls.ts +38 -0
- package/src/onboarding/api.ts +32 -0
- package/src/onboarding/service.ts +149 -0
- package/src/onboarding/steps.ts +75 -0
- package/src/onboarding/waivers.ts +40 -0
- package/src/options.ts +534 -34
- package/src/page-presentation.ts +47 -2
- package/src/profile/api.ts +175 -0
- package/src/profile/links.ts +162 -0
- package/src/push/api.ts +189 -52
- package/src/push/dispatch.ts +156 -0
- package/src/push/drain.ts +139 -0
- package/src/push/outbox.ts +218 -0
- package/src/push/prefs.ts +118 -0
- package/src/push/sender.ts +7 -2
- package/src/push/triggers.ts +56 -5
- package/src/push/vapid.ts +19 -0
- package/src/push/web-push-transport.ts +106 -0
- package/src/push-sender/index.ts +14 -0
- package/src/push-sender/run.ts +118 -0
- package/src/push-sender/session.ts +54 -0
- package/src/pwa.ts +32 -4
- package/src/visitors.ts +94 -6
- package/src/web-app.ts +718 -0
- package/src/web-plugin.ts +279 -26
- package/dist/chunk-action-menu-qe839g4b.js +0 -1
- package/dist/chunk-arranged-rooms-enpfswmg.js +0 -1
- package/dist/chunk-character-creator-6yf29xwx.js +0 -1
- package/dist/chunk-codeeditor-gdbdg7rp.js +0 -2
- package/dist/chunk-confirm-xrw7d732.js +0 -1
- package/dist/chunk-conversations-ry228aes.js +0 -1
- package/dist/chunk-folder-list-5cp8stna.js +0 -1
- package/dist/chunk-folds-hff104tk.js +0 -1
- package/dist/chunk-group-scene-h228jdat.js +0 -1
- package/dist/chunk-index-0p5h2kdz.js +0 -2
- package/dist/chunk-index-0pt08yd9.js +0 -1
- package/dist/chunk-index-12bw9yj8.js +0 -2
- package/dist/chunk-index-2ad17eh4.js +0 -2
- package/dist/chunk-index-31wh5twj.js +0 -52
- package/dist/chunk-index-4dy94pbz.js +0 -2
- package/dist/chunk-index-4ecjtdy8.js +0 -13
- package/dist/chunk-index-4v1b7ks9.js +0 -2
- package/dist/chunk-index-5d8yjj4m.js +0 -2
- package/dist/chunk-index-89ajmxh7.js +0 -2
- package/dist/chunk-index-b9ny4623.js +0 -2
- package/dist/chunk-index-e1dr6g10.js +0 -2
- package/dist/chunk-index-gpkzhq1g.js +0 -2
- package/dist/chunk-index-hk3xyy20.js +0 -2
- package/dist/chunk-index-jm23vn03.js +0 -4
- package/dist/chunk-index-k7gh0jvz.js +0 -2
- package/dist/chunk-index-ky42ev0b.js +0 -2
- package/dist/chunk-index-pa2yyqh0.js +0 -2
- package/dist/chunk-index-pcvt668q.js +0 -2
- package/dist/chunk-index-pqw27eye.js +0 -1
- package/dist/chunk-index-wmbmmrd4.js +0 -302
- package/dist/chunk-index-ymbjrsp6.js +0 -2
- package/dist/chunk-lang-css-t85r8rz0.js +0 -1
- package/dist/chunk-lang-html-n6jparge.js +0 -1
- package/dist/chunk-lang-javascript-xj00cp97.js +0 -1
- package/dist/chunk-lang-markdown-zxx8eke8.js +0 -6
- package/dist/chunk-lang-typescript-8f6yywt3.js +0 -1
- package/dist/chunk-layout-actions-bv0chj41.js +0 -1
- package/dist/chunk-notes-9b7pgqfe.js +0 -1
- package/dist/chunk-overview-8yw71esz.js +0 -1
- package/dist/chunk-palette-frms7axe.js +0 -1
- package/dist/chunk-placement-items-fvfy9m0y.js +0 -1
- package/dist/chunk-prompt-history-yrqehads.js +0 -1
- package/dist/chunk-prompts-g6609tqx.js +0 -1
- package/dist/chunk-room-confirms-0a2y0925.js +0 -1
- package/dist/chunk-room-menu-g11q97br.js +0 -1
- package/dist/chunk-rooms-menu-button-h9qge48n.js +0 -1
- package/dist/chunk-schedules-ezmacrbj.js +0 -1
- package/dist/chunk-sheet-hosts-b0azyfhb.js +0 -1
- package/dist/chunk-skill-editor-h6k6whwg.js +0 -8
- package/dist/chunk-skills-gqcfhq56.js +0 -1
- package/dist/chunk-switcher-list-mcpamqnj.js +0 -1
- package/dist/chunk-table-s8cde1xz.js +0 -1
- package/dist/chunk-table-seats-wqkjn8bc.js +0 -1
- package/dist/chunk-transcript-c59c6mpm.js +0 -1
- package/dist/chunk-workspace-0cyspkk4.js +0 -1
- package/dist/chunk-zh-tw-nc93jm10.js +0 -1
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import type { HostCardView, RoomMessage, SendWording } from "../api-types.ts";
|
|
2
|
+
import { bad, bodyOf, HttpError } from "../app/api-http.ts";
|
|
3
|
+
import { PAGE_LIMIT } from "../app/room-log.ts";
|
|
4
|
+
import { json } from "../http.ts";
|
|
5
|
+
import type {
|
|
6
|
+
ActionResult,
|
|
7
|
+
FormValues,
|
|
8
|
+
HostRoomKind,
|
|
9
|
+
RoomHeader,
|
|
10
|
+
RouteArea,
|
|
11
|
+
Viewer,
|
|
12
|
+
} from "../web-app.ts";
|
|
13
|
+
import { checkActions, safeLink } from "./forms.ts";
|
|
14
|
+
import { actInRoom, hostRoomOf } from "./frames.ts";
|
|
15
|
+
import { asRoom, type HostContext } from "./host-context.ts";
|
|
16
|
+
import {
|
|
17
|
+
cardView,
|
|
18
|
+
checkPicture,
|
|
19
|
+
readsOf,
|
|
20
|
+
withExpiredFiles,
|
|
21
|
+
} from "./room-views.ts";
|
|
22
|
+
import type {
|
|
23
|
+
HostActionResult,
|
|
24
|
+
HostHeaderView,
|
|
25
|
+
HostListView,
|
|
26
|
+
HostMessagesView,
|
|
27
|
+
HostPageView,
|
|
28
|
+
HostRoomSummaryView,
|
|
29
|
+
HostRoomView,
|
|
30
|
+
} from "./views.ts";
|
|
31
|
+
|
|
32
|
+
/** How many messages a room opens with. */
|
|
33
|
+
const OPEN_LIMIT = 50;
|
|
34
|
+
/** The largest JSON body an action reads: a form's values. */
|
|
35
|
+
const ACT_BODY_BYTES = 512 * 1024;
|
|
36
|
+
|
|
37
|
+
const nothing = () => new HttpError(404, "There is no such room.");
|
|
38
|
+
|
|
39
|
+
/** The query of a request's URL. */
|
|
40
|
+
export const queryOf = (request: Request): URLSearchParams =>
|
|
41
|
+
URL.parse(request.url)?.searchParams ?? new URLSearchParams();
|
|
42
|
+
|
|
43
|
+
/** An action's answer as the page is sent it: `open` and `url` are kept only when they are places the page may go. */
|
|
44
|
+
export function resultView(result: ActionResult): HostActionResult {
|
|
45
|
+
if (!result.ok)
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
error: String(result.error).slice(0, 1_000),
|
|
49
|
+
...(result.field === undefined ? {} : { field: result.field }),
|
|
50
|
+
};
|
|
51
|
+
const open =
|
|
52
|
+
result.open && "room" in result.open
|
|
53
|
+
? {
|
|
54
|
+
room: String(result.open.room),
|
|
55
|
+
...(result.open.message === undefined
|
|
56
|
+
? {}
|
|
57
|
+
: { message: result.open.message }),
|
|
58
|
+
}
|
|
59
|
+
: result.open && "page" in result.open
|
|
60
|
+
? {
|
|
61
|
+
page: String(result.open.page),
|
|
62
|
+
...(result.open.query ? { query: { ...result.open.query } } : {}),
|
|
63
|
+
}
|
|
64
|
+
: undefined;
|
|
65
|
+
return {
|
|
66
|
+
ok: true,
|
|
67
|
+
...(result.notice === undefined
|
|
68
|
+
? {}
|
|
69
|
+
: { notice: String(result.notice).slice(0, 1_000) }),
|
|
70
|
+
...(open ? { open } : {}),
|
|
71
|
+
...(result.url !== undefined && safeLink(result.url)
|
|
72
|
+
? { url: result.url }
|
|
73
|
+
: {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The values of an action's `input`: text, or a list of text, by field name. */
|
|
78
|
+
export function inputOf(value: unknown): FormValues {
|
|
79
|
+
if (value === undefined) return {};
|
|
80
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
81
|
+
throw bad("input must be an object.");
|
|
82
|
+
const input: Record<string, string | readonly string[]> = {};
|
|
83
|
+
for (const [name, field] of Object.entries(value)) {
|
|
84
|
+
if (typeof field === "string") input[name] = field;
|
|
85
|
+
else if (
|
|
86
|
+
Array.isArray(field) &&
|
|
87
|
+
field.every((one) => typeof one === "string")
|
|
88
|
+
)
|
|
89
|
+
input[name] = field as string[];
|
|
90
|
+
else throw bad("input holds text or lists of text.");
|
|
91
|
+
}
|
|
92
|
+
return input;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A host's header, cut down to what the page draws and checked. */
|
|
96
|
+
function headerView(header: RoomHeader): HostHeaderView {
|
|
97
|
+
checkActions(header.actions, "a room's header");
|
|
98
|
+
for (const field of header.fields)
|
|
99
|
+
if (field.link !== undefined && !safeLink(field.link))
|
|
100
|
+
throw new Error(
|
|
101
|
+
`a header link ${JSON.stringify(field.link)} must be https or a path of this origin`,
|
|
102
|
+
);
|
|
103
|
+
return {
|
|
104
|
+
title: String(header.title).slice(0, 200),
|
|
105
|
+
fields: header.fields.slice(0, 40).map((field) => ({
|
|
106
|
+
label: String(field.label).slice(0, 120),
|
|
107
|
+
value: String(field.value).slice(0, 2_000),
|
|
108
|
+
...(field.link ? { link: field.link } : {}),
|
|
109
|
+
})),
|
|
110
|
+
actions: structuredClone([...header.actions]),
|
|
111
|
+
...(header.banner
|
|
112
|
+
? {
|
|
113
|
+
banner: {
|
|
114
|
+
tone: header.banner.tone,
|
|
115
|
+
text: String(header.banner.text).slice(0, 2_000),
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
: {}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The REST of the host's rooms below `api/host/`: the list of a kind, a room as it opens, older
|
|
124
|
+
* messages, and a press on a button. The authority is the kind's `access`, asked again on every
|
|
125
|
+
* request; to anyone it gives nothing, a room, a kind and a message are all 404.
|
|
126
|
+
*/
|
|
127
|
+
export function hostArea(ctx: HostContext): RouteArea {
|
|
128
|
+
return {
|
|
129
|
+
area: "host",
|
|
130
|
+
handle: async (request, parts, { viewer }) => {
|
|
131
|
+
const [kindName, id, action] = parts;
|
|
132
|
+
const { method } = request;
|
|
133
|
+
if (!kindName) throw nothing();
|
|
134
|
+
if (!id) {
|
|
135
|
+
if (method !== "GET") throw new HttpError(405, "Method not allowed.");
|
|
136
|
+
return json(await list(ctx, viewer, kindName, request));
|
|
137
|
+
}
|
|
138
|
+
const room = `${ctx.surface}:${kindName}.${id}`;
|
|
139
|
+
if (!hostRoomOf(ctx, room)) throw nothing();
|
|
140
|
+
if (!action && method === "GET")
|
|
141
|
+
return json(await open(ctx, viewer, room));
|
|
142
|
+
if (action === "messages" && method === "GET" && parts.length === 3)
|
|
143
|
+
return json(await older(ctx, viewer, room, request));
|
|
144
|
+
if (action === "act" && method === "POST" && parts.length === 3)
|
|
145
|
+
return act(ctx, viewer, room, request);
|
|
146
|
+
throw new HttpError(404, "There is no such API.");
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function list(
|
|
152
|
+
ctx: HostContext,
|
|
153
|
+
viewer: Viewer,
|
|
154
|
+
kindName: string,
|
|
155
|
+
request: Request,
|
|
156
|
+
): Promise<HostListView> {
|
|
157
|
+
const kind = ctx.registry.kind(kindName);
|
|
158
|
+
if (!kind) throw nothing();
|
|
159
|
+
const params = queryOf(request);
|
|
160
|
+
const filter = params.get("filter");
|
|
161
|
+
const cursor = params.get("cursor");
|
|
162
|
+
const given = await kind.list(viewer, {
|
|
163
|
+
...(filter === null ? {} : { filter }),
|
|
164
|
+
...(cursor === null ? {} : { cursor }),
|
|
165
|
+
});
|
|
166
|
+
ctx.listed.set(viewer.principalId, kindName, given !== undefined);
|
|
167
|
+
if (!given) throw nothing();
|
|
168
|
+
const rooms: HostRoomSummaryView[] = [];
|
|
169
|
+
for (const summary of given.rooms) {
|
|
170
|
+
const key = `${ctx.surface}:${kindName}.${summary.id}`;
|
|
171
|
+
if (!hostRoomOf(ctx, key)) {
|
|
172
|
+
ctx.logger.warn(
|
|
173
|
+
{ kind: kindName },
|
|
174
|
+
"a room of a kind was left out of its list: its id is not 8 to 64 letters, digits, - or _",
|
|
175
|
+
);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
let picture = summary.picture;
|
|
179
|
+
if (picture) {
|
|
180
|
+
try {
|
|
181
|
+
picture = checkPicture(picture);
|
|
182
|
+
} catch {
|
|
183
|
+
picture = undefined;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
rooms.push({
|
|
187
|
+
id: summary.id,
|
|
188
|
+
key,
|
|
189
|
+
title: String(summary.title).slice(0, 200),
|
|
190
|
+
...(summary.subtitle === undefined
|
|
191
|
+
? {}
|
|
192
|
+
: { subtitle: String(summary.subtitle).slice(0, 300) }),
|
|
193
|
+
...(picture ? { picture } : {}),
|
|
194
|
+
...(summary.lastAt === undefined ? {} : { lastAt: summary.lastAt }),
|
|
195
|
+
...(summary.attention ? { attention: true } : {}),
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
// The app counts what is unread for this person; a number the host gave is replaced.
|
|
199
|
+
const unread = await ctx.reads.unread(
|
|
200
|
+
viewer.principalId,
|
|
201
|
+
rooms.map((room) => room.key),
|
|
202
|
+
);
|
|
203
|
+
return {
|
|
204
|
+
kind: kindName,
|
|
205
|
+
label: kind.label,
|
|
206
|
+
rooms: rooms.map((room) => {
|
|
207
|
+
const count = unread.get(room.key);
|
|
208
|
+
return count ? { ...room, unread: count } : room;
|
|
209
|
+
}),
|
|
210
|
+
...(given.cursor === undefined ? {} : { cursor: given.cursor }),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function headerOf(
|
|
215
|
+
ctx: HostContext,
|
|
216
|
+
kind: HostRoomKind,
|
|
217
|
+
viewer: Viewer,
|
|
218
|
+
id: string,
|
|
219
|
+
): Promise<HostHeaderView> {
|
|
220
|
+
try {
|
|
221
|
+
const header = await kind.header?.(viewer, id);
|
|
222
|
+
if (header) return headerView(header);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
ctx.logger.warn(
|
|
225
|
+
{ kind: kind.kind, err: error },
|
|
226
|
+
"a room's header could not be read",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return { title: id, fields: [], actions: [] };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function open(
|
|
233
|
+
ctx: HostContext,
|
|
234
|
+
viewer: Viewer,
|
|
235
|
+
room: string,
|
|
236
|
+
): Promise<HostRoomView> {
|
|
237
|
+
const found = hostRoomOf(ctx, room);
|
|
238
|
+
if (!found) throw nothing();
|
|
239
|
+
const access = await ctx.access.of(viewer, room);
|
|
240
|
+
if (!access) throw nothing();
|
|
241
|
+
const cursor = await ctx.reads.cursorOf(room, viewer.principalId);
|
|
242
|
+
const [header, page, stored] = await Promise.all([
|
|
243
|
+
headerOf(ctx, found.kind, viewer, found.id),
|
|
244
|
+
ctx.log.page(room, undefined, OPEN_LIMIT),
|
|
245
|
+
ctx.cards.list(room),
|
|
246
|
+
]);
|
|
247
|
+
const cards: HostCardView[] = await Promise.all(
|
|
248
|
+
stored.map((card) => cardView(viewer, found, access, card)),
|
|
249
|
+
);
|
|
250
|
+
return {
|
|
251
|
+
room,
|
|
252
|
+
kind: found.kind.kind,
|
|
253
|
+
id: found.id,
|
|
254
|
+
access: { send: [...access.send], act: access.act },
|
|
255
|
+
...wordingOf(found.kind.sendWording),
|
|
256
|
+
header,
|
|
257
|
+
messages: withExpiredFiles(page.messages, ctx.files),
|
|
258
|
+
more: page.more,
|
|
259
|
+
cards,
|
|
260
|
+
reads: await readsOf(ctx.reads, room, viewer.principalId, ctx.nameOf),
|
|
261
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The longest label and placeholder a kind may give, in characters. */
|
|
266
|
+
const LABEL_CHARS = 40;
|
|
267
|
+
const PLACEHOLDER_CHARS = 100;
|
|
268
|
+
|
|
269
|
+
const trimmed = (value: unknown, chars: number): string | undefined =>
|
|
270
|
+
typeof value === "string"
|
|
271
|
+
? Array.from(value.trim()).slice(0, chars).join("") || undefined
|
|
272
|
+
: undefined;
|
|
273
|
+
|
|
274
|
+
/** The kind's wording of the two ways to write as the page may show it: text only, cut short; nothing when it gave none. */
|
|
275
|
+
function wordingOf(wording: SendWording | undefined): {
|
|
276
|
+
wording?: SendWording;
|
|
277
|
+
} {
|
|
278
|
+
const note = trimmed(wording?.note?.placeholder, PLACEHOLDER_CHARS);
|
|
279
|
+
const label = trimmed(wording?.turn?.label, LABEL_CHARS);
|
|
280
|
+
const turn = trimmed(wording?.turn?.placeholder, PLACEHOLDER_CHARS);
|
|
281
|
+
if (!note && !label && !turn) return {};
|
|
282
|
+
return {
|
|
283
|
+
wording: {
|
|
284
|
+
...(note ? { note: { placeholder: note } } : {}),
|
|
285
|
+
...(label || turn
|
|
286
|
+
? {
|
|
287
|
+
turn: {
|
|
288
|
+
...(label ? { label } : {}),
|
|
289
|
+
...(turn ? { placeholder: turn } : {}),
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
: {}),
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function older(
|
|
298
|
+
ctx: HostContext,
|
|
299
|
+
viewer: Viewer,
|
|
300
|
+
room: string,
|
|
301
|
+
request: Request,
|
|
302
|
+
): Promise<HostMessagesView> {
|
|
303
|
+
if (!(await ctx.access.of(viewer, room))) throw nothing();
|
|
304
|
+
const params = queryOf(request);
|
|
305
|
+
const before = params.get("before");
|
|
306
|
+
const limit = params.get("limit");
|
|
307
|
+
const beforeId = before === null ? undefined : Number(before);
|
|
308
|
+
const count = limit === null ? OPEN_LIMIT : Number(limit);
|
|
309
|
+
if (
|
|
310
|
+
(beforeId !== undefined && !Number.isInteger(beforeId)) ||
|
|
311
|
+
!Number.isInteger(count) ||
|
|
312
|
+
count < 1 ||
|
|
313
|
+
count > PAGE_LIMIT
|
|
314
|
+
)
|
|
315
|
+
throw bad("before and limit must be whole numbers.");
|
|
316
|
+
const page = await ctx.log.page(room, beforeId, count);
|
|
317
|
+
const messages: RoomMessage[] = withExpiredFiles(page.messages, ctx.files);
|
|
318
|
+
return {
|
|
319
|
+
messages,
|
|
320
|
+
more: page.more,
|
|
321
|
+
reads: await readsOf(ctx.reads, room, viewer.principalId, ctx.nameOf),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async function act(
|
|
326
|
+
ctx: HostContext,
|
|
327
|
+
viewer: Viewer,
|
|
328
|
+
room: string,
|
|
329
|
+
request: Request,
|
|
330
|
+
): Promise<Response> {
|
|
331
|
+
const body = await bodyOf(request, ACT_BODY_BYTES);
|
|
332
|
+
const { action, card } = body;
|
|
333
|
+
if (typeof action !== "string" || action === "" || action.length > 200)
|
|
334
|
+
throw bad("action must be text.");
|
|
335
|
+
if (card !== undefined && (typeof card !== "string" || card.length > 200))
|
|
336
|
+
throw bad("card must be text.");
|
|
337
|
+
const outcome = await actInRoom(ctx, viewer, asRoom(room), {
|
|
338
|
+
action,
|
|
339
|
+
...(card === undefined ? {} : { card }),
|
|
340
|
+
input: inputOf(body.input),
|
|
341
|
+
});
|
|
342
|
+
if ("refused" in outcome) {
|
|
343
|
+
if (outcome.refused === "unknown") throw nothing();
|
|
344
|
+
throw new HttpError(403, "This is not yours to do.");
|
|
345
|
+
}
|
|
346
|
+
return json(resultView(outcome.result));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** The most query parameters a page is given, and the longest value. */
|
|
350
|
+
const MAX_QUERY = 20;
|
|
351
|
+
const MAX_QUERY_VALUE = 500;
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* The REST of the host's pages below `api/pages/`: a page's view and the press of one of its
|
|
355
|
+
* actions. A page the viewer may not see is 404, as `visible` says it afresh on every call.
|
|
356
|
+
*/
|
|
357
|
+
export function pagesArea(ctx: HostContext): RouteArea {
|
|
358
|
+
return {
|
|
359
|
+
area: "pages",
|
|
360
|
+
handle: async (request, parts, { viewer }) => {
|
|
361
|
+
const [id, action] = parts;
|
|
362
|
+
const { method } = request;
|
|
363
|
+
if (!id) throw new HttpError(404, "There is no such page.");
|
|
364
|
+
if (!action && method === "GET") {
|
|
365
|
+
const query: Record<string, string> = {};
|
|
366
|
+
let count = 0;
|
|
367
|
+
for (const [key, value] of queryOf(request)) {
|
|
368
|
+
count += 1;
|
|
369
|
+
if (count > MAX_QUERY || value.length > MAX_QUERY_VALUE)
|
|
370
|
+
throw bad("The page's query is too long.");
|
|
371
|
+
query[key] = value;
|
|
372
|
+
}
|
|
373
|
+
const view = await ctx.pages.view(viewer, id, query);
|
|
374
|
+
if (!view) throw new HttpError(404, "There is no such page.");
|
|
375
|
+
return json(view satisfies HostPageView);
|
|
376
|
+
}
|
|
377
|
+
if (action === "act" && method === "POST" && parts.length === 2) {
|
|
378
|
+
const body = await bodyOf(request, ACT_BODY_BYTES);
|
|
379
|
+
if (
|
|
380
|
+
typeof body.action !== "string" ||
|
|
381
|
+
body.action === "" ||
|
|
382
|
+
body.action.length > 200
|
|
383
|
+
)
|
|
384
|
+
throw bad("action must be text.");
|
|
385
|
+
const outcome = await ctx.pages.act(viewer, id, {
|
|
386
|
+
action: body.action,
|
|
387
|
+
input: inputOf(body.input),
|
|
388
|
+
});
|
|
389
|
+
if ("refused" in outcome)
|
|
390
|
+
throw new HttpError(404, "There is no such page.");
|
|
391
|
+
return json(resultView(outcome.result));
|
|
392
|
+
}
|
|
393
|
+
throw new HttpError(404, "There is no such API.");
|
|
394
|
+
},
|
|
395
|
+
};
|
|
396
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import type { EventSink, Logger } from "pi-roundtable";
|
|
2
|
+
import { PluginError } from "pi-roundtable";
|
|
3
|
+
import type { FileDir } from "../app/files.ts";
|
|
4
|
+
import { viewerOfSocket } from "../app/socket.ts";
|
|
5
|
+
import type { AppSocket, Sockets } from "../app/sockets.ts";
|
|
6
|
+
import type {
|
|
7
|
+
ActionResult,
|
|
8
|
+
FormValues,
|
|
9
|
+
HostPage,
|
|
10
|
+
HostPages,
|
|
11
|
+
PageSection,
|
|
12
|
+
Viewer,
|
|
13
|
+
WebAppRegistry,
|
|
14
|
+
} from "../web-app.ts";
|
|
15
|
+
import { checkActions, checkCard, safeLink } from "./forms.ts";
|
|
16
|
+
import { markdownImageType } from "./rooms-service.ts";
|
|
17
|
+
import type { HostPageView, PageSectionView } from "./views.ts";
|
|
18
|
+
|
|
19
|
+
/** The most sections a page has, rows a list has, and characters a text section has. */
|
|
20
|
+
const MAX_SECTIONS = 50;
|
|
21
|
+
const MAX_ROWS = 500;
|
|
22
|
+
const MAX_TEXT = 100_000;
|
|
23
|
+
const COALESCE_MS = 100;
|
|
24
|
+
|
|
25
|
+
export interface HostPagesOptions {
|
|
26
|
+
registry: Pick<WebAppRegistry, "page" | "hostPages">;
|
|
27
|
+
sockets: Pick<Sockets, "each" | "deliver">;
|
|
28
|
+
files: FileDir;
|
|
29
|
+
maxFileBytes: number;
|
|
30
|
+
events: Pick<EventSink, "action"> | undefined;
|
|
31
|
+
now(): number;
|
|
32
|
+
logger: Logger;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The file binding of an image a page keeps: the page's id after this prefix. */
|
|
36
|
+
export const PAGE_BINDING = "page:";
|
|
37
|
+
|
|
38
|
+
/** What a press on a page reached. */
|
|
39
|
+
export type PageOutcome =
|
|
40
|
+
| { result: ActionResult }
|
|
41
|
+
| { refused: "unknown" | "forbidden" };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The host's pages (`WebApp.pages`): each viewed, acted on and refreshed only for those the page's
|
|
45
|
+
* own `visible` says it is for, asked again on every call. What a page returns is checked and
|
|
46
|
+
* cut to size here, because it comes from a plugin.
|
|
47
|
+
*/
|
|
48
|
+
export class HostPagesService implements HostPages {
|
|
49
|
+
readonly #options: HostPagesOptions;
|
|
50
|
+
readonly #pending = new Map<string, ReturnType<typeof setTimeout>>();
|
|
51
|
+
|
|
52
|
+
constructor(options: HostPagesOptions) {
|
|
53
|
+
this.#options = options;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The page when the viewer may see it now; a page that cannot say is not visible. */
|
|
57
|
+
async visible(viewer: Viewer, id: string): Promise<HostPage | undefined> {
|
|
58
|
+
const page = this.#options.registry.page(id);
|
|
59
|
+
if (!page) return undefined;
|
|
60
|
+
try {
|
|
61
|
+
return (await page.visible(viewer)) ? page : undefined;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
this.#options.logger.warn(
|
|
64
|
+
{ page: id, err: error },
|
|
65
|
+
"a page's visibility was not read",
|
|
66
|
+
);
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async view(
|
|
72
|
+
viewer: Viewer,
|
|
73
|
+
id: string,
|
|
74
|
+
query: Readonly<Record<string, string>>,
|
|
75
|
+
): Promise<HostPageView | undefined> {
|
|
76
|
+
const page = await this.visible(viewer, id);
|
|
77
|
+
if (!page) return undefined;
|
|
78
|
+
const view = await page.view(viewer, query);
|
|
79
|
+
if (view.sections.length > MAX_SECTIONS)
|
|
80
|
+
throw new PluginError(
|
|
81
|
+
`web app: a page has at most ${MAX_SECTIONS} sections`,
|
|
82
|
+
);
|
|
83
|
+
return {
|
|
84
|
+
id,
|
|
85
|
+
title: String(view.title).slice(0, 200),
|
|
86
|
+
sections: view.sections.map((section) => sectionView(section, id)),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async act(
|
|
91
|
+
viewer: Viewer,
|
|
92
|
+
id: string,
|
|
93
|
+
press: { action: string; input?: FormValues },
|
|
94
|
+
): Promise<PageOutcome> {
|
|
95
|
+
const page = await this.visible(viewer, id);
|
|
96
|
+
if (!page?.act) return { refused: "unknown" };
|
|
97
|
+
let result: ActionResult;
|
|
98
|
+
try {
|
|
99
|
+
result = await page.act(viewer, press.action, press.input ?? {});
|
|
100
|
+
} catch (error) {
|
|
101
|
+
this.#options.logger.warn(
|
|
102
|
+
{ page: id, err: error },
|
|
103
|
+
"a page action failed",
|
|
104
|
+
);
|
|
105
|
+
result = { ok: false, error: "The action could not be done." };
|
|
106
|
+
}
|
|
107
|
+
this.#options.events?.action?.({
|
|
108
|
+
kind: "act",
|
|
109
|
+
actor: viewer.speakerId,
|
|
110
|
+
principalId: viewer.principalId,
|
|
111
|
+
channel: `page:${id}`,
|
|
112
|
+
ok: result.ok,
|
|
113
|
+
at: new Date(this.#options.now()),
|
|
114
|
+
});
|
|
115
|
+
return { result };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
changed(id?: string): void {
|
|
119
|
+
const ids =
|
|
120
|
+
id === undefined
|
|
121
|
+
? this.#options.registry.hostPages().map((page) => page.id)
|
|
122
|
+
: [id];
|
|
123
|
+
for (const page of ids) {
|
|
124
|
+
if (this.#pending.has(page)) continue;
|
|
125
|
+
// Calls that come together tell the viewers once.
|
|
126
|
+
this.#pending.set(
|
|
127
|
+
page,
|
|
128
|
+
setTimeout(() => {
|
|
129
|
+
this.#pending.delete(page);
|
|
130
|
+
this.#tell(page);
|
|
131
|
+
}, COALESCE_MS),
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#tell(id: string): void {
|
|
137
|
+
const { sockets } = this.#options;
|
|
138
|
+
sockets.each(
|
|
139
|
+
(connection) => connection.started === true,
|
|
140
|
+
(connection: AppSocket) => {
|
|
141
|
+
const viewer = viewerOfSocket(connection);
|
|
142
|
+
this.visible(viewer, id).then(
|
|
143
|
+
(page) => {
|
|
144
|
+
if (page)
|
|
145
|
+
sockets.deliver(
|
|
146
|
+
{ type: "page_changed", page: id },
|
|
147
|
+
{ connection },
|
|
148
|
+
);
|
|
149
|
+
},
|
|
150
|
+
() => undefined,
|
|
151
|
+
);
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async saveAsset(
|
|
157
|
+
page: string,
|
|
158
|
+
file: { name: string; type: string; bytes: Uint8Array | ReadableStream },
|
|
159
|
+
): Promise<string> {
|
|
160
|
+
if (!this.#options.registry.page(page))
|
|
161
|
+
throw new PluginError(`web app: there is no page ${page}`);
|
|
162
|
+
const chunks: Uint8Array[] = [];
|
|
163
|
+
if (file.bytes instanceof Uint8Array) chunks.push(file.bytes);
|
|
164
|
+
else {
|
|
165
|
+
const reader = file.bytes.getReader();
|
|
166
|
+
for (;;) {
|
|
167
|
+
const { done, value } = await reader.read();
|
|
168
|
+
if (done) break;
|
|
169
|
+
chunks.push(value as Uint8Array);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const size = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
|
|
173
|
+
if (size === 0 || size > this.#options.maxFileBytes)
|
|
174
|
+
throw new PluginError(
|
|
175
|
+
`web app: a page image is 1 to ${this.#options.maxFileBytes} bytes`,
|
|
176
|
+
);
|
|
177
|
+
const bytes = new Uint8Array(size);
|
|
178
|
+
let at = 0;
|
|
179
|
+
for (const chunk of chunks) {
|
|
180
|
+
bytes.set(chunk, at);
|
|
181
|
+
at += chunk.byteLength;
|
|
182
|
+
}
|
|
183
|
+
const type = markdownImageType(bytes);
|
|
184
|
+
if (!type)
|
|
185
|
+
throw new PluginError("web app: a page image is a PNG, JPEG or WebP");
|
|
186
|
+
return this.#options.files.put(file.name, type, bytes, undefined, {
|
|
187
|
+
room: `${PAGE_BINDING}${page}`,
|
|
188
|
+
}).id;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
stop(): void {
|
|
192
|
+
for (const timer of this.#pending.values()) clearTimeout(timer);
|
|
193
|
+
this.#pending.clear();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function cut(text: string): string {
|
|
198
|
+
return text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) : text;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function sectionView(section: PageSection, page: string): PageSectionView {
|
|
202
|
+
switch (section.kind) {
|
|
203
|
+
case "markdown":
|
|
204
|
+
return { kind: "markdown", text: cut(String(section.text)) };
|
|
205
|
+
case "list": {
|
|
206
|
+
if (section.rows.length > MAX_ROWS)
|
|
207
|
+
throw new PluginError(`web app: a list has at most ${MAX_ROWS} rows`);
|
|
208
|
+
return {
|
|
209
|
+
kind: "list",
|
|
210
|
+
...(section.title === undefined
|
|
211
|
+
? {}
|
|
212
|
+
: { title: String(section.title).slice(0, 200) }),
|
|
213
|
+
rows: section.rows.map((row) => {
|
|
214
|
+
if (row.open !== undefined && !safeLink(row.open))
|
|
215
|
+
throw new PluginError(
|
|
216
|
+
`web app: a row's open ${JSON.stringify(row.open)} must be https or a path of this origin`,
|
|
217
|
+
);
|
|
218
|
+
return {
|
|
219
|
+
title: String(row.title).slice(0, 300),
|
|
220
|
+
...(row.subtitle === undefined
|
|
221
|
+
? {}
|
|
222
|
+
: { subtitle: String(row.subtitle).slice(0, 500) }),
|
|
223
|
+
...(row.open === undefined ? {} : { open: row.open }),
|
|
224
|
+
...(row.attention ? { attention: true } : {}),
|
|
225
|
+
...(row.actions?.length
|
|
226
|
+
? {
|
|
227
|
+
actions: structuredClone([
|
|
228
|
+
...checkActions(row.actions, `page ${page}`),
|
|
229
|
+
]),
|
|
230
|
+
}
|
|
231
|
+
: {}),
|
|
232
|
+
};
|
|
233
|
+
}),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
case "form":
|
|
237
|
+
checkActions(
|
|
238
|
+
[{ id: "form", label: "form", form: section.form }],
|
|
239
|
+
`page ${page}`,
|
|
240
|
+
);
|
|
241
|
+
return {
|
|
242
|
+
kind: "form",
|
|
243
|
+
form: structuredClone(section.form),
|
|
244
|
+
action: String(section.action),
|
|
245
|
+
};
|
|
246
|
+
case "card": {
|
|
247
|
+
checkCard(section.card);
|
|
248
|
+
const { title, body, tone, state, links, buttons } = section.card;
|
|
249
|
+
return {
|
|
250
|
+
kind: "card",
|
|
251
|
+
card: {
|
|
252
|
+
title,
|
|
253
|
+
body,
|
|
254
|
+
...(tone ? { tone } : {}),
|
|
255
|
+
...(state ? { state } : {}),
|
|
256
|
+
...(links?.length
|
|
257
|
+
? { links: links.map((link) => ({ ...link })) }
|
|
258
|
+
: {}),
|
|
259
|
+
...(buttons?.length && (state === undefined || state === "open")
|
|
260
|
+
? { buttons: structuredClone([...buttons]) }
|
|
261
|
+
: {}),
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|