pi-roundtable-web 0.24.2 → 0.26.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 +48 -0
- package/README.md +195 -16
- package/dist/account-zk4efb87.css +102 -0
- package/dist/chunk-account-hw49gd0d.js +1 -0
- package/dist/chunk-action-menu-rm5cyqkj.js +1 -0
- package/dist/chunk-agent-b017vswv.js +1 -0
- package/dist/chunk-arranged-rooms-q5v3dm1r.js +1 -0
- package/dist/chunk-character-creator-5dxcvd16.js +1 -0
- package/dist/chunk-codeeditor-pyt09fwg.js +2 -0
- package/dist/chunk-confirm-521yqmta.js +1 -0
- package/dist/chunk-connectors-hrm06nx8.js +1 -0
- package/dist/chunk-conversations-e26rrf01.js +1 -0
- package/dist/chunk-drag-v41gx2mc.js +1 -0
- package/dist/chunk-entry-jxxb3ath.js +1 -0
- package/dist/chunk-folder-list-b97xk12b.js +1 -0
- package/dist/chunk-folds-b8q7eags.js +1 -0
- package/dist/chunk-gate-8k61stsc.js +1 -0
- package/dist/chunk-group-scene-dx6wt19m.js +1 -0
- package/dist/chunk-group-table-e4jr8r3a.js +1 -0
- package/dist/chunk-host-state-g7e7c0k5.js +1 -0
- package/dist/chunk-host-ui-x7k6n07v.js +939 -0
- package/dist/chunk-index-014rjv9k.js +2 -0
- package/dist/chunk-index-2d0kg096.js +2 -0
- package/dist/chunk-index-2qd81wbx.js +2 -0
- package/dist/chunk-index-2stynxw6.css +1 -0
- package/dist/chunk-index-459drsft.js +2 -0
- package/dist/chunk-index-4mfj596y.js +2 -0
- package/dist/chunk-index-66nskrdj.js +47 -0
- package/dist/chunk-index-awx6nnta.js +2 -0
- package/dist/chunk-index-bbsytycb.js +2 -0
- package/dist/chunk-index-c07p8k0x.js +4 -0
- package/dist/chunk-index-cq1e9xdz.js +2 -0
- package/dist/chunk-index-d1prgfbp.js +2 -0
- package/dist/chunk-index-dhpt8qr9.js +2 -0
- package/dist/chunk-index-e5sd30xy.js +2 -0
- package/dist/chunk-index-g351921r.js +1 -0
- package/dist/chunk-index-hby0ap91.js +2 -0
- package/dist/chunk-index-hssjbr1k.js +2 -0
- package/dist/chunk-index-k2qq1gdj.js +2 -0
- package/dist/chunk-index-ke9ynm0n.js +2 -0
- package/dist/chunk-index-km40bhsz.js +2 -0
- package/dist/chunk-index-kpscvg85.js +2 -0
- package/dist/chunk-index-m0d100mg.js +2 -0
- package/dist/chunk-index-mb72snk9.js +2 -0
- package/dist/chunk-index-mjqqver5.js +4 -0
- package/dist/chunk-index-nqfkwkfp.js +2 -0
- package/dist/chunk-index-nwqk447w.js +2 -0
- package/dist/chunk-index-pcfhpm8v.js +302 -0
- package/dist/chunk-index-q8h8xzbm.js +2 -0
- package/dist/chunk-index-r5vszgfs.js +2 -0
- package/dist/{chunk-index-mpm90318.js → chunk-index-rhtvcbsq.js} +2 -2
- package/dist/chunk-index-rjv7vrwd.js +13 -0
- package/dist/chunk-index-v23m38p6.js +50 -0
- package/dist/chunk-index-va0g7d88.js +1 -0
- package/dist/{chunk-index-6t68gbex.js → chunk-index-wnjp6wv0.js} +2 -2
- package/dist/chunk-index-xgkd15n3.js +2 -0
- package/dist/chunk-index-y8rnq8zf.js +14 -0
- package/dist/chunk-index-z3k18atc.js +2 -0
- package/dist/chunk-lang-css-6bepqpnx.js +1 -0
- package/dist/{chunk-lang-dockerfile-ey0nvhhm.js → chunk-lang-dockerfile-hrav55t7.js} +1 -1
- package/dist/{chunk-lang-go-403qsw9f.js → chunk-lang-go-x9ahv6e5.js} +3 -3
- package/dist/chunk-lang-html-8jc72zt4.js +1 -0
- package/dist/chunk-lang-javascript-2p2qtbnf.js +1 -0
- package/dist/chunk-lang-json-hsemyd0r.js +1 -0
- package/dist/chunk-lang-markdown-zkwb4c9n.js +6 -0
- package/dist/{chunk-lang-python-9jk5a49y.js → chunk-lang-python-tewx7006.js} +2 -2
- package/dist/chunk-lang-rust-0ydx84s1.js +1 -0
- package/dist/{chunk-lang-shell-cgq6q0kj.js → chunk-lang-shell-gq1tmdz4.js} +1 -1
- package/dist/chunk-lang-sql-xt1qyycg.js +2 -0
- package/dist/{chunk-lang-toml-xh45nm68.js → chunk-lang-toml-0hamc5fc.js} +1 -1
- package/dist/chunk-lang-typescript-82xf7z2e.js +1 -0
- package/dist/{chunk-lang-xml-1mh879j9.js → chunk-lang-xml-wf6h5pt2.js} +1 -1
- package/dist/{chunk-lang-yaml-jac98srt.js → chunk-lang-yaml-7vk521h1.js} +1 -1
- package/dist/chunk-layout-actions-zh7bezk7.js +1 -0
- package/dist/chunk-menu-items-1n9qargb.js +2 -0
- package/dist/chunk-notes-t15bh04m.js +1 -0
- package/dist/chunk-notice-bell-w3y0h0sr.js +1 -0
- package/dist/chunk-notices-fa3g7qze.js +1 -0
- package/dist/chunk-onboarding-34v3nr17.js +1 -0
- package/dist/chunk-overview-km5vq375.js +1 -0
- package/dist/chunk-palette-a8txdgd0.js +1 -0
- package/dist/chunk-placement-items-wgaxsbvs.js +1 -0
- package/dist/chunk-profile-ypxdbsk1.js +1 -0
- package/dist/chunk-prompt-history-wm9dsy6s.js +1 -0
- package/dist/chunk-prompts-7mhhq7zs.js +1 -0
- package/dist/chunk-room-confirms-t11a1gsw.js +1 -0
- package/dist/chunk-room-menu-2tm2xtfx.js +1 -0
- package/dist/chunk-rooms-menu-button-wxnsxjwc.js +1 -0
- package/dist/chunk-schedules-2ft6xxyw.js +1 -0
- package/dist/chunk-seat-menu-d3npdtx1.js +1 -0
- package/dist/chunk-sheet-hosts-vngvp7wv.js +1 -0
- package/dist/chunk-skill-editor-65n2hsq4.js +8 -0
- package/dist/chunk-skills-shkdwes8.js +1 -0
- package/dist/chunk-switcher-list-g6hhmr1t.js +1 -0
- package/dist/chunk-table-1b6bsq2y.js +1 -0
- package/dist/chunk-table-seats-vy1qhdks.js +1 -0
- package/dist/chunk-table2d-n26b7gd0.js +1 -0
- package/dist/chunk-table3d-8aysyf56.js +4220 -0
- package/dist/chunk-transcript-355faceq.js +1 -0
- package/dist/chunk-where-j5qdt8a4.js +1 -0
- package/dist/chunk-workspace-vrbs6zx1.js +1 -0
- package/dist/chunk-zh-tw-zeg600bk.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 +515 -6
- package/src/app/api-http.ts +66 -0
- package/src/app/app.ts +214 -33
- package/src/app/card-log.ts +6 -0
- package/src/app/desk.ts +47 -22
- package/src/app/files.ts +73 -7
- package/src/app/layout-routes.ts +239 -0
- package/src/app/layout.ts +133 -0
- package/src/app/presence.ts +2 -2
- package/src/app/prompt-routes.ts +167 -0
- package/src/app/protocol.ts +63 -1
- package/src/app/room-log.ts +148 -14
- package/src/app/rooms.ts +23 -0
- package/src/app/routes.ts +278 -156
- package/src/app/skill-doc-routes.ts +122 -0
- package/src/app/socket.ts +309 -77
- package/src/app/sockets.ts +108 -20
- package/src/app/surface.ts +72 -36
- package/src/app/team-routes.ts +457 -0
- package/src/app/threads.ts +14 -0
- package/src/app/uploads.ts +33 -7
- package/src/auth/authenticator.ts +71 -8
- package/src/auth/next.ts +52 -0
- package/src/auth/oidc-pages.ts +60 -0
- package/src/auth/oidc.ts +611 -0
- package/src/auth/sessions.ts +193 -0
- package/src/authz.ts +172 -0
- package/src/console-api.ts +122 -25
- package/src/console-server.ts +70 -6
- package/src/gate.ts +90 -0
- package/src/host/access.ts +161 -0
- package/src/host/cards.ts +99 -0
- package/src/host/context.ts +141 -0
- package/src/host/forms.ts +237 -0
- package/src/host/frames.ts +342 -0
- package/src/host/host-app.ts +176 -0
- package/src/host/host-context.ts +49 -0
- package/src/host/host-routes.ts +363 -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 +414 -0
- package/src/host/stored-json.ts +13 -0
- package/src/host/views.ts +103 -0
- package/src/i18n/zh-tw.ts +309 -10
- package/src/layout-order.ts +210 -0
- 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 +498 -34
- 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/mutes.ts +17 -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/visitors.ts +94 -6
- package/src/web-app.ts +674 -0
- package/src/web-plugin.ts +293 -26
- package/dist/chunk-action-menu-7v5fce2v.js +0 -1
- package/dist/chunk-agent-2rw4ydw2.js +0 -1
- package/dist/chunk-agent-sheet-2q24dkkf.js +0 -3
- package/dist/chunk-character-creator-8q6ympxj.js +0 -1
- package/dist/chunk-codeeditor-fgmq298t.js +0 -2
- package/dist/chunk-connectors-mr53r7fh.js +0 -1
- package/dist/chunk-conversations-4vy2z6w9.js +0 -1
- package/dist/chunk-entry-jg1eem82.js +0 -1
- package/dist/chunk-group-scene-na95b1te.js +0 -1
- package/dist/chunk-index-11rjg8a8.js +0 -1
- package/dist/chunk-index-1y50d6sj.js +0 -2
- package/dist/chunk-index-5y2r0eaj.js +0 -47
- package/dist/chunk-index-7fphxxzd.js +0 -14
- package/dist/chunk-index-80f1zkp6.css +0 -1
- package/dist/chunk-index-87h45frg.js +0 -2
- package/dist/chunk-index-bnwmssyn.js +0 -2
- package/dist/chunk-index-ckgbszwt.js +0 -51
- package/dist/chunk-index-fmjjekhd.js +0 -2
- package/dist/chunk-index-t1m65yn6.js +0 -13
- package/dist/chunk-index-t711t17m.js +0 -2
- package/dist/chunk-index-txv7qvyy.js +0 -2
- package/dist/chunk-index-wr3dg24d.js +0 -2
- package/dist/chunk-index-yx683d5n.js +0 -2
- package/dist/chunk-lang-css-fkdg3x8x.js +0 -1
- package/dist/chunk-lang-html-v8h9g977.js +0 -1
- package/dist/chunk-lang-javascript-ykb2vhaa.js +0 -1
- package/dist/chunk-lang-json-2rze3rpy.js +0 -1
- package/dist/chunk-lang-markdown-6w2htnfc.js +0 -6
- package/dist/chunk-lang-rust-24fggcqx.js +0 -1
- package/dist/chunk-lang-sql-gjywb9ke.js +0 -2
- package/dist/chunk-lang-typescript-qh6rebyb.js +0 -1
- package/dist/chunk-menu-items-zjpyj51b.js +0 -2
- package/dist/chunk-notes-sjba7kw3.js +0 -1
- package/dist/chunk-overview-26r77kb6.js +0 -1
- package/dist/chunk-room-confirms-851184ep.js +0 -1
- package/dist/chunk-room-menu-8y9c026y.js +0 -1
- package/dist/chunk-skills-56s4hdbn.js +0 -1
- package/dist/chunk-table-qykpvp32.js +0 -1
- package/dist/chunk-table2d-k60ressj.js +0 -1
- package/dist/chunk-table3d-bv2ajk39.js +0 -4220
- package/dist/chunk-transcript-vekz1mh6.js +0 -1
- package/dist/chunk-workspace-hevefd0d.js +0 -1
- package/dist/chunk-zh-tw-5r2wjre0.js +0 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { Migration } from "pi-roundtable";
|
|
3
|
+
|
|
4
|
+
/** Where one person has read a room up to. */
|
|
5
|
+
export interface ReadMark {
|
|
6
|
+
principalId: string;
|
|
7
|
+
/** The id of the newest message they have read. */
|
|
8
|
+
upTo: number;
|
|
9
|
+
/** ISO time. */
|
|
10
|
+
at: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface MarkRow {
|
|
14
|
+
principal_id: string;
|
|
15
|
+
last_read_id: string | number | bigint;
|
|
16
|
+
read_at: Date;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The read cursors, one per person and room, kept in `web_room_reads`. A cursor only moves
|
|
21
|
+
* forward: reading an older message again, or a second device that is behind, changes nothing.
|
|
22
|
+
* A message counts as unread for a person when it is newer than their cursor and is not a line the
|
|
23
|
+
* server wrote (a divider, a yolo approval), which is nobody's to read.
|
|
24
|
+
*/
|
|
25
|
+
export class RoomReads {
|
|
26
|
+
readonly #sql: SQL;
|
|
27
|
+
|
|
28
|
+
constructor(sql: SQL) {
|
|
29
|
+
this.#sql = sql;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
static readonly migration: Migration = {
|
|
33
|
+
name: "web-room-reads",
|
|
34
|
+
up: async (sql) => {
|
|
35
|
+
await sql`
|
|
36
|
+
CREATE TABLE IF NOT EXISTS web_room_reads (
|
|
37
|
+
room text NOT NULL,
|
|
38
|
+
principal_id text NOT NULL,
|
|
39
|
+
last_read_id bigint NOT NULL,
|
|
40
|
+
read_at timestamptz NOT NULL DEFAULT now(),
|
|
41
|
+
PRIMARY KEY (room, principal_id)
|
|
42
|
+
)`;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Moves the person's cursor in the room up to `upTo`, no further than the room's newest
|
|
48
|
+
* message. The mark when the cursor moved, undefined when it stayed where it was.
|
|
49
|
+
*/
|
|
50
|
+
async advance(
|
|
51
|
+
room: string,
|
|
52
|
+
principalId: string,
|
|
53
|
+
upTo: number,
|
|
54
|
+
): Promise<ReadMark | undefined> {
|
|
55
|
+
const [row]: MarkRow[] = await this.#sql`
|
|
56
|
+
INSERT INTO web_room_reads (room, principal_id, last_read_id)
|
|
57
|
+
SELECT ${room}, ${principalId}, LEAST(${upTo}::bigint, newest.id)
|
|
58
|
+
FROM (SELECT COALESCE(max(id), 0) AS id FROM web_room_messages WHERE room = ${room}) AS newest
|
|
59
|
+
WHERE newest.id > 0
|
|
60
|
+
ON CONFLICT (room, principal_id) DO UPDATE
|
|
61
|
+
SET last_read_id = EXCLUDED.last_read_id, read_at = now()
|
|
62
|
+
WHERE web_room_reads.last_read_id < EXCLUDED.last_read_id
|
|
63
|
+
RETURNING principal_id, last_read_id, read_at`;
|
|
64
|
+
return row && markOf(row);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The person's cursor in the room, or undefined when they never read it. */
|
|
68
|
+
async cursorOf(
|
|
69
|
+
room: string,
|
|
70
|
+
principalId: string,
|
|
71
|
+
): Promise<number | undefined> {
|
|
72
|
+
const [row]: { last_read_id: string | number | bigint }[] = await this.#sql`
|
|
73
|
+
SELECT last_read_id FROM web_room_reads
|
|
74
|
+
WHERE room = ${room} AND principal_id = ${principalId}`;
|
|
75
|
+
return row === undefined ? undefined : Number(row.last_read_id);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Everyone's cursor in the room: who has read up to where. */
|
|
79
|
+
async marks(room: string): Promise<ReadMark[]> {
|
|
80
|
+
const rows: MarkRow[] = await this.#sql`
|
|
81
|
+
SELECT principal_id, last_read_id, read_at FROM web_room_reads
|
|
82
|
+
WHERE room = ${room} ORDER BY read_at`;
|
|
83
|
+
return rows.map(markOf);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* How many messages each room holds past the person's cursor. A person who never read a room
|
|
88
|
+
* has every message to read, unless `baseline` names the room: its cursor is then set to the
|
|
89
|
+
* newest message the first time it is asked, so what was there before they could have read
|
|
90
|
+
* anything counts as read. Rooms with nothing unread are left out.
|
|
91
|
+
*/
|
|
92
|
+
async unread(
|
|
93
|
+
principalId: string,
|
|
94
|
+
rooms: readonly string[],
|
|
95
|
+
baseline: ReadonlySet<string> = new Set(),
|
|
96
|
+
): Promise<Map<string, number>> {
|
|
97
|
+
const counts = new Map<string, number>();
|
|
98
|
+
if (rooms.length === 0) return counts;
|
|
99
|
+
const start = rooms.filter((room) => baseline.has(room));
|
|
100
|
+
if (start.length > 0) {
|
|
101
|
+
await this.#sql`
|
|
102
|
+
INSERT INTO web_room_reads (room, principal_id, last_read_id)
|
|
103
|
+
SELECT room, ${principalId}, max(id) FROM web_room_messages
|
|
104
|
+
WHERE room IN ${this.#sql(start)} GROUP BY room
|
|
105
|
+
ON CONFLICT (room, principal_id) DO NOTHING`;
|
|
106
|
+
// A room with nothing in it yet starts at the beginning, so what is first said there is unread.
|
|
107
|
+
const held: { room: string }[] = await this.#sql`
|
|
108
|
+
SELECT room FROM web_room_reads
|
|
109
|
+
WHERE principal_id = ${principalId} AND room IN ${this.#sql(start)}`;
|
|
110
|
+
const have = new Set(held.map((row) => row.room));
|
|
111
|
+
const empty = start
|
|
112
|
+
.filter((room) => !have.has(room))
|
|
113
|
+
.map((room) => ({
|
|
114
|
+
room,
|
|
115
|
+
principal_id: principalId,
|
|
116
|
+
last_read_id: 0,
|
|
117
|
+
}));
|
|
118
|
+
if (empty.length > 0)
|
|
119
|
+
await this.#sql`
|
|
120
|
+
INSERT INTO web_room_reads ${this.#sql(empty)}
|
|
121
|
+
ON CONFLICT (room, principal_id) DO NOTHING`;
|
|
122
|
+
}
|
|
123
|
+
const rows: { room: string; unread: string | number | bigint }[] =
|
|
124
|
+
await this.#sql`
|
|
125
|
+
SELECT m.room, count(*) AS unread
|
|
126
|
+
FROM web_room_messages m
|
|
127
|
+
LEFT JOIN web_room_reads r ON r.room = m.room AND r.principal_id = ${principalId}
|
|
128
|
+
WHERE m.room IN ${this.#sql(rooms)} AND m.kind <> 'system'
|
|
129
|
+
AND m.id > COALESCE(r.last_read_id, 0)
|
|
130
|
+
GROUP BY m.room`;
|
|
131
|
+
for (const row of rows) counts.set(row.room, Number(row.unread));
|
|
132
|
+
return counts;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Forgets the cursors of rooms that are gone. */
|
|
136
|
+
async removeRoom(room: string): Promise<void> {
|
|
137
|
+
await this.#sql`DELETE FROM web_room_reads WHERE room = ${room}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Forgets the cursors of rooms of a kind whose messages are all gone. */
|
|
141
|
+
async sweepOrphans(prefix: string): Promise<void> {
|
|
142
|
+
await this.#sql`
|
|
143
|
+
DELETE FROM web_room_reads r
|
|
144
|
+
WHERE starts_with(r.room, ${prefix})
|
|
145
|
+
AND NOT EXISTS (SELECT 1 FROM web_room_messages m WHERE m.room = r.room)`;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function markOf(row: MarkRow): ReadMark {
|
|
150
|
+
return {
|
|
151
|
+
principalId: row.principal_id,
|
|
152
|
+
upTo: Number(row.last_read_id),
|
|
153
|
+
at: row.read_at.toISOString(),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { HostContext } from "./host-context.ts";
|
|
2
|
+
|
|
3
|
+
const DAY_MS = 86_400_000;
|
|
4
|
+
|
|
5
|
+
/** What one sweep of the host kinds' retention removed. */
|
|
6
|
+
export interface HostSweep {
|
|
7
|
+
messages: number;
|
|
8
|
+
cards: number;
|
|
9
|
+
files: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Deletes what the registered kinds no longer keep: for a kind with `retention`, its messages and
|
|
14
|
+
* cards older than `textDays` (counted from the message's own time, so an import dated long ago
|
|
15
|
+
* goes at once), and the files those messages carried. Files have their own expiry, which the
|
|
16
|
+
* stored files' sweep applies; a message whose files went first keeps its row and says "file expired".
|
|
17
|
+
*/
|
|
18
|
+
export async function sweepHostKinds(
|
|
19
|
+
ctx: Pick<
|
|
20
|
+
HostContext,
|
|
21
|
+
"registry" | "surface" | "log" | "cards" | "reads" | "files" | "now"
|
|
22
|
+
>,
|
|
23
|
+
): Promise<HostSweep> {
|
|
24
|
+
const total: HostSweep = { messages: 0, cards: 0, files: 0 };
|
|
25
|
+
for (const kind of ctx.registry.kinds()) {
|
|
26
|
+
if (!kind.retention) continue;
|
|
27
|
+
const prefix = `${ctx.surface}:${kind.kind}.`;
|
|
28
|
+
const before = new Date(ctx.now() - kind.retention.textDays * DAY_MS);
|
|
29
|
+
const gone = await ctx.log.sweepKind(prefix, before);
|
|
30
|
+
total.messages += gone.length;
|
|
31
|
+
for (const message of gone)
|
|
32
|
+
for (const file of message.files)
|
|
33
|
+
if (ctx.files.binding(file.id)?.room === message.room) {
|
|
34
|
+
ctx.files.remove(file.id);
|
|
35
|
+
total.files += 1;
|
|
36
|
+
}
|
|
37
|
+
total.cards += await ctx.cards.sweepKind(prefix, before);
|
|
38
|
+
await ctx.reads.sweepOrphans(prefix);
|
|
39
|
+
}
|
|
40
|
+
return total;
|
|
41
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { ChannelKey } from "pi-roundtable";
|
|
2
|
+
import type {
|
|
3
|
+
HostCardView,
|
|
4
|
+
PictureRef,
|
|
5
|
+
ReadMarkView,
|
|
6
|
+
RoomMessage,
|
|
7
|
+
} from "../api-types.ts";
|
|
8
|
+
import type { FileDir } from "../app/files.ts";
|
|
9
|
+
import type {
|
|
10
|
+
HostRoomKind,
|
|
11
|
+
RoomAccess,
|
|
12
|
+
StoredHostCard,
|
|
13
|
+
Viewer,
|
|
14
|
+
} from "../web-app.ts";
|
|
15
|
+
import { pressable } from "./forms.ts";
|
|
16
|
+
import type { RoomReads } from "./reads.ts";
|
|
17
|
+
|
|
18
|
+
const FILE_ID =
|
|
19
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
20
|
+
const COLOR = /^#[0-9a-fA-F]{6}$/;
|
|
21
|
+
|
|
22
|
+
/** Throws when a picture is not one the page may draw: a stored file, a person, or initials. */
|
|
23
|
+
export function checkPicture(picture: PictureRef): PictureRef {
|
|
24
|
+
const bad = (): never => {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"a picture is a stored file, a principal, or initials with a #rrggbb color; never an outside URL",
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
if (typeof picture !== "object" || picture === null) return bad();
|
|
30
|
+
if ("file" in picture)
|
|
31
|
+
return typeof picture.file === "string" && FILE_ID.test(picture.file)
|
|
32
|
+
? { file: picture.file }
|
|
33
|
+
: bad();
|
|
34
|
+
if ("principal" in picture)
|
|
35
|
+
return typeof picture.principal === "string" &&
|
|
36
|
+
picture.principal.length > 0 &&
|
|
37
|
+
picture.principal.length <= 200
|
|
38
|
+
? { principal: picture.principal }
|
|
39
|
+
: bad();
|
|
40
|
+
if ("initials" in picture)
|
|
41
|
+
return typeof picture.initials === "string" &&
|
|
42
|
+
picture.initials.length >= 1 &&
|
|
43
|
+
picture.initials.length <= 4 &&
|
|
44
|
+
typeof picture.color === "string" &&
|
|
45
|
+
COLOR.test(picture.color)
|
|
46
|
+
? { initials: picture.initials, color: picture.color }
|
|
47
|
+
: bad();
|
|
48
|
+
return bad();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The messages as the page is shown them: a file whose bytes retention deleted is taken out of the
|
|
53
|
+
* message's files and counted in `expiredFiles`, so the page says "file expired" instead of
|
|
54
|
+
* offering a download that answers 404.
|
|
55
|
+
*/
|
|
56
|
+
export function withExpiredFiles(
|
|
57
|
+
messages: readonly RoomMessage[],
|
|
58
|
+
files: Pick<FileDir, "has">,
|
|
59
|
+
): RoomMessage[] {
|
|
60
|
+
return messages.map((message) => {
|
|
61
|
+
if (message.files.length === 0) return message;
|
|
62
|
+
const present = message.files.filter((file) => files.has(file.id));
|
|
63
|
+
const gone = message.files.length - present.length;
|
|
64
|
+
return gone === 0
|
|
65
|
+
? message
|
|
66
|
+
: { ...message, files: present, expiredFiles: gone };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A host room a key names. */
|
|
71
|
+
export interface FoundRoom {
|
|
72
|
+
kind: HostRoomKind;
|
|
73
|
+
id: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The buttons of a host card this viewer may press: none once the card is settled, none without
|
|
78
|
+
* `access.act`, and otherwise the card's own, narrowed by the kind's `buttons` when it has one.
|
|
79
|
+
*/
|
|
80
|
+
export function buttonsFor(
|
|
81
|
+
viewer: Viewer,
|
|
82
|
+
found: FoundRoom,
|
|
83
|
+
access: RoomAccess,
|
|
84
|
+
card: StoredHostCard,
|
|
85
|
+
): NonNullable<StoredHostCard["buttons"]> {
|
|
86
|
+
const all = card.buttons ?? [];
|
|
87
|
+
if (!access.act || !pressable(card) || all.length === 0) return [];
|
|
88
|
+
if (!found.kind.buttons) return all;
|
|
89
|
+
const allowed = new Set(found.kind.buttons(viewer, found.id, card));
|
|
90
|
+
return all.filter((button) => allowed.has(button.id));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A host card as one viewer sees it. */
|
|
94
|
+
export function cardView(
|
|
95
|
+
viewer: Viewer,
|
|
96
|
+
found: FoundRoom,
|
|
97
|
+
access: RoomAccess,
|
|
98
|
+
card: StoredHostCard,
|
|
99
|
+
): HostCardView {
|
|
100
|
+
const buttons = buttonsFor(viewer, found, access, card);
|
|
101
|
+
return {
|
|
102
|
+
id: card.id,
|
|
103
|
+
room: card.room,
|
|
104
|
+
title: card.title,
|
|
105
|
+
body: card.body,
|
|
106
|
+
...(card.tone ? { tone: card.tone } : {}),
|
|
107
|
+
...(card.state ? { state: card.state } : {}),
|
|
108
|
+
...(card.links?.length
|
|
109
|
+
? { links: card.links.map((link) => ({ ...link })) }
|
|
110
|
+
: {}),
|
|
111
|
+
...(buttons.length
|
|
112
|
+
? { buttons: buttons.map((button) => structuredClone(button)) }
|
|
113
|
+
: {}),
|
|
114
|
+
at: card.createdAt.toISOString(),
|
|
115
|
+
updatedAt: card.updatedAt.toISOString(),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Where the others have read a room up to, each with a name; the viewer's own mark is left out. */
|
|
120
|
+
export async function readsOf(
|
|
121
|
+
reads: Pick<RoomReads, "marks">,
|
|
122
|
+
room: ChannelKey | string,
|
|
123
|
+
viewerPrincipal: string,
|
|
124
|
+
nameOf: (principalId: string) => Promise<string>,
|
|
125
|
+
): Promise<ReadMarkView[]> {
|
|
126
|
+
const marks = (await reads.marks(room)).filter(
|
|
127
|
+
(mark) => mark.principalId !== viewerPrincipal,
|
|
128
|
+
);
|
|
129
|
+
return Promise.all(
|
|
130
|
+
marks.map(async (mark) => ({
|
|
131
|
+
principal: mark.principalId,
|
|
132
|
+
name: await nameOf(mark.principalId),
|
|
133
|
+
upTo: mark.upTo,
|
|
134
|
+
at: mark.at,
|
|
135
|
+
})),
|
|
136
|
+
);
|
|
137
|
+
}
|