pi-roundtable-web 0.25.0 → 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.
Files changed (222) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +145 -8
  3. package/dist/account-zk4efb87.css +102 -0
  4. package/dist/chunk-account-hw49gd0d.js +1 -0
  5. package/dist/chunk-action-menu-rm5cyqkj.js +1 -0
  6. package/dist/{chunk-agent-0pm9p3nn.js → chunk-agent-b017vswv.js} +1 -1
  7. package/dist/chunk-arranged-rooms-q5v3dm1r.js +1 -0
  8. package/dist/chunk-character-creator-5dxcvd16.js +1 -0
  9. package/dist/chunk-codeeditor-pyt09fwg.js +2 -0
  10. package/dist/chunk-confirm-521yqmta.js +1 -0
  11. package/dist/{chunk-connectors-anhghy3k.js → chunk-connectors-hrm06nx8.js} +1 -1
  12. package/dist/chunk-conversations-e26rrf01.js +1 -0
  13. package/dist/{chunk-drag-q4fx9b22.js → chunk-drag-v41gx2mc.js} +1 -1
  14. package/dist/{chunk-entry-he1embgp.js → chunk-entry-jxxb3ath.js} +1 -1
  15. package/dist/{chunk-folder-list-5cp8stna.js → chunk-folder-list-b97xk12b.js} +1 -1
  16. package/dist/chunk-folds-b8q7eags.js +1 -0
  17. package/dist/chunk-gate-8k61stsc.js +1 -0
  18. package/dist/chunk-group-scene-dx6wt19m.js +1 -0
  19. package/dist/chunk-group-table-e4jr8r3a.js +1 -0
  20. package/dist/chunk-host-state-g7e7c0k5.js +1 -0
  21. package/dist/chunk-host-ui-x7k6n07v.js +939 -0
  22. package/dist/{chunk-index-bvfqjh77.js → chunk-index-014rjv9k.js} +2 -2
  23. package/dist/{chunk-index-d5abvq7v.js → chunk-index-2d0kg096.js} +2 -2
  24. package/dist/chunk-index-2qd81wbx.js +2 -0
  25. package/dist/{chunk-index-9b1kt7qw.css → chunk-index-2stynxw6.css} +1 -1
  26. package/dist/{chunk-index-gpkzhq1g.js → chunk-index-459drsft.js} +2 -2
  27. package/dist/chunk-index-4mfj596y.js +2 -0
  28. package/dist/{chunk-index-18gxy79n.js → chunk-index-66nskrdj.js} +2 -2
  29. package/dist/chunk-index-awx6nnta.js +2 -0
  30. package/dist/chunk-index-bbsytycb.js +2 -0
  31. package/dist/chunk-index-c07p8k0x.js +4 -0
  32. package/dist/chunk-index-cq1e9xdz.js +2 -0
  33. package/dist/chunk-index-d1prgfbp.js +2 -0
  34. package/dist/chunk-index-dhpt8qr9.js +2 -0
  35. package/dist/chunk-index-e5sd30xy.js +2 -0
  36. package/dist/chunk-index-g351921r.js +1 -0
  37. package/dist/chunk-index-hby0ap91.js +2 -0
  38. package/dist/chunk-index-hssjbr1k.js +2 -0
  39. package/dist/chunk-index-k2qq1gdj.js +2 -0
  40. package/dist/chunk-index-ke9ynm0n.js +2 -0
  41. package/dist/{chunk-index-s681nny9.js → chunk-index-km40bhsz.js} +2 -2
  42. package/dist/chunk-index-kpscvg85.js +2 -0
  43. package/dist/chunk-index-m0d100mg.js +2 -0
  44. package/dist/chunk-index-mb72snk9.js +2 -0
  45. package/dist/chunk-index-mjqqver5.js +4 -0
  46. package/dist/{chunk-index-zfd00hwb.js → chunk-index-nqfkwkfp.js} +2 -2
  47. package/dist/chunk-index-nwqk447w.js +2 -0
  48. package/dist/{chunk-index-wmbmmrd4.js → chunk-index-pcfhpm8v.js} +4 -4
  49. package/dist/chunk-index-q8h8xzbm.js +2 -0
  50. package/dist/chunk-index-r5vszgfs.js +2 -0
  51. package/dist/{chunk-index-27qca4mr.js → chunk-index-rhtvcbsq.js} +2 -2
  52. package/dist/chunk-index-rjv7vrwd.js +13 -0
  53. package/dist/chunk-index-v23m38p6.js +50 -0
  54. package/dist/chunk-index-va0g7d88.js +1 -0
  55. package/dist/{chunk-index-krtk6nn2.js → chunk-index-wnjp6wv0.js} +2 -2
  56. package/dist/chunk-index-xgkd15n3.js +2 -0
  57. package/dist/{chunk-index-9jqcskb4.js → chunk-index-y8rnq8zf.js} +5 -5
  58. package/dist/chunk-index-z3k18atc.js +2 -0
  59. package/dist/chunk-lang-css-6bepqpnx.js +1 -0
  60. package/dist/{chunk-lang-dockerfile-d5mzxs60.js → chunk-lang-dockerfile-hrav55t7.js} +1 -1
  61. package/dist/{chunk-lang-go-830fmhba.js → chunk-lang-go-x9ahv6e5.js} +3 -3
  62. package/dist/chunk-lang-html-8jc72zt4.js +1 -0
  63. package/dist/chunk-lang-javascript-2p2qtbnf.js +1 -0
  64. package/dist/{chunk-lang-json-5eaarz7j.js → chunk-lang-json-hsemyd0r.js} +1 -1
  65. package/dist/chunk-lang-markdown-zkwb4c9n.js +6 -0
  66. package/dist/{chunk-lang-python-fjw84w10.js → chunk-lang-python-tewx7006.js} +2 -2
  67. package/dist/{chunk-lang-rust-5rbp2zdk.js → chunk-lang-rust-0ydx84s1.js} +1 -1
  68. package/dist/{chunk-lang-shell-vzagpym3.js → chunk-lang-shell-gq1tmdz4.js} +1 -1
  69. package/dist/{chunk-lang-sql-0c2w9c2y.js → chunk-lang-sql-xt1qyycg.js} +2 -2
  70. package/dist/{chunk-lang-toml-yr9fv6eb.js → chunk-lang-toml-0hamc5fc.js} +1 -1
  71. package/dist/chunk-lang-typescript-82xf7z2e.js +1 -0
  72. package/dist/{chunk-lang-xml-eze08p42.js → chunk-lang-xml-wf6h5pt2.js} +1 -1
  73. package/dist/{chunk-lang-yaml-c4b6ft3y.js → chunk-lang-yaml-7vk521h1.js} +1 -1
  74. package/dist/chunk-layout-actions-zh7bezk7.js +1 -0
  75. package/dist/{chunk-menu-items-jrwxycr1.js → chunk-menu-items-1n9qargb.js} +2 -2
  76. package/dist/chunk-notes-t15bh04m.js +1 -0
  77. package/dist/chunk-notice-bell-w3y0h0sr.js +1 -0
  78. package/dist/chunk-notices-fa3g7qze.js +1 -0
  79. package/dist/chunk-onboarding-34v3nr17.js +1 -0
  80. package/dist/chunk-overview-km5vq375.js +1 -0
  81. package/dist/chunk-palette-a8txdgd0.js +1 -0
  82. package/dist/chunk-placement-items-wgaxsbvs.js +1 -0
  83. package/dist/chunk-profile-ypxdbsk1.js +1 -0
  84. package/dist/chunk-prompt-history-wm9dsy6s.js +1 -0
  85. package/dist/chunk-prompts-7mhhq7zs.js +1 -0
  86. package/dist/chunk-room-confirms-t11a1gsw.js +1 -0
  87. package/dist/chunk-room-menu-2tm2xtfx.js +1 -0
  88. package/dist/chunk-rooms-menu-button-wxnsxjwc.js +1 -0
  89. package/dist/chunk-schedules-2ft6xxyw.js +1 -0
  90. package/dist/{chunk-seat-menu-jv4s7hmj.js → chunk-seat-menu-d3npdtx1.js} +1 -1
  91. package/dist/chunk-sheet-hosts-vngvp7wv.js +1 -0
  92. package/dist/chunk-skill-editor-65n2hsq4.js +8 -0
  93. package/dist/chunk-skills-shkdwes8.js +1 -0
  94. package/dist/chunk-switcher-list-g6hhmr1t.js +1 -0
  95. package/dist/chunk-table-1b6bsq2y.js +1 -0
  96. package/dist/chunk-table-seats-vy1qhdks.js +1 -0
  97. package/dist/{chunk-table2d-4sje1fr0.js → chunk-table2d-n26b7gd0.js} +1 -1
  98. package/dist/{chunk-table3d-h64skazp.js → chunk-table3d-8aysyf56.js} +12 -12
  99. package/dist/chunk-transcript-355faceq.js +1 -0
  100. package/dist/chunk-where-j5qdt8a4.js +1 -0
  101. package/dist/chunk-workspace-vrbs6zx1.js +1 -0
  102. package/dist/chunk-zh-tw-zeg600bk.js +1 -0
  103. package/dist/index.html +1 -1
  104. package/dist/sw.js +1 -1
  105. package/package.json +5 -4
  106. package/src/api-types.ts +301 -4
  107. package/src/app/app.ts +196 -32
  108. package/src/app/desk.ts +47 -22
  109. package/src/app/files.ts +73 -7
  110. package/src/app/presence.ts +2 -2
  111. package/src/app/protocol.ts +63 -1
  112. package/src/app/room-log.ts +141 -14
  113. package/src/app/routes.ts +124 -26
  114. package/src/app/socket.ts +309 -77
  115. package/src/app/sockets.ts +108 -20
  116. package/src/app/surface.ts +72 -36
  117. package/src/app/uploads.ts +33 -7
  118. package/src/auth/authenticator.ts +71 -8
  119. package/src/auth/next.ts +52 -0
  120. package/src/auth/oidc-pages.ts +60 -0
  121. package/src/auth/oidc.ts +611 -0
  122. package/src/auth/sessions.ts +193 -0
  123. package/src/authz.ts +172 -0
  124. package/src/console-api.ts +122 -25
  125. package/src/console-server.ts +70 -6
  126. package/src/gate.ts +90 -0
  127. package/src/host/access.ts +161 -0
  128. package/src/host/cards.ts +99 -0
  129. package/src/host/context.ts +141 -0
  130. package/src/host/forms.ts +237 -0
  131. package/src/host/frames.ts +342 -0
  132. package/src/host/host-app.ts +176 -0
  133. package/src/host/host-context.ts +49 -0
  134. package/src/host/host-routes.ts +363 -0
  135. package/src/host/pages.ts +266 -0
  136. package/src/host/reads.ts +155 -0
  137. package/src/host/retention.ts +41 -0
  138. package/src/host/room-views.ts +137 -0
  139. package/src/host/rooms-service.ts +414 -0
  140. package/src/host/stored-json.ts +13 -0
  141. package/src/host/views.ts +103 -0
  142. package/src/i18n/zh-tw.ts +132 -0
  143. package/src/notices/api.ts +43 -0
  144. package/src/notices/mount.ts +292 -0
  145. package/src/notices/service.ts +294 -0
  146. package/src/notices/store.ts +298 -0
  147. package/src/notices/urls.ts +38 -0
  148. package/src/onboarding/api.ts +32 -0
  149. package/src/onboarding/service.ts +149 -0
  150. package/src/onboarding/steps.ts +75 -0
  151. package/src/onboarding/waivers.ts +40 -0
  152. package/src/options.ts +498 -34
  153. package/src/profile/api.ts +175 -0
  154. package/src/profile/links.ts +162 -0
  155. package/src/push/api.ts +189 -52
  156. package/src/push/dispatch.ts +156 -0
  157. package/src/push/drain.ts +139 -0
  158. package/src/push/outbox.ts +218 -0
  159. package/src/push/prefs.ts +118 -0
  160. package/src/push/sender.ts +7 -2
  161. package/src/push/triggers.ts +56 -5
  162. package/src/push/vapid.ts +19 -0
  163. package/src/push/web-push-transport.ts +106 -0
  164. package/src/push-sender/index.ts +14 -0
  165. package/src/push-sender/run.ts +118 -0
  166. package/src/push-sender/session.ts +54 -0
  167. package/src/visitors.ts +94 -6
  168. package/src/web-app.ts +674 -0
  169. package/src/web-plugin.ts +268 -26
  170. package/dist/chunk-action-menu-qe839g4b.js +0 -1
  171. package/dist/chunk-arranged-rooms-enpfswmg.js +0 -1
  172. package/dist/chunk-character-creator-6yf29xwx.js +0 -1
  173. package/dist/chunk-codeeditor-gdbdg7rp.js +0 -2
  174. package/dist/chunk-confirm-xrw7d732.js +0 -1
  175. package/dist/chunk-conversations-ry228aes.js +0 -1
  176. package/dist/chunk-folds-hff104tk.js +0 -1
  177. package/dist/chunk-group-scene-h228jdat.js +0 -1
  178. package/dist/chunk-index-0p5h2kdz.js +0 -2
  179. package/dist/chunk-index-0pt08yd9.js +0 -1
  180. package/dist/chunk-index-12bw9yj8.js +0 -2
  181. package/dist/chunk-index-2ad17eh4.js +0 -2
  182. package/dist/chunk-index-31wh5twj.js +0 -52
  183. package/dist/chunk-index-4dy94pbz.js +0 -2
  184. package/dist/chunk-index-4ecjtdy8.js +0 -13
  185. package/dist/chunk-index-4v1b7ks9.js +0 -2
  186. package/dist/chunk-index-5d8yjj4m.js +0 -2
  187. package/dist/chunk-index-89ajmxh7.js +0 -2
  188. package/dist/chunk-index-b9ny4623.js +0 -2
  189. package/dist/chunk-index-e1dr6g10.js +0 -2
  190. package/dist/chunk-index-hk3xyy20.js +0 -2
  191. package/dist/chunk-index-jm23vn03.js +0 -4
  192. package/dist/chunk-index-k7gh0jvz.js +0 -2
  193. package/dist/chunk-index-ky42ev0b.js +0 -2
  194. package/dist/chunk-index-pa2yyqh0.js +0 -2
  195. package/dist/chunk-index-pcvt668q.js +0 -2
  196. package/dist/chunk-index-pqw27eye.js +0 -1
  197. package/dist/chunk-index-ymbjrsp6.js +0 -2
  198. package/dist/chunk-lang-css-t85r8rz0.js +0 -1
  199. package/dist/chunk-lang-html-n6jparge.js +0 -1
  200. package/dist/chunk-lang-javascript-xj00cp97.js +0 -1
  201. package/dist/chunk-lang-markdown-zxx8eke8.js +0 -6
  202. package/dist/chunk-lang-typescript-8f6yywt3.js +0 -1
  203. package/dist/chunk-layout-actions-bv0chj41.js +0 -1
  204. package/dist/chunk-notes-9b7pgqfe.js +0 -1
  205. package/dist/chunk-overview-8yw71esz.js +0 -1
  206. package/dist/chunk-palette-frms7axe.js +0 -1
  207. package/dist/chunk-placement-items-fvfy9m0y.js +0 -1
  208. package/dist/chunk-prompt-history-yrqehads.js +0 -1
  209. package/dist/chunk-prompts-g6609tqx.js +0 -1
  210. package/dist/chunk-room-confirms-0a2y0925.js +0 -1
  211. package/dist/chunk-room-menu-g11q97br.js +0 -1
  212. package/dist/chunk-rooms-menu-button-h9qge48n.js +0 -1
  213. package/dist/chunk-schedules-ezmacrbj.js +0 -1
  214. package/dist/chunk-sheet-hosts-b0azyfhb.js +0 -1
  215. package/dist/chunk-skill-editor-h6k6whwg.js +0 -8
  216. package/dist/chunk-skills-gqcfhq56.js +0 -1
  217. package/dist/chunk-switcher-list-mcpamqnj.js +0 -1
  218. package/dist/chunk-table-s8cde1xz.js +0 -1
  219. package/dist/chunk-table-seats-wqkjn8bc.js +0 -1
  220. package/dist/chunk-transcript-c59c6mpm.js +0 -1
  221. package/dist/chunk-workspace-0cyspkk4.js +0 -1
  222. package/dist/chunk-zh-tw-nc93jm10.js +0 -1
@@ -0,0 +1,414 @@
1
+ import type { ChannelKey, Logger } from "pi-roundtable";
2
+ import { PluginError } from "pi-roundtable";
3
+ import type { ServerFrame } from "../api-types.ts";
4
+ import { MAX_MESSAGE_CHARS } from "../api-types.ts";
5
+ import { type FileDir, isFileId, safeName } from "../app/files.ts";
6
+ import type { RoomLog } from "../app/room-log.ts";
7
+ import { viewerOfSocket } from "../app/socket.ts";
8
+ import type { AppSocket, Sockets } from "../app/sockets.ts";
9
+ import { imageTypeOf } from "../app/uploads.ts";
10
+ import type {
11
+ HostCard,
12
+ HostRoomKind,
13
+ HostRooms,
14
+ WebAppRegistry,
15
+ } from "../web-app.ts";
16
+ import type { HostAccess } from "./access.ts";
17
+ import type { HostCardLog } from "./cards.ts";
18
+ import { checkCard } from "./forms.ts";
19
+ import { cardView, checkPicture, type FoundRoom } from "./room-views.ts";
20
+
21
+ /** The most files one post carries. */
22
+ const MAX_POST_FILES = 20;
23
+ const AUTHOR_KINDS = new Set(["external", "member", "assistant", "notice"]);
24
+ const DAY_MS = 86_400_000;
25
+
26
+ export interface HostRoomsOptions {
27
+ registry: Pick<WebAppRegistry, "hostKindOf" | "kinds">;
28
+ sockets: Pick<Sockets, "each" | "deliver" | "reaches">;
29
+ log: Pick<RoomLog, "appendOnce" | "edit" | "remove" | "byReference" | "get">;
30
+ cards: Pick<HostCardLog, "create" | "update" | "get">;
31
+ /** The stored files, where `saveFile` keeps what a host saves. */
32
+ files: FileDir;
33
+ access: Pick<HostAccess, "forget" | "of">;
34
+ /** The most bytes of one saved file. */
35
+ maxFileBytes: number;
36
+ /** The same-origin URL of a room, and of one of its messages. */
37
+ roomUrl(room: ChannelKey, message?: number): string;
38
+ /** Which kinds a person's last list showed, so the list's changes reach only them. */
39
+ listed: ListedKinds;
40
+ now?: () => number;
41
+ logger: Logger;
42
+ }
43
+
44
+ /** What each person's latest `list` of each kind answered: whether it was defined for them. */
45
+ export class ListedKinds {
46
+ readonly #by = new Map<string, Set<string>>();
47
+
48
+ set(principalId: string, kind: string, defined: boolean): void {
49
+ const kinds = this.#by.get(principalId) ?? new Set<string>();
50
+ if (defined) kinds.add(kind);
51
+ else kinds.delete(kind);
52
+ this.#by.set(principalId, kinds);
53
+ }
54
+
55
+ has(principalId: string, kind: string): boolean {
56
+ return this.#by.get(principalId)?.has(kind) ?? false;
57
+ }
58
+ }
59
+
60
+ /** Collects a file's bytes, refusing more than `max`. */
61
+ async function bytesOf(
62
+ source: Uint8Array | ReadableStream,
63
+ max: number,
64
+ ): Promise<Uint8Array> {
65
+ if (source instanceof Uint8Array) {
66
+ if (source.byteLength > max)
67
+ throw new PluginError(`web app: a saved file is at most ${max} bytes`);
68
+ return source;
69
+ }
70
+ const chunks: Uint8Array[] = [];
71
+ let size = 0;
72
+ const reader = source.getReader();
73
+ for (;;) {
74
+ const { done, value } = await reader.read();
75
+ if (done) break;
76
+ const chunk = value as Uint8Array;
77
+ size += chunk.byteLength;
78
+ if (size > max) {
79
+ await reader.cancel();
80
+ throw new PluginError(`web app: a saved file is at most ${max} bytes`);
81
+ }
82
+ chunks.push(chunk);
83
+ }
84
+ const whole = new Uint8Array(size);
85
+ let at = 0;
86
+ for (const chunk of chunks) {
87
+ whole.set(chunk, at);
88
+ at += chunk.byteLength;
89
+ }
90
+ return whole;
91
+ }
92
+
93
+ /**
94
+ * What the app's stored file keeps as the type of a host's file: an image type only when the bytes
95
+ * are that image (the page shows those inline), anything else the host declared as given.
96
+ */
97
+ export function keptType(declared: string, bytes: Uint8Array): string {
98
+ const image = imageTypeOf(bytes.subarray(0, 16));
99
+ if (image) return image;
100
+ return declared.startsWith("image/") ? "application/octet-stream" : declared;
101
+ }
102
+
103
+ /** The PNG, JPEG or WebP the bytes are, if they are one: the images host Markdown may show. */
104
+ export function markdownImageType(bytes: Uint8Array): string | undefined {
105
+ const type = imageTypeOf(bytes.subarray(0, 16));
106
+ return type === "image/gif" ? undefined : type;
107
+ }
108
+
109
+ /**
110
+ * What a host writes into its rooms (`WebApp.rooms`): messages, their edits and removals, cards,
111
+ * and the files they carry. Everything the host passes is checked here, because it comes from
112
+ * a plugin, not from a person; what is stored is then shown, at once, to the sockets that follow
113
+ * the room and may read it now.
114
+ */
115
+ export class HostRoomsService implements HostRooms {
116
+ readonly #options: HostRoomsOptions;
117
+ /** One delivery at a time per room, so frames of quick changes arrive in the order they were made. */
118
+ readonly #chains = new Map<string, Promise<void>>();
119
+
120
+ constructor(options: HostRoomsOptions) {
121
+ this.#options = options;
122
+ }
123
+
124
+ #now(): number {
125
+ return (this.#options.now ?? Date.now)();
126
+ }
127
+
128
+ #found(room: string): FoundRoom {
129
+ const found = this.#options.registry.hostKindOf(room);
130
+ if (!found)
131
+ throw new PluginError(
132
+ `web app: ${JSON.stringify(room)} is not a room of a registered kind`,
133
+ );
134
+ return found;
135
+ }
136
+
137
+ /** When a file saved in a room of this kind goes, counted from `from`; undefined when the kind keeps files. */
138
+ expiryOf(kind: HostRoomKind, from: number): number | undefined {
139
+ return kind.retention ? from + kind.retention.fileDays * DAY_MS : undefined;
140
+ }
141
+
142
+ // ── Messages ──
143
+
144
+ async post(
145
+ room: ChannelKey,
146
+ m: Parameters<HostRooms["post"]>[1],
147
+ ): Promise<{ id: number }> {
148
+ const { kind } = this.#found(room);
149
+ const { author } = m;
150
+ if (!AUTHOR_KINDS.has(author.kind))
151
+ throw new PluginError(
152
+ "web app: a message's author is external, member, assistant or notice",
153
+ );
154
+ if (
155
+ typeof author.name !== "string" ||
156
+ author.name.trim() === "" ||
157
+ author.name.length > 120
158
+ )
159
+ throw new PluginError(
160
+ "web app: an author needs a name of at most 120 characters",
161
+ );
162
+ if (
163
+ author.label !== undefined &&
164
+ (typeof author.label !== "string" || author.label.length > 40)
165
+ )
166
+ throw new PluginError(
167
+ "web app: an author's label is at most 40 characters",
168
+ );
169
+ const picture = author.picture ? checkPicture(author.picture) : undefined;
170
+ const text = m.text ?? "";
171
+ if (typeof text !== "string" || text.length > MAX_MESSAGE_CHARS)
172
+ throw new PluginError(
173
+ `web app: a message's text is at most ${MAX_MESSAGE_CHARS} characters`,
174
+ );
175
+ const ids = m.files ?? [];
176
+ if (ids.length > MAX_POST_FILES || new Set(ids).size !== ids.length)
177
+ throw new PluginError(
178
+ `web app: a message carries at most ${MAX_POST_FILES} different files`,
179
+ );
180
+ if (text === "" && ids.length === 0)
181
+ throw new PluginError("web app: a message needs text or a file");
182
+ if (
183
+ m.reference !== undefined &&
184
+ (typeof m.reference !== "string" ||
185
+ m.reference === "" ||
186
+ m.reference.length > 300)
187
+ )
188
+ throw new PluginError("web app: a reference is 1 to 300 characters");
189
+ const files = ids.map((id) => {
190
+ const meta = this.#options.files.meta(id);
191
+ if (
192
+ !isFileId(id) ||
193
+ !meta ||
194
+ this.#options.files.binding(id)?.room !== room
195
+ )
196
+ throw new PluginError(
197
+ `web app: file ${id} was not saved in ${room}; save it with rooms.saveFile for this room`,
198
+ );
199
+ return meta;
200
+ });
201
+ const now = this.#now();
202
+ const at =
203
+ m.at && !Number.isNaN(m.at.getTime())
204
+ ? new Date(Math.min(m.at.getTime(), now))
205
+ : undefined;
206
+ const stored = await this.#options.log.appendOnce({
207
+ room,
208
+ // A person's words are `owner` lines, the assistant's an `agent`'s, a notice a `system` line.
209
+ kind:
210
+ author.kind === "assistant"
211
+ ? "agent"
212
+ : author.kind === "notice"
213
+ ? "system"
214
+ : "owner",
215
+ ...(author.principalId ? { principalId: author.principalId } : {}),
216
+ author: author.name.trim(),
217
+ text,
218
+ files,
219
+ authorKind: author.kind,
220
+ ...(author.label ? { authorLabel: author.label } : {}),
221
+ ...(picture ? { picture } : {}),
222
+ ...(m.reference ? { reference: m.reference } : {}),
223
+ ...(at ? { at } : {}),
224
+ });
225
+ if (!stored.created) return { id: stored.message.id };
226
+ // A file lives as long as the kind keeps files, counted from when the message was written.
227
+ if (at)
228
+ for (const file of files)
229
+ this.#options.files.bind(file.id, {
230
+ room,
231
+ ...(this.expiryOf(kind, at.getTime()) === undefined
232
+ ? {}
233
+ : { expiresAt: this.expiryOf(kind, at.getTime()) as number }),
234
+ });
235
+ this.#deliver(room, {
236
+ type: "host_message",
237
+ room,
238
+ message: stored.message,
239
+ });
240
+ this.#listChanged(kind.kind);
241
+ return { id: stored.message.id };
242
+ }
243
+
244
+ async edit(room: ChannelKey, id: number, text: string): Promise<void> {
245
+ this.#found(room);
246
+ if (typeof text !== "string" || text.length > MAX_MESSAGE_CHARS)
247
+ throw new PluginError(
248
+ `web app: a message's text is at most ${MAX_MESSAGE_CHARS} characters`,
249
+ );
250
+ const message = await this.#options.log.edit(room, id, text, true);
251
+ if (!message)
252
+ throw new PluginError(`web app: ${room} has no message ${id}`);
253
+ this.#deliver(room, { type: "host_edited", room, message });
254
+ }
255
+
256
+ async remove(room: ChannelKey, id: number): Promise<void> {
257
+ const { kind } = this.#found(room);
258
+ const row = await this.#options.log.remove(room, id);
259
+ if (!row) return;
260
+ for (const file of row.files)
261
+ if (this.#options.files.binding(file.id)?.room === room)
262
+ this.#options.files.remove(file.id);
263
+ this.#deliver(room, { type: "host_removed", room, id });
264
+ this.#listChanged(kind.kind);
265
+ }
266
+
267
+ byReference(
268
+ room: ChannelKey,
269
+ reference: string,
270
+ ): Promise<number | undefined> {
271
+ this.#found(room);
272
+ return this.#options.log.byReference(room, reference);
273
+ }
274
+
275
+ // ── Cards ──
276
+
277
+ async card(room: ChannelKey, card: HostCard): Promise<{ id: string }> {
278
+ this.#found(room);
279
+ checkCard(card);
280
+ const stored = await this.#options.cards.create(room, card);
281
+ this.#showCard(room, stored.id);
282
+ return { id: stored.id };
283
+ }
284
+
285
+ async updateCard(
286
+ room: ChannelKey,
287
+ id: string,
288
+ card: HostCard,
289
+ ): Promise<void> {
290
+ this.#found(room);
291
+ checkCard(card);
292
+ const stored = await this.#options.cards.update(room, id, card);
293
+ if (!stored) throw new PluginError(`web app: ${room} has no card ${id}`);
294
+ this.#showCard(room, id);
295
+ }
296
+
297
+ /** Shows a card to the room's readers, each its own buttons. */
298
+ #showCard(room: ChannelKey, id: string): void {
299
+ const found = this.#found(room);
300
+ this.#chain(room, async () => {
301
+ const stored = await this.#options.cards.get(room, id);
302
+ if (!stored) return;
303
+ const targets: AppSocket[] = [];
304
+ this.#options.sockets.each(
305
+ (connection) => this.#options.sockets.reaches(connection, { room }),
306
+ (connection) => targets.push(connection),
307
+ );
308
+ for (const connection of targets) {
309
+ const viewer = viewerOfSocket(connection);
310
+ const access = await this.#options.access.of(viewer, room);
311
+ if (!access) continue;
312
+ this.#options.sockets.deliver(
313
+ {
314
+ type: "host_card",
315
+ room,
316
+ card: cardView(viewer, found, access, stored),
317
+ },
318
+ { connection, audience: { room } },
319
+ );
320
+ }
321
+ });
322
+ }
323
+
324
+ // ── Files ──
325
+
326
+ async saveFile(
327
+ room: ChannelKey,
328
+ file: { name: string; type: string; bytes: Uint8Array | ReadableStream },
329
+ ): Promise<string> {
330
+ const { kind } = this.#found(room);
331
+ if (typeof file.type !== "string" || file.type === "")
332
+ throw new PluginError("web app: a saved file needs a type");
333
+ const bytes = await bytesOf(file.bytes, this.#options.maxFileBytes);
334
+ if (bytes.byteLength === 0)
335
+ throw new PluginError("web app: a saved file is not empty");
336
+ const expiresAt = this.expiryOf(kind, this.#now());
337
+ return this.#options.files.put(
338
+ safeName(file.name),
339
+ keptType(file.type, bytes),
340
+ bytes,
341
+ undefined,
342
+ { room, ...(expiresAt === undefined ? {} : { expiresAt }) },
343
+ ).id;
344
+ }
345
+
346
+ // ── What changed ──
347
+
348
+ link(room: ChannelKey, message?: number): string {
349
+ this.#found(room);
350
+ if (message !== undefined && !Number.isSafeInteger(message))
351
+ throw new PluginError("web app: a message number is a whole number");
352
+ return this.#options.roomUrl(room, message);
353
+ }
354
+
355
+ changed(room?: ChannelKey): void {
356
+ if (room === undefined) {
357
+ for (const kind of this.#options.registry.kinds())
358
+ this.#listChanged(kind.kind);
359
+ this.#options.access.forget({}).catch(() => undefined);
360
+ return;
361
+ }
362
+ const { kind } = this.#found(room);
363
+ // The followers are asked again first, so only those who still read the room are told to look again.
364
+ this.#chain(room, async () => {
365
+ await this.#options.access.forget({ room });
366
+ this.#deliver(room, { type: "room_changed", room }, false);
367
+ });
368
+ this.#listChanged(kind.kind);
369
+ }
370
+
371
+ accessChanged(principalId?: string): void {
372
+ this.#options.access
373
+ .forget(principalId === undefined ? {} : { principalId })
374
+ .catch(() => undefined);
375
+ // Their lists may differ now.
376
+ for (const kind of this.#options.registry.kinds())
377
+ this.#listChanged(kind.kind);
378
+ }
379
+
380
+ /** Tells those whose list of a kind was defined that it may differ; the frame holds nothing. */
381
+ #listChanged(kind: string): void {
382
+ const { sockets, listed } = this.#options;
383
+ sockets.each(
384
+ (connection) => listed.has(connection.principal.id, kind),
385
+ (connection) =>
386
+ sockets.deliver({ type: "list_changed", kind }, { connection }),
387
+ );
388
+ }
389
+
390
+ /** Puts a frame of a room on the sockets that follow it and may read it now, in order after earlier ones. */
391
+ #deliver(room: string, frame: ServerFrame, queued = true): void {
392
+ const send = async () => {
393
+ this.#options.sockets.deliver(frame, { room });
394
+ };
395
+ if (queued) this.#chain(room, send);
396
+ else send().catch(() => undefined);
397
+ }
398
+
399
+ #chain(room: string, step: () => Promise<void>): void {
400
+ const previous = this.#chains.get(room) ?? Promise.resolve();
401
+ const next = previous
402
+ .then(step)
403
+ .catch((error: unknown) =>
404
+ this.#options.logger.warn(
405
+ { room, err: error },
406
+ "a host room frame was not delivered",
407
+ ),
408
+ );
409
+ this.#chains.set(room, next);
410
+ void next.then(() => {
411
+ if (this.#chains.get(room) === next) this.#chains.delete(room);
412
+ });
413
+ }
414
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * A jsonb value as the driver returned it: already an object, or the text of one when the
3
+ * driver left it as text. Anything else the database holds here is a stored value gone wrong,
4
+ * which is named instead of surfacing as a bare parse error.
5
+ */
6
+ export function storedJson<T>(value: T | string, what: string): T {
7
+ if (typeof value !== "string") return value;
8
+ try {
9
+ return JSON.parse(value) as T;
10
+ } catch {
11
+ throw new Error(`the stored ${what} is not valid JSON`);
12
+ }
13
+ }
@@ -0,0 +1,103 @@
1
+ // What the host-room and page API answers, shared by the server (`src/host/`) and the page (`web/`).
2
+ // Times are ISO strings. A key is `<surface>:<kind>.<id>`.
3
+
4
+ import type {
5
+ FormSpecView,
6
+ HostActionView,
7
+ HostCardView,
8
+ PictureRef,
9
+ ReadMarkView,
10
+ RoomMessage,
11
+ } from "../api-types.ts";
12
+
13
+ /** One room of a kind, as the list shows it. */
14
+ export interface HostRoomSummaryView {
15
+ id: string;
16
+ key: string;
17
+ title: string;
18
+ subtitle?: string;
19
+ picture?: PictureRef;
20
+ /** ISO time of the newest message. */
21
+ lastAt?: string;
22
+ attention?: boolean;
23
+ /** Messages past the viewer's read cursor; absent when none. */
24
+ unread?: number;
25
+ }
26
+
27
+ /** `GET api/host/<kind>`: the rooms of one kind this viewer may open. */
28
+ export interface HostListView {
29
+ kind: string;
30
+ label: string;
31
+ rooms: HostRoomSummaryView[];
32
+ cursor?: string;
33
+ }
34
+
35
+ /** The header of one room: facts about it, what the viewer may do, and a banner. */
36
+ export interface HostHeaderView {
37
+ title: string;
38
+ fields: { label: string; value: string; link?: string }[];
39
+ actions: HostActionView[];
40
+ banner?: { tone: "warn" | "risk"; text: string };
41
+ }
42
+
43
+ /** `GET api/host/<kind>/<id>`: a room as it opens. */
44
+ export interface HostRoomView {
45
+ room: string;
46
+ kind: string;
47
+ id: string;
48
+ /** What this viewer may do in the room. */
49
+ access: { send: ("note" | "turn")[]; act: boolean };
50
+ header: HostHeaderView;
51
+ /** The newest messages, oldest first. */
52
+ messages: RoomMessage[];
53
+ more: boolean;
54
+ cards: HostCardView[];
55
+ /** The other readers' cursors; the viewer's own is `cursor`. */
56
+ reads: ReadMarkView[];
57
+ /** The viewer's own read cursor before they opened the room; absent when they never read it. */
58
+ cursor?: number;
59
+ }
60
+
61
+ /** `GET api/host/<kind>/<id>/messages`: a page of older messages. */
62
+ export interface HostMessagesView {
63
+ messages: RoomMessage[];
64
+ more: boolean;
65
+ reads: ReadMarkView[];
66
+ }
67
+
68
+ /** What the page shows of a section of a host page. */
69
+ export type PageSectionView =
70
+ | { kind: "markdown"; text: string }
71
+ | {
72
+ kind: "list";
73
+ title?: string;
74
+ rows: {
75
+ title: string;
76
+ subtitle?: string;
77
+ open?: string;
78
+ attention?: boolean;
79
+ actions?: HostActionView[];
80
+ }[];
81
+ }
82
+ | { kind: "form"; form: FormSpecView; action: string }
83
+ | {
84
+ kind: "card";
85
+ card: Omit<HostCardView, "id" | "room" | "at" | "updatedAt">;
86
+ };
87
+
88
+ /** `GET api/pages/<id>`. */
89
+ export interface HostPageView {
90
+ id: string;
91
+ title: string;
92
+ sections: PageSectionView[];
93
+ }
94
+
95
+ /** Where the page takes a person after an action: a room (and a message in it), or a page with a query. */
96
+ export type OpenView =
97
+ | { room: string; message?: number }
98
+ | { page: string; query?: Record<string, string> };
99
+
100
+ /** What an action answers, for a button, a form or a page's action. */
101
+ export type HostActionResult =
102
+ | { ok: true; notice?: string; open?: OpenView; url?: string }
103
+ | { ok: false; error: string; field?: string };
package/src/i18n/zh-tw.ts CHANGED
@@ -266,6 +266,9 @@ export const ZH_TW: Readonly<Record<string, string>> = {
266
266
  // 進度
267
267
  "{name} is working…": "{name}正在處理…",
268
268
  "Show the whole stream": "顯示完整串流",
269
+ "{count} earlier tool": "前面還有 {count} 個工具",
270
+ "{count} earlier tools": "前面還有 {count} 個工具",
271
+ "Show fewer tools": "收合工具",
269
272
 
270
273
  // 聊天室選單
271
274
  "A thread has no settings.": "討論串沒有設定。",
@@ -1021,4 +1024,133 @@ export const ZH_TW: Readonly<Record<string, string>> = {
1021
1024
  "baseHash 必須是讀取技能時的雜湊值。",
1022
1025
  "This skill changed while you were editing it.":
1023
1026
  "這個技能在你編輯時被更動了。",
1027
+ // 成員、登入與設定關卡
1028
+ Profile: "個人資料",
1029
+ Owner: "擁有者",
1030
+ Admin: "管理員",
1031
+ Member: "成員",
1032
+ "Finish setting up": "完成設定",
1033
+ "Finish setting up first.": "請先完成設定。",
1034
+ "These steps come first. The rest of the app opens when they are done.":
1035
+ "請先完成這些步驟,完成後才能使用其餘功能。",
1036
+ "Push is not available to you.": "你不能使用推播通知。",
1037
+ "You are signed out.": "你已登出。",
1038
+ "This account is not allowed to use this app.":
1039
+ "這個帳號不能使用此應用程式。",
1040
+ "Sign-in did not complete.": "登入沒有完成。",
1041
+ // 主機的聊天室、頁面、卡片與已讀
1042
+ "Host room": "主機聊天室",
1043
+ "No rooms yet.": "還沒有聊天室。",
1044
+ "No messages yet.": "還沒有訊息。",
1045
+ "Nothing here.": "這裡沒有內容。",
1046
+ "Show more": "顯示更多",
1047
+ "Search {name}": "搜尋{name}",
1048
+ "Search…": "搜尋…",
1049
+ "Needs attention": "需要處理",
1050
+ "This page is not available.": "這個頁面無法使用。",
1051
+ "You can read this room but not write in it.":
1052
+ "你可以閱讀這個聊天室,但不能在其中發言。",
1053
+ "Send as": "傳送方式",
1054
+ "Ask the agent": "請夥伴處理",
1055
+ "Earlier cards ({count})": "先前的卡片({count})",
1056
+ Replaced: "已被取代",
1057
+ Image: "圖片",
1058
+ edited: "已編輯",
1059
+ "{count} file was removed": "已移除 {count} 個檔案",
1060
+ "{count} files were removed": "已移除 {count} 個檔案",
1061
+ "{count} unread message": "{count} 則未讀訊息",
1062
+ "{count} unread messages": "{count} 則未讀訊息",
1063
+ "New messages": "新訊息",
1064
+ "Seen by {count}": "已讀 {count}",
1065
+ "More settings": "更多設定",
1066
+ // 主機聊天室與頁面:伺服器的拒絕訊息
1067
+ "There is no such room.": "沒有這個聊天室。",
1068
+ "There is no such page.": "沒有這個頁面。",
1069
+ "input must be an object.": "input 必須是物件。",
1070
+ "input holds text or lists of text.": "input 只能放文字或文字清單。",
1071
+ "action must be text.": "action 必須是文字。",
1072
+ "card must be text.": "card 必須是文字。",
1073
+ "This is not yours to do.": "這個操作不是你可以做的。",
1074
+ "The page's query is too long.": "頁面的查詢參數太長。",
1075
+ "This action takes no form.": "這個操作沒有表單。",
1076
+ "The form has no such field.": "表單沒有這個欄位。",
1077
+ "This field is required.": "這個欄位必填。",
1078
+ "Choose from the list.": "請從清單中選擇。",
1079
+ "This field takes one value.": "這個欄位只能填一個值。",
1080
+ "This field is too long.": "這個欄位的內容太長。",
1081
+ "Give a web address.": "請填入網址。",
1082
+ "The action could not be done.": "無法執行這個操作。",
1083
+ // 通知、設定步驟與帳號連結
1084
+ Notices: "通知",
1085
+ "Notices, {count} waiting": "通知,{count} 則待處理",
1086
+ "Turn on notifications": "開啟通知",
1087
+ "Link your {name} account": "連結你的 {name} 帳號",
1088
+ "Prove which account is yours, so the app knows who you are there.":
1089
+ "證明哪個帳號是你的,讓應用程式知道你在那裡是誰。",
1090
+ "So that you hear when something needs you, even when the app is closed.":
1091
+ "這樣即使應用程式關著,有事找你時你也聽得到。",
1092
+ Urgent: "緊急",
1093
+ Handled: "已處理",
1094
+ "Nothing needs you.": "目前沒有需要你處理的事。",
1095
+ Linked: "已連結",
1096
+ Required: "必填",
1097
+ Recommended: "建議",
1098
+ "The server's configuration links this account.":
1099
+ "這個帳號由伺服器設定連結,無法在這裡移除。",
1100
+ Unlink: "解除連結",
1101
+ "Unlink {name}?": "要解除連結 {name} 嗎?",
1102
+ "The app will no longer know this account is yours. You can link it again later.":
1103
+ "應用程式將不再知道這個帳號是你的。之後可以再連結。",
1104
+ "Your code": "你的驗證碼",
1105
+ "The code works until {time}.": "驗證碼的有效時間到 {time}。",
1106
+ "A code is waiting to be used.": "有一組驗證碼等著被使用。",
1107
+ "{name} used the code. Confirm that this is you.":
1108
+ "{name} 使用了驗證碼。請確認那是你。",
1109
+ "I did it": "我完成了",
1110
+ "Link {name} first.": "請先連結 {name}。",
1111
+ Link: "連結",
1112
+ "In your quiet hours a normal notice waits in the bell instead of ringing your phone. An urgent one still rings.":
1113
+ "勿擾時段內,一般通知只會留在通知鈴裡,不會響手機;緊急通知照常響。",
1114
+ From: "從",
1115
+ To: "到",
1116
+ "Turn off quiet hours": "關閉勿擾時段",
1117
+ "A test notification is on its way.": "測試通知已送出。",
1118
+ "Send a test notification": "傳送測試通知",
1119
+ "Shown as": "顯示為",
1120
+ "The name and picture of this account are what others see in the app.":
1121
+ "別人在應用程式裡看到的,是這個帳號的名稱與頭像。",
1122
+ Automatic: "自動",
1123
+ "Your accounts": "你的帳號",
1124
+ "Put the app on your phone": "把應用程式放到手機上",
1125
+ "On iPhone and iPad, open this page in Safari, tap Share, then Add to Home Screen, and open the app from there.":
1126
+ "在 iPhone 與 iPad 上,用 Safari 開啟此頁,點「分享」,再點「加入主畫面」,之後從主畫面開啟。",
1127
+ "On a phone, add it to the home screen from the browser's menu so notifications reach you when it is closed.":
1128
+ "在手機上,請從瀏覽器選單加入主畫面,這樣應用程式關著時也收得到通知。",
1129
+ "Allow notifications": "允許通知",
1130
+ "This browser cannot get push notifications.": "這個瀏覽器無法接收推播通知。",
1131
+ "Allowed on this device.": "這台裝置已允許。",
1132
+ "Send a test": "傳送測試",
1133
+ "It arrived. Notifications work on this device.":
1134
+ "收到了。這台裝置的通知可以正常運作。",
1135
+ "Waiting for it…": "等待通知中…",
1136
+ "It has not arrived. Check that notifications are allowed for this app, then send another.":
1137
+ "還沒收到。請確認這個應用程式已允許通知,再傳送一次。",
1138
+ Skip: "略過",
1139
+ "{count} device(s) will get notifications.": "{count} 台裝置會收到通知。",
1140
+ Skipped: "已略過",
1141
+ "There is nothing left to set up.": "沒有需要再設定的項目。",
1142
+ "There is no such notice to handle.": "沒有這則可處理的通知。",
1143
+ "This account has no device that gets push yet. Turn it on first.":
1144
+ "這個帳號還沒有接收推播的裝置,請先開啟推播。",
1145
+ "A receipt needs a tag and received or opened.":
1146
+ "回報需要標籤,以及「已收到」或「已開啟」。",
1147
+ "There is no such notification.": "沒有這則通知。",
1148
+ "Send quiet: {start, end, timeZone} or null.":
1149
+ "請傳入 quiet:{start, end, timeZone},或 null。",
1150
+ "Send provider: the account's provider, or null for the default.":
1151
+ "請傳入 provider(帳號的提供者),或 null 使用預設。",
1152
+ "There is no picture.": "沒有頭像。",
1153
+ "Send request: the id the code came with.":
1154
+ "請傳入 request:驗證碼附帶的代號。",
1155
+ "That step cannot be waived.": "這個步驟不能略過。",
1024
1156
  };