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
package/src/app/sockets.ts
CHANGED
|
@@ -1,32 +1,79 @@
|
|
|
1
|
-
import type { ActorFacts, Logger, RouteSocket } from "pi-roundtable";
|
|
1
|
+
import type { ActorFacts, Logger, RouteSocket, Tier } from "pi-roundtable";
|
|
2
2
|
import type { ServerFrame } from "../api-types.ts";
|
|
3
3
|
|
|
4
|
-
/** One open WebSocket
|
|
4
|
+
/** One open WebSocket: who, what the page shows, and when its credential lapses. */
|
|
5
5
|
export interface AppSocket {
|
|
6
6
|
principal: { id: string; name: string };
|
|
7
7
|
/** The actor the credential vouched for, which the person's messages are written as. */
|
|
8
8
|
actor: ActorFacts | undefined;
|
|
9
9
|
expiresAt: Date | undefined;
|
|
10
|
+
/** The tier they hold, as of the upgrade or the latest frame or recheck. */
|
|
11
|
+
tier: Tier;
|
|
12
|
+
/** The identity they spoke as; the principal's id when the credential names none. */
|
|
13
|
+
speakerId: string;
|
|
14
|
+
/** The roles their sign-in carried. */
|
|
15
|
+
roles: readonly string[];
|
|
16
|
+
/** A required onboarding step stands undone: only the frames a gated socket may have reach it. */
|
|
17
|
+
gated: boolean;
|
|
18
|
+
/** The session the socket came in on, for the recheck that asks whether it still stands. */
|
|
19
|
+
session?: string | undefined;
|
|
10
20
|
/** The page is in view; a push goes out only when no socket is. */
|
|
11
21
|
visible: boolean;
|
|
12
22
|
/** The room the page shows, whose progress streams to it. */
|
|
13
23
|
focus: string | undefined;
|
|
24
|
+
/** The host rooms the socket follows. */
|
|
25
|
+
subscriptions: Set<string>;
|
|
26
|
+
/** Set once `ready` and the replay went out: until then nothing but a frame addressed to the socket reaches it. */
|
|
27
|
+
started?: boolean;
|
|
14
28
|
socket?: RouteSocket<AppSocket>;
|
|
15
29
|
/** Closes the socket when its credential lapses or the cap on its life is reached. */
|
|
16
30
|
expiry?: ReturnType<typeof setTimeout>;
|
|
17
|
-
/** Looks again at whether the person is still
|
|
31
|
+
/** Looks again at whether the person is still admitted, and at which tier. */
|
|
18
32
|
recheck?: ReturnType<typeof setInterval>;
|
|
19
33
|
}
|
|
20
34
|
|
|
21
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Who a frame is for. There is no unaddressed send: every frame names its audience, and the
|
|
37
|
+
* open-time replay goes through the same filter as a live frame.
|
|
38
|
+
*/
|
|
39
|
+
export type Audience =
|
|
40
|
+
/** Sockets whose tier is owner now; with `focus`, those showing that room. */
|
|
41
|
+
| { owners: true; focus?: string }
|
|
42
|
+
/** Every socket of one person. */
|
|
43
|
+
| { principal: string }
|
|
44
|
+
/** The sockets that follow a host room and may read it now; with `focus`, those also showing it. */
|
|
45
|
+
| { room: string; focus?: true }
|
|
46
|
+
/** One socket; with `audience`, only if it is also in that audience (the replay on open). */
|
|
47
|
+
| { connection: AppSocket; audience?: Audience };
|
|
48
|
+
|
|
49
|
+
/** The frames a gated socket still gets: the gate itself, the person's notices and badge, and refusals. */
|
|
50
|
+
const GATED_FRAMES: ReadonlySet<ServerFrame["type"]> = new Set([
|
|
51
|
+
"ready",
|
|
52
|
+
"refused",
|
|
53
|
+
"gate",
|
|
54
|
+
"notice",
|
|
55
|
+
"notice_resolved",
|
|
56
|
+
"badge",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/** Whether a socket may read a host room now; answered from a memo, so it costs no lookup. */
|
|
60
|
+
export type RoomReader = (connection: AppSocket, room: string) => boolean;
|
|
61
|
+
|
|
62
|
+
/** The open sockets, and the one way to put a frame on them. */
|
|
22
63
|
export class Sockets {
|
|
23
64
|
readonly #open = new Set<AppSocket>();
|
|
24
65
|
readonly #logger: Logger;
|
|
66
|
+
#reader: RoomReader = () => false;
|
|
25
67
|
|
|
26
68
|
constructor(logger: Logger) {
|
|
27
69
|
this.#logger = logger;
|
|
28
70
|
}
|
|
29
71
|
|
|
72
|
+
/** Sets who may read which host room; until it is set, no host room frame reaches anyone. */
|
|
73
|
+
setRoomReader(reader: RoomReader): void {
|
|
74
|
+
this.#reader = reader;
|
|
75
|
+
}
|
|
76
|
+
|
|
30
77
|
add(connection: AppSocket, socket: RouteSocket<AppSocket>): void {
|
|
31
78
|
connection.socket = socket;
|
|
32
79
|
this.#open.add(connection);
|
|
@@ -40,8 +87,42 @@ export class Sockets {
|
|
|
40
87
|
this.#open.delete(connection);
|
|
41
88
|
}
|
|
42
89
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
90
|
+
/** Whether the connection is in the audience now. */
|
|
91
|
+
reaches(connection: AppSocket, audience: Audience): boolean {
|
|
92
|
+
if ("connection" in audience)
|
|
93
|
+
return (
|
|
94
|
+
audience.connection === connection &&
|
|
95
|
+
(!audience.audience || this.reaches(connection, audience.audience))
|
|
96
|
+
);
|
|
97
|
+
if (!connection.started) return false;
|
|
98
|
+
if ("owners" in audience)
|
|
99
|
+
return (
|
|
100
|
+
connection.tier === "owner" &&
|
|
101
|
+
(audience.focus === undefined || connection.focus === audience.focus)
|
|
102
|
+
);
|
|
103
|
+
if ("principal" in audience)
|
|
104
|
+
return connection.principal.id === audience.principal;
|
|
105
|
+
return (
|
|
106
|
+
connection.subscriptions.has(audience.room) &&
|
|
107
|
+
this.#reader(connection, audience.room) &&
|
|
108
|
+
(!audience.focus || connection.focus === audience.room)
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Puts a frame on the sockets in `audience`, except `except`. A gated socket gets only what the
|
|
114
|
+
* gate lets through. A dropped send is logged, never thrown.
|
|
115
|
+
*/
|
|
116
|
+
deliver(frame: ServerFrame, audience: Audience, except?: AppSocket): void {
|
|
117
|
+
for (const connection of this.#open) {
|
|
118
|
+
if (connection === except || !this.reaches(connection, audience))
|
|
119
|
+
continue;
|
|
120
|
+
if (connection.gated && !GATED_FRAMES.has(frame.type)) continue;
|
|
121
|
+
this.#send(connection, frame);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#send(connection: AppSocket, frame: ServerFrame): void {
|
|
45
126
|
const result = connection.socket?.send(JSON.stringify(frame));
|
|
46
127
|
if (result === "dropped")
|
|
47
128
|
this.#logger.warn(
|
|
@@ -50,18 +131,7 @@ export class Sockets {
|
|
|
50
131
|
);
|
|
51
132
|
}
|
|
52
133
|
|
|
53
|
-
/**
|
|
54
|
-
broadcast(
|
|
55
|
-
frame: ServerFrame,
|
|
56
|
-
where?: (connection: AppSocket) => boolean,
|
|
57
|
-
except?: AppSocket,
|
|
58
|
-
): void {
|
|
59
|
-
for (const connection of this.#open)
|
|
60
|
-
if (connection !== except && (!where || where(connection)))
|
|
61
|
-
this.send(connection, frame);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** Whether a page of this person is in view now; of any owner when no one is named. */
|
|
134
|
+
/** Whether a page of this person is in view now; of any person when none is named. */
|
|
65
135
|
anyVisible(principalId?: string): boolean {
|
|
66
136
|
for (const connection of this.#open)
|
|
67
137
|
if (
|
|
@@ -72,10 +142,28 @@ export class Sockets {
|
|
|
72
142
|
return false;
|
|
73
143
|
}
|
|
74
144
|
|
|
145
|
+
/** Calls `visit` on each open socket `where` accepts. */
|
|
146
|
+
each(
|
|
147
|
+
where: (connection: AppSocket) => boolean,
|
|
148
|
+
visit: (connection: AppSocket) => void,
|
|
149
|
+
): void {
|
|
150
|
+
for (const connection of [...this.#open])
|
|
151
|
+
if (where(connection)) visit(connection);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Closes the sockets `where` accepts, such as those of a session that ended. */
|
|
155
|
+
closeWhere(
|
|
156
|
+
where: (connection: AppSocket) => boolean,
|
|
157
|
+
code: number,
|
|
158
|
+
reason: string,
|
|
159
|
+
): void {
|
|
160
|
+
for (const connection of [...this.#open])
|
|
161
|
+
if (where(connection)) connection.socket?.close(code, reason);
|
|
162
|
+
}
|
|
163
|
+
|
|
75
164
|
/** Closes every socket, when the app stops. */
|
|
76
165
|
closeAll(code: number, reason: string): void {
|
|
77
|
-
|
|
78
|
-
connection.socket?.close(code, reason);
|
|
166
|
+
this.closeWhere(() => true, code, reason);
|
|
79
167
|
}
|
|
80
168
|
|
|
81
169
|
get size(): number {
|
package/src/app/surface.ts
CHANGED
|
@@ -16,9 +16,9 @@ import {
|
|
|
16
16
|
} from "pi-roundtable";
|
|
17
17
|
import type { RoomFile, RoomMessage } from "../api-types.ts";
|
|
18
18
|
import type { OwnerDesk } from "./desk.ts";
|
|
19
|
-
import { type FileDir, typeByName } from "./files.ts";
|
|
19
|
+
import { type FileBinding, type FileDir, typeByName } from "./files.ts";
|
|
20
20
|
import type { NewRoomMessage, RoomLog } from "./room-log.ts";
|
|
21
|
-
import type { AppSocket, Sockets } from "./sockets.ts";
|
|
21
|
+
import type { AppSocket, Audience, Sockets } from "./sockets.ts";
|
|
22
22
|
import { AppSurfaceThreads } from "./surface-threads.ts";
|
|
23
23
|
import type { WebThreads } from "./threads.ts";
|
|
24
24
|
import type { Uploads } from "./uploads.ts";
|
|
@@ -45,6 +45,12 @@ export interface AppSurfaceOptions {
|
|
|
45
45
|
threads: Pick<WebThreads, "open" | "close">;
|
|
46
46
|
/** Whether a room can host a thread: an agent's or group's room that exists. */
|
|
47
47
|
canHost(parent: ChannelKey): Promise<boolean>;
|
|
48
|
+
/** Who has a room's frames: the owners' sockets for an agent room, a host room's readers for a host room. */
|
|
49
|
+
audienceOf(room: string): Audience;
|
|
50
|
+
/** Whether the room is a host's (its frames are the `host_*` ones). */
|
|
51
|
+
isHostRoom(room: string): boolean;
|
|
52
|
+
/** What binds a file an agent attaches in a host room, so its readers are served it. */
|
|
53
|
+
fileBinding?(room: string): FileBinding | undefined;
|
|
48
54
|
/** Tells the sockets the room list differs, once changes settle. */
|
|
49
55
|
changed(): void;
|
|
50
56
|
/**
|
|
@@ -54,6 +60,8 @@ export interface AppSurfaceOptions {
|
|
|
54
60
|
unprompted?(room: ChannelKey, author: string, text: string): void;
|
|
55
61
|
/** Called when a room's activity changes, so the presence table can follow. */
|
|
56
62
|
activity?(): void;
|
|
63
|
+
/** What was said in a host room since the assistant last posted; the host rooms' reader. */
|
|
64
|
+
channelContext?: ChatSurface["channelContext"];
|
|
57
65
|
/** The clock, in milliseconds; default `Date.now`. */
|
|
58
66
|
now?: () => number;
|
|
59
67
|
logger: Logger;
|
|
@@ -88,8 +96,8 @@ export interface RoomActivity {
|
|
|
88
96
|
}
|
|
89
97
|
|
|
90
98
|
/**
|
|
91
|
-
* The owner app's rooms as a chat surface: what the host shows in a room reaches
|
|
92
|
-
*
|
|
99
|
+
* The owner app's rooms as a chat surface: what the host shows in a room reaches the sockets that
|
|
100
|
+
* may have it (the owners' for an agent room) as a frame, and what a room says is stored in the room log. The agent team runs
|
|
93
101
|
* in the rooms (`agentRooms`) as it runs in its Discord channels, but each room is a
|
|
94
102
|
* conversation of its own. A reply arrives whole, in Markdown, with its files stored, and an
|
|
95
103
|
* agent's interim text is shown live until the final reply replaces it.
|
|
@@ -101,6 +109,8 @@ export class AppSurface implements ChatSurface {
|
|
|
101
109
|
readonly formatting = APP_FORMATTING;
|
|
102
110
|
/** The turn takes an upload up to the most the app lets a person upload. */
|
|
103
111
|
readonly maxAttachmentBytes: number;
|
|
112
|
+
/** Present on a surface with host rooms: what was said in one since the assistant last posted. */
|
|
113
|
+
channelContext?: ChatSurface["channelContext"];
|
|
104
114
|
readonly threads: AppSurfaceThreads;
|
|
105
115
|
readonly #options: AppSurfaceOptions;
|
|
106
116
|
readonly #activity = new Map<ChannelKey, Activity>();
|
|
@@ -114,6 +124,7 @@ export class AppSurface implements ChatSurface {
|
|
|
114
124
|
this.surface = options.surface;
|
|
115
125
|
this.maxAttachmentBytes = options.uploads.maxBytes;
|
|
116
126
|
this.#options = options;
|
|
127
|
+
if (options.channelContext) this.channelContext = options.channelContext;
|
|
117
128
|
this.threads = new AppSurfaceThreads({
|
|
118
129
|
surface: options.surface,
|
|
119
130
|
threads: options.threads,
|
|
@@ -147,26 +158,29 @@ export class AppSurface implements ChatSurface {
|
|
|
147
158
|
return this.#deliver !== undefined;
|
|
148
159
|
}
|
|
149
160
|
|
|
150
|
-
/** Stores a message of a room and shows it to
|
|
161
|
+
/** Stores a message of a room and shows it to the sockets that may have the room, except `except`. */
|
|
151
162
|
async publish(row: NewRoomMessage, except?: AppSocket): Promise<RoomMessage> {
|
|
152
163
|
const message = await this.#options.log.append(row);
|
|
153
|
-
this.#options.sockets.
|
|
154
|
-
|
|
155
|
-
|
|
164
|
+
this.#options.sockets.deliver(
|
|
165
|
+
this.#options.isHostRoom(row.room)
|
|
166
|
+
? { type: "host_message", room: row.room, message }
|
|
167
|
+
: { type: "message", room: row.room, message },
|
|
168
|
+
this.#options.audienceOf(row.room),
|
|
156
169
|
except,
|
|
157
170
|
);
|
|
158
171
|
return message;
|
|
159
172
|
}
|
|
160
173
|
|
|
161
|
-
/** Changes the text of a stored message and shows the change to
|
|
174
|
+
/** Changes the text of a stored message and shows the change to the sockets that may have the room. */
|
|
162
175
|
async edit(room: ChannelKey, id: number, text: string): Promise<void> {
|
|
163
176
|
const message = await this.#options.log.edit(room, id, text);
|
|
164
177
|
if (!message) return;
|
|
165
|
-
this.#options.sockets.
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
178
|
+
this.#options.sockets.deliver(
|
|
179
|
+
this.#options.isHostRoom(room)
|
|
180
|
+
? { type: "host_edited", room: message.room, message }
|
|
181
|
+
: { type: "edited", room: message.room, message },
|
|
182
|
+
this.#options.audienceOf(room),
|
|
183
|
+
);
|
|
170
184
|
}
|
|
171
185
|
|
|
172
186
|
/** Whether a turn works in the room and whether Stop applies, as `activity` frames say. */
|
|
@@ -225,12 +239,15 @@ export class AppSurface implements ChatSurface {
|
|
|
225
239
|
}
|
|
226
240
|
|
|
227
241
|
#announce(room: ChannelKey, outcome?: "done" | "failed"): void {
|
|
228
|
-
this.#options.sockets.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
242
|
+
this.#options.sockets.deliver(
|
|
243
|
+
{
|
|
244
|
+
type: "activity",
|
|
245
|
+
room,
|
|
246
|
+
...this.activityOf(room),
|
|
247
|
+
...(outcome ? { outcome } : {}),
|
|
248
|
+
},
|
|
249
|
+
this.#options.audienceOf(room),
|
|
250
|
+
);
|
|
234
251
|
this.#options.activity?.();
|
|
235
252
|
}
|
|
236
253
|
|
|
@@ -283,7 +300,15 @@ export class AppSurface implements ChatSurface {
|
|
|
283
300
|
const entry = this.#activity.get(channel);
|
|
284
301
|
if (entry) entry.outcome = reply.failed ? "failed" : "done";
|
|
285
302
|
const files: RoomFile[] = (reply.files ?? []).map((file) =>
|
|
286
|
-
this.#options.files.put(
|
|
303
|
+
this.#options.files.put(
|
|
304
|
+
file.name,
|
|
305
|
+
typeByName(file.name),
|
|
306
|
+
file.data,
|
|
307
|
+
undefined,
|
|
308
|
+
this.#options.isHostRoom(channel)
|
|
309
|
+
? this.#options.fileBinding?.(channel)
|
|
310
|
+
: undefined,
|
|
311
|
+
),
|
|
287
312
|
);
|
|
288
313
|
await this.publish({
|
|
289
314
|
room: channel,
|
|
@@ -328,21 +353,26 @@ export class AppSurface implements ChatSurface {
|
|
|
328
353
|
this.#speaks(channel, as?.agent);
|
|
329
354
|
const live = crypto.randomUUID();
|
|
330
355
|
const show = (current: string) =>
|
|
331
|
-
this.#options.sockets.
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
356
|
+
this.#options.sockets.deliver(
|
|
357
|
+
{
|
|
358
|
+
type: this.#options.isHostRoom(channel)
|
|
359
|
+
? "host_message"
|
|
360
|
+
: "message",
|
|
336
361
|
room: channel,
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
362
|
+
message: {
|
|
363
|
+
id: 0,
|
|
364
|
+
room: channel,
|
|
365
|
+
kind: "agent",
|
|
366
|
+
...(as?.agent ? { agent: as.agent } : {}),
|
|
367
|
+
author: as?.name ?? ASSISTANT,
|
|
368
|
+
text: current,
|
|
369
|
+
files: [],
|
|
370
|
+
at: new Date().toISOString(),
|
|
371
|
+
live,
|
|
372
|
+
},
|
|
344
373
|
},
|
|
345
|
-
|
|
374
|
+
this.#options.audienceOf(channel),
|
|
375
|
+
);
|
|
346
376
|
show(text);
|
|
347
377
|
return {
|
|
348
378
|
edit: async (edited) => {
|
|
@@ -362,9 +392,15 @@ export class AppSurface implements ChatSurface {
|
|
|
362
392
|
|
|
363
393
|
progress(channel: ChannelKey, event: TurnProgress): void {
|
|
364
394
|
this.#toolEvent(channel, event);
|
|
365
|
-
|
|
395
|
+
// Only the sockets showing the room get its progress, and only those that may have the room.
|
|
396
|
+
const audience = this.#options.audienceOf(channel);
|
|
397
|
+
this.#options.sockets.deliver(
|
|
366
398
|
{ type: "progress", room: channel, event },
|
|
367
|
-
|
|
399
|
+
"owners" in audience
|
|
400
|
+
? { owners: true, focus: channel }
|
|
401
|
+
: "room" in audience
|
|
402
|
+
? { room: audience.room, focus: true }
|
|
403
|
+
: audience,
|
|
368
404
|
);
|
|
369
405
|
}
|
|
370
406
|
|
package/src/app/threads.ts
CHANGED
|
@@ -60,6 +60,15 @@ export class WebThreads {
|
|
|
60
60
|
},
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
/** The mark of a finished thread the owners took off the room list; the thread itself stays. */
|
|
64
|
+
static readonly dismissedMigration: Migration = {
|
|
65
|
+
name: "web-threads-dismissed",
|
|
66
|
+
up: async (sql) => {
|
|
67
|
+
await sql`
|
|
68
|
+
ALTER TABLE web_threads ADD COLUMN IF NOT EXISTS dismissed_at timestamptz`;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
63
72
|
/** Starts a thread under `parent`. */
|
|
64
73
|
async open(parent: string, title: string): Promise<WebThread> {
|
|
65
74
|
const id = `thread.${crypto.randomUUID()}`;
|
|
@@ -76,6 +85,26 @@ export class WebThreads {
|
|
|
76
85
|
await this.#sql`UPDATE web_threads SET open = false WHERE id = ${id}`;
|
|
77
86
|
}
|
|
78
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Takes a finished thread off the room list. It stays readable by its address, and asking again
|
|
90
|
+
* changes nothing. An open thread is refused: `open` says so, `missing` that there is none.
|
|
91
|
+
*/
|
|
92
|
+
async dismiss(id: string): Promise<"dismissed" | "open" | "missing"> {
|
|
93
|
+
const rows: unknown[] = await this.#sql`
|
|
94
|
+
UPDATE web_threads SET dismissed_at = COALESCE(dismissed_at, now())
|
|
95
|
+
WHERE id = ${id} AND NOT open RETURNING 1`;
|
|
96
|
+
if (rows.length) return "dismissed";
|
|
97
|
+
return (await this.get(id)) ? "open" : "missing";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Dismisses every finished thread under `parent` that is still listed; returns how many. */
|
|
101
|
+
async dismissFinished(parent: string): Promise<number> {
|
|
102
|
+
const rows: unknown[] = await this.#sql`
|
|
103
|
+
UPDATE web_threads SET dismissed_at = now()
|
|
104
|
+
WHERE parent = ${parent} AND NOT open AND dismissed_at IS NULL RETURNING 1`;
|
|
105
|
+
return rows.length;
|
|
106
|
+
}
|
|
107
|
+
|
|
79
108
|
async get(id: string): Promise<WebThread | undefined> {
|
|
80
109
|
const [row]: Row[] = await this
|
|
81
110
|
.#sql`SELECT * FROM web_threads WHERE id = ${id}`;
|
|
@@ -98,14 +127,14 @@ export class WebThreads {
|
|
|
98
127
|
|
|
99
128
|
/**
|
|
100
129
|
* The threads the room list shows, newest first: every open one, and the `closedLimit` newest
|
|
101
|
-
* closed ones
|
|
130
|
+
* closed ones that were not dismissed. The rest stay readable but are not kept in view.
|
|
102
131
|
*/
|
|
103
132
|
async list(closedLimit = CLOSED_LISTED): Promise<WebThread[]> {
|
|
104
133
|
const rows: Row[] = await this.#sql`
|
|
105
134
|
SELECT * FROM (
|
|
106
135
|
SELECT * FROM web_threads WHERE open
|
|
107
136
|
UNION ALL
|
|
108
|
-
(SELECT * FROM web_threads WHERE NOT open
|
|
137
|
+
(SELECT * FROM web_threads WHERE NOT open AND dismissed_at IS NULL
|
|
109
138
|
ORDER BY created_at DESC, id LIMIT ${closedLimit})
|
|
110
139
|
) shown ORDER BY created_at DESC, id`;
|
|
111
140
|
return rows.map(threadOf);
|
package/src/app/uploads.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { extname } from "node:path";
|
|
2
2
|
import type { AttachmentFile, AttachmentRef } from "pi-roundtable";
|
|
3
3
|
import type { RoomFile, UploadView } from "../api-types.ts";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
FILE_RETENTION_MS,
|
|
6
|
+
type FileBinding,
|
|
7
|
+
type FileDir,
|
|
8
|
+
safeName,
|
|
9
|
+
} from "./files.ts";
|
|
5
10
|
import {
|
|
6
11
|
boundaryOf,
|
|
7
12
|
MultipartError,
|
|
@@ -32,7 +37,7 @@ const startsWith = (bytes: Uint8Array, signature: readonly number[], at = 0) =>
|
|
|
32
37
|
const ascii = (text: string) => [...text].map((char) => char.charCodeAt(0));
|
|
33
38
|
|
|
34
39
|
/** The image type the first bytes say, if any; these four are the ones the page shows itself. */
|
|
35
|
-
function imageTypeOf(head: Uint8Array): string | undefined {
|
|
40
|
+
export function imageTypeOf(head: Uint8Array): string | undefined {
|
|
36
41
|
if (startsWith(head, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
37
42
|
return "image/png";
|
|
38
43
|
if (startsWith(head, [0xff, 0xd8, 0xff])) return "image/jpeg";
|
|
@@ -153,6 +158,8 @@ export class Uploads {
|
|
|
153
158
|
readonly #now: () => number;
|
|
154
159
|
readonly maxBytes: number;
|
|
155
160
|
readonly #pendingBytes: number;
|
|
161
|
+
/** How long a stored file of an agent room is kept, in milliseconds. */
|
|
162
|
+
readonly #keepMs: number;
|
|
156
163
|
|
|
157
164
|
constructor(
|
|
158
165
|
waiting: FileDir,
|
|
@@ -161,6 +168,8 @@ export class Uploads {
|
|
|
161
168
|
maxBytes?: number;
|
|
162
169
|
/** The most bytes one person may have waiting; default 400 MiB. */
|
|
163
170
|
pendingBytes?: number;
|
|
171
|
+
/** How many days a stored file of an agent room is kept; default 30. */
|
|
172
|
+
keepDays?: number;
|
|
164
173
|
now?: () => number;
|
|
165
174
|
} = {},
|
|
166
175
|
) {
|
|
@@ -169,6 +178,10 @@ export class Uploads {
|
|
|
169
178
|
this.#now = options.now ?? Date.now;
|
|
170
179
|
this.maxBytes = options.maxBytes ?? DEFAULT_UPLOAD_MAX_BYTES;
|
|
171
180
|
this.#pendingBytes = options.pendingBytes ?? PENDING_BYTES_PER_PRINCIPAL;
|
|
181
|
+
this.#keepMs =
|
|
182
|
+
options.keepDays === undefined
|
|
183
|
+
? FILE_RETENTION_MS
|
|
184
|
+
: options.keepDays * 24 * 3_600_000;
|
|
172
185
|
}
|
|
173
186
|
|
|
174
187
|
#waitingOf(principalId: string): { files: number; bytes: number } {
|
|
@@ -303,11 +316,15 @@ export class Uploads {
|
|
|
303
316
|
return files;
|
|
304
317
|
}
|
|
305
318
|
|
|
306
|
-
/**
|
|
307
|
-
|
|
319
|
+
/**
|
|
320
|
+
* Moves claimed uploads into the stored files, once their message is accepted. `binding` ties
|
|
321
|
+
* them to the host room the message is in, so only that room's readers are served them.
|
|
322
|
+
*/
|
|
323
|
+
use(ids: readonly string[], binding?: FileBinding): void {
|
|
308
324
|
for (const id of ids) {
|
|
309
325
|
this.#pending.delete(id);
|
|
310
|
-
this.#waiting.moveTo(id, this.#stored)
|
|
326
|
+
if (this.#waiting.moveTo(id, this.#stored) && binding)
|
|
327
|
+
this.#stored.bind(id, binding);
|
|
311
328
|
}
|
|
312
329
|
}
|
|
313
330
|
|
|
@@ -334,7 +351,11 @@ export class Uploads {
|
|
|
334
351
|
return { path };
|
|
335
352
|
}
|
|
336
353
|
|
|
337
|
-
/**
|
|
354
|
+
/**
|
|
355
|
+
* Deletes the waiting uploads older than a day and the stored files that are due; returns how
|
|
356
|
+
* many. A file of an agent room is due after `keepDays` (default 30); a file bound to a host
|
|
357
|
+
* room is due when its own expiry passes, and kept when it has none.
|
|
358
|
+
*/
|
|
338
359
|
sweep(): number {
|
|
339
360
|
const now = this.#now();
|
|
340
361
|
let dropped = 0;
|
|
@@ -343,7 +364,12 @@ export class Uploads {
|
|
|
343
364
|
this.#pending.delete(id);
|
|
344
365
|
dropped += 1;
|
|
345
366
|
}
|
|
346
|
-
for (const id of this.#stored.
|
|
367
|
+
for (const id of this.#stored.ids()) {
|
|
368
|
+
const binding = this.#stored.binding(id);
|
|
369
|
+
const due = binding
|
|
370
|
+
? binding.expiresAt !== undefined && binding.expiresAt <= now
|
|
371
|
+
: (this.#stored.ageOf(id, now) ?? 0) > this.#keepMs;
|
|
372
|
+
if (!due) continue;
|
|
347
373
|
this.#stored.remove(id);
|
|
348
374
|
dropped += 1;
|
|
349
375
|
}
|