Skip to content
740 linesCodeBlameRaw
1/**
2 * One person's feed, as a Durable Object named by their user id.
3 *
4 * It holds a socket per open tab with the WebSocket Hibernation API, so a
5 * person with g1t open all day costs nothing between notifications. Plain
6 * `ping` keepalives are answered at the edge without waking it, and the
7 * time of the last one says whether a tab is still there
8 * (`getWebSocketAutoResponseTimestamp`). Each socket carries the tab's
9 * focus and page (`serializeAttachment`), which survive hibernation.
10 *
11 * Kept in the object's own SQLite storage: the latest notifications, the
12 * unread counts per conversation, push subscriptions (browsers) and
13 * devices (the phone app, pushed through Expo: src/expopush.ts) and
14 * preferences, and
15 * the person's status, Do Not Disturb and whether they set themselves away,
16 * and when they were last on each workspace's Home page (src/visits.ts),
17 * and which repositories' pages their tabs are watching (src/watch.ts).
18 *
19 * Presence is worked out here from the tabs (src/presence.ts) and told to
20 * the room of every workspace the person belongs to (src/room.ts), which
21 * passes it to everyone there who is online. A status or Do Not Disturb
22 * that runs out is cleared by an alarm and told the same way.
23 *
24 * Live repository pages: a tab on a repository's page has the site ask
25 * the feed to watch it (`watchRepo`), which tells the repository's topic
26 * (src/topic.ts); the topic hands back what changes there (`repoChanged`),
27 * and the feed sends it to the tabs as a `repo` event.
28 *
29 * The feed authorizes nothing: the Worker reaches it only for the person
30 * the site checked (src/index.ts).
31 */
32import { DurableObject } from "cloudflare:workers";
33
34import {
35 NOTIFY_SEED_HEADER,
36 type ChannelCounts,
37 type OwnPresence,
38 type PresenceChange,
39 type PresenceEntry,
40 type FeedDelivery,
41 type FeedEvent,
42 type FeedNotification,
43 type FeedSeed,
44 type RepoChange,
45 type NotifyPreferences,
46 type NotifyStatus,
47 type PushDevice,
48 type PushSubscriptionJson,
49} from "@g1t/contracts";
50
51import { applyCounts, totals } from "./counts.ts";
52import { cleanDevice, sendExpo } from "./expopush.ts";
53import { cleanNotification, decide, mergePreferences, pushPayload, readPreferences, type PushPayload, type TabState } from "./prefs.ts";
54import {
55 OFFLINE_GRACE_MS,
56 applyChange,
57 cleanWorkspaces,
58 current,
59 dndOn,
60 entryOf,
61 nextExpiry,
62 ownOf,
63 presenceOf,
64 readKept,
65 sameEntry,
66 type Kept,
67} from "./presence.ts";
68import type { Room } from "./room.ts";
69import type { Topic } from "./topic.ts";
70import { type Visit, lastVisit, markVisit } from "./visits.ts";
71import { MAX_WATCHING, stillWatching, watchUntil } from "./watch.ts";
72import { sendPush, type Vapid } from "./webpush.ts";
73
74export type FeedEnv = {
75 VAPID_PUBLIC_KEY?: string;
76 VAPID_PRIVATE_KEY?: string;
77 VAPID_SUBJECT?: string;
78 /**
79 * A secret, optional: the access token Expo's push service asks for once
80 * the Expo project turns on enhanced push security. Without it, pushes to
81 * phones go unauthenticated, which Expo takes otherwise.
82 */
83 EXPO_ACCESS_TOKEN?: string;
84 /**
85 * `off` sends phones nothing: a self-hosted g1t that would rather not pass
86 * notifications through Expo's push service (and Google's and Apple's)
87 * sets PHONE_PUSH=off (deploy/self-host/configs.mjs). Phones then do not
88 * register, and the site's /.well-known/g1t.json says so to the app.
89 */
90 PHONE_PUSH?: string;
91 /** One presence room per workspace (src/room.ts); without it, presence stays in the person's own tabs. */
92 ROOMS?: DurableObjectNamespace<Room>;
93 /** One live topic per repository (src/topic.ts); without it, repository pages refresh on their own. */
94 TOPICS?: DurableObjectNamespace<Topic>;
95};
96
97/** The headers the Worker passes the person's id and username in, with a socket (src/index.ts). */
98export const FEED_USERNAME_HEADER = "x-g1t-notify-username";
99export const FEED_USER_ID_HEADER = "x-g1t-notify-user-id";
100
101/** What each socket carries: focus, page, whether it has gone idle, and the workspace it is open in. */
102type Tab = { focused: boolean; path: string; at: number; idle?: boolean; workspace?: string | null };
103
104/** Notifications kept for a tab that opens later. */
105const KEPT = 100;
106/** Sent with `hello`: the latest few. */
107const HELLO = 20;
108/** The most browsers one person gets pushes on. */
109const MAX_SUBSCRIPTIONS = 20;
110/** The longest endpoint taken. */
111const MAX_ENDPOINT = 2000;
112/** The most phones one person gets pushes on. */
113const MAX_DEVICES = 10;
114
115export class Feed extends DurableObject<FeedEnv> {
116 private readonly sql: SqlStorage;
117
118 constructor(ctx: DurableObjectState, env: FeedEnv) {
119 super(ctx, env);
120 this.sql = ctx.storage.sql;
121 // Keepalives are answered without waking the object.
122 ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
123 this.sql.exec(`CREATE TABLE IF NOT EXISTS notifications (id TEXT PRIMARY KEY, at INTEGER NOT NULL, json TEXT NOT NULL)`);
124 this.sql.exec(`CREATE INDEX IF NOT EXISTS notifications_at ON notifications (at)`);
125 this.sql.exec(
126 `CREATE TABLE IF NOT EXISTS counts (workspace TEXT NOT NULL, channel_id TEXT NOT NULL, unread INTEGER NOT NULL, mentions INTEGER NOT NULL, muted INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (workspace, channel_id))`,
127 );
128 this.sql.exec(
129 `CREATE TABLE IF NOT EXISTS subscriptions (endpoint TEXT PRIMARY KEY, p256dh TEXT NOT NULL, auth TEXT NOT NULL, user_agent TEXT, created_at INTEGER NOT NULL)`,
130 );
131 // The phone app's Expo push tokens (src/expopush.ts).
132 this.sql.exec(`CREATE TABLE IF NOT EXISTS devices (token TEXT PRIMARY KEY, platform TEXT NOT NULL, name TEXT, created_at INTEGER NOT NULL)`);
133 this.sql.exec(`CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
134 // When the person was last on each workspace's Home page (src/visits.ts).
135 this.sql.exec(`CREATE TABLE IF NOT EXISTS visits (workspace TEXT PRIMARY KEY, seen_at INTEGER NOT NULL, previous_at INTEGER)`);
136 // The repositories whose pages the person's tabs are watching, until when (src/watch.ts).
137 this.sql.exec(`CREATE TABLE IF NOT EXISTS watching (repo_id TEXT PRIMARY KEY, until INTEGER NOT NULL)`);
138 }
139
140 // ── Kept state ──────────────────────────────────────────────────────────
141
142 private meta(key: string): string | null {
143 const row = this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = ?", key).toArray()[0];
144 return row?.value ?? null;
145 }
146
147 private setMeta(key: string, value: string): void {
148 this.sql.exec("INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value", key, value);
149 }
150
151 private preferences(): NotifyPreferences {
152 return readPreferences(this.meta("preferences"));
153 }
154
155 private inboxUnread(): number {
156 return Number(this.meta("inbox_unread") ?? 0) || 0;
157 }
158
159 private channelRows(workspace: string): ChannelCounts[] {
160 return this.sql
161 .exec<{ channel_id: string; unread: number; mentions: number; muted: number }>(
162 "SELECT channel_id, unread, mentions, muted FROM counts WHERE workspace = ?",
163 workspace,
164 )
165 .toArray()
166 .map((r) => ({ channel_id: r.channel_id, unread: r.unread, mentions: r.mentions, muted: !!r.muted }));
167 }
168
169 private counts(workspace: string): FeedEvent {
170 return { type: "counts", ...totals(workspace, this.channelRows(workspace), this.inboxUnread(), this.meta(`complete:${workspace}`) === "1") };
171 }
172
173 private workspaces(): string[] {
174 return this.sql.exec<{ workspace: string }>("SELECT DISTINCT workspace FROM counts").toArray().map((r) => r.workspace);
175 }
176
177 private vapid(): Vapid | null {
178 const { VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT } = this.env;
179 return VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY ? { publicKey: VAPID_PUBLIC_KEY, privateKey: VAPID_PRIVATE_KEY, subject: VAPID_SUBJECT || "https://g1t.sh" } : null;
180 }
181
182 private subscriptionCount(): number {
183 return this.sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM subscriptions").one().n;
184 }
185
186 private deviceCount(): number {
187 if (!this.phonePush()) return 0;
188 return this.sql.exec<{ n: number }>("SELECT COUNT(*) AS n FROM devices").one().n;
189 }
190
191 /** Whether this g1t sends phones anything (PHONE_PUSH). */
192 private phonePush(): boolean {
193 return this.env.PHONE_PUSH !== "off";
194 }
195
196 // ── Presence ────────────────────────────────────────────────────────────
197
198 private kept(): Kept {
199 return readKept(this.meta("presence"));
200 }
201
202 /** Who this feed is for, as the site last said. */
203 private person(): { user_id: string; username: string } {
204 return { user_id: this.meta("user_id") ?? "", username: this.meta("username") ?? "" };
205 }
206
207 /** The open tabs as presence sees them, leaving out one that is closing. */
208 private presenceTabs(closing?: WebSocket): { idle: boolean }[] {
209 return this.ctx
210 .getWebSockets()
211 .filter((socket) => socket !== closing && socket.readyState === WebSocket.OPEN)
212 .map((socket) => ({ idle: (socket.deserializeAttachment() as Tab | null)?.idle === true }));
213 }
214
215 private entry(closing?: WebSocket): PresenceEntry {
216 const kept = this.kept();
217 return entryOf(this.person(), presenceOf(this.presenceTabs(closing), kept.away_manual), kept, Date.now());
218 }
219
220 private room(workspace: string) {
221 const rooms = this.env.ROOMS;
222 return rooms ? rooms.get(rooms.idFromName(workspace)) : null;
223 }
224
225 private memberOf(): string[] {
226 try {
227 return cleanWorkspaces(JSON.parse(this.meta("workspaces") ?? "[]"));
228 } catch {
229 return [];
230 }
231 }
232
233 /**
234 * Works presence out again and, when how the person shows has changed,
235 * tells their own tabs and every workspace's room. `force` tells them
236 * anyway (a tab just connected, so a room may have missed the last word).
237 */
238 private async refresh(options: { closing?: WebSocket; force?: boolean } = {}): Promise<PresenceEntry> {
239 const now = Date.now();
240 // What ran out goes, so nobody is told of it again.
241 const kept = this.kept();
242 const live = current(kept, now);
243 if (JSON.stringify(live) !== JSON.stringify(kept)) this.setMeta("presence", JSON.stringify(live));
244 const entry = this.entry(options.closing);
245 let before: PresenceEntry | null = null;
246 try {
247 before = JSON.parse(this.meta("reported") ?? "null") as PresenceEntry | null;
248 } catch {
249 before = null;
250 }
251 const changed = !sameEntry(before, entry);
252 if (changed || options.force) {
253 this.setMeta("reported", JSON.stringify(entry));
254 this.send({ type: "me", me: ownOf(entry, live) }, options.closing);
255 // Without an id there is nobody to name: the site always sends one with a socket.
256 if (entry.user_id) await Promise.allSettled(this.memberOf().map((workspace) => this.room(workspace)?.report(workspace, entry)));
257 }
258 await this.schedule();
259 return entry;
260 }
261
262 /** The next alarm: when a status or Do Not Disturb runs out, or when a person whose last tab closed shows offline. */
263 private async schedule(): Promise<void> {
264 const now = Date.now();
265 const times = [nextExpiry(this.kept(), now), Number(this.meta("offline_at") ?? 0) || null].filter((at): at is number => at != null && at > now);
266 if (times.length) await this.ctx.storage.setAlarm(Math.min(...times));
267 else await this.ctx.storage.deleteAlarm();
268 }
269
270 override async alarm(): Promise<void> {
271 const offlineAt = Number(this.meta("offline_at") ?? 0);
272 if (offlineAt && offlineAt <= Date.now()) this.setMeta("offline_at", "0");
273 await this.refresh();
274 }
275
276 /** A tab just connected: everyone hears it is here, and it hears how everyone in its workspace shows. */
277 private async connected(socket: WebSocket, workspace: string | null): Promise<void> {
278 this.setMeta("offline_at", "0");
279 const entry = await this.refresh({ force: true });
280 if (!workspace || !entry.user_id) return;
281 const people = await this.room(workspace)
282 ?.report(workspace, entry, true)
283 .catch((error: unknown) => {
284 console.error("notify: reading a presence room failed", error);
285 return null;
286 });
287 if (!people) return;
288 try {
289 socket.send(JSON.stringify({ type: "presence", workspace, people, full: true } satisfies FeedEvent));
290 } catch {
291 // Gone already.
292 }
293 }
294
295 // ── Sockets ─────────────────────────────────────────────────────────────
296
297 private tabs(): TabState[] {
298 return this.ctx.getWebSockets().map((socket) => {
299 const tab = (socket.deserializeAttachment() as Tab | null) ?? { focused: false, path: "", at: 0 };
300 const pinged = this.ctx.getWebSocketAutoResponseTimestamp(socket)?.getTime() ?? 0;
301 return { focused: tab.focused, seen_at: Math.max(tab.at, pinged) };
302 });
303 }
304
305 private send(event: FeedEvent, except?: WebSocket): void {
306 const text = JSON.stringify(event);
307 for (const socket of this.ctx.getWebSockets()) {
308 if (socket === except) continue;
309 try {
310 socket.send(text);
311 } catch {
312 // Closing already.
313 }
314 }
315 }
316
317 override async fetch(request: Request): Promise<Response> {
318 if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
319 return new Response("Expected a WebSocket upgrade\n", { status: 426 });
320 }
321 let seed: FeedSeed | null = null;
322 try {
323 seed = JSON.parse(request.headers.get(NOTIFY_SEED_HEADER) ?? "null") as FeedSeed | null;
324 } catch {
325 seed = null;
326 }
327 if (seed) this.applySeed(seed);
328 const username = request.headers.get(FEED_USERNAME_HEADER);
329 const userId = request.headers.get(FEED_USER_ID_HEADER);
330 this.remember({ user_id: userId ?? "", username: username ?? "" });
331 if (Array.isArray(seed?.workspaces)) this.setMeta("workspaces", JSON.stringify(cleanWorkspaces(seed.workspaces)));
332 const workspace = seed?.workspace?.toLowerCase() || null;
333 const pair = new WebSocketPair();
334 const [client, server] = Object.values(pair) as [WebSocket, WebSocket];
335 this.ctx.acceptWebSocket(server);
336 server.serializeAttachment({ focused: false, path: "", at: Date.now(), idle: false, workspace } satisfies Tab);
337 const hello: FeedEvent = {
338 type: "hello",
339 notifications: this.latest(HELLO),
340 vapid_public_key: this.env.VAPID_PUBLIC_KEY || null,
341 preferences: this.preferences(),
342 };
343 server.send(JSON.stringify(hello));
344 const shown = new Set(this.workspaces());
345 if (seed?.workspace) shown.add(seed.workspace);
346 for (const workspace of shown) server.send(JSON.stringify(this.counts(workspace)));
347 // Presence after the socket is handed back: the rooms are not waited on.
348 this.ctx.waitUntil(this.connected(server, workspace));
349 return new Response(null, { status: 101, webSocket: client });
350 }
351
352 /** Counts read from chat and the inbox just now replace what was kept for that workspace. */
353 private applySeed(seed: FeedSeed): void {
354 if (typeof seed.inbox_unread === "number") this.setMeta("inbox_unread", String(Math.max(0, Math.floor(seed.inbox_unread))));
355 const workspace = seed.workspace?.toLowerCase();
356 if (!workspace || !Array.isArray(seed.per_channel)) return;
357 this.ctx.storage.transactionSync(() => {
358 this.sql.exec("DELETE FROM counts WHERE workspace = ?", workspace);
359 for (const row of seed.per_channel!.slice(0, 2000)) {
360 if (typeof row?.channel_id !== "string") continue;
361 const kept = applyCounts(null, { ...row, set: true });
362 this.sql.exec(
363 "INSERT OR REPLACE INTO counts (workspace, channel_id, unread, mentions, muted) VALUES (?, ?, ?, ?, ?)",
364 workspace,
365 kept.channel_id,
366 kept.unread,
367 kept.mentions,
368 kept.muted ? 1 : 0,
369 );
370 }
371 this.setMeta(`complete:${workspace}`, "1");
372 });
373 }
374
375 override async webSocketMessage(socket: WebSocket, message: string | ArrayBuffer): Promise<void> {
376 if (typeof message !== "string" || message.length > 4096) return;
377 let frame: Record<string, unknown>;
378 try {
379 frame = JSON.parse(message);
380 } catch {
381 return;
382 }
383 if (frame.type === "state") {
384 const before = (socket.deserializeAttachment() as Tab | null) ?? { focused: false, path: "", at: 0 };
385 const idle = frame.idle === true;
386 socket.serializeAttachment({
387 focused: frame.focused === true,
388 path: String(frame.path ?? "").slice(0, 500),
389 at: Date.now(),
390 idle,
391 workspace: before.workspace ?? null,
392 } satisfies Tab);
393 // Gone idle, or back: away or active.
394 if (idle !== (before.idle === true)) await this.refresh();
395 } else if (frame.type === "inbox" && Number.isFinite(frame.unread)) {
396 // The inbox count a page just read: every tab shows it at once.
397 await this.setInbox(Number(frame.unread));
398 }
399 }
400
401 override async webSocketClose(socket: WebSocket, code: number, reason: string): Promise<void> {
402 try {
403 socket.close(code, reason);
404 } catch {
405 // Already closed.
406 }
407 await this.left(socket);
408 }
409
410 override async webSocketError(socket: WebSocket): Promise<void> {
411 await this.left(socket);
412 }
413
414 /**
415 * A tab went. The last one leaves the person showing as they were for a
416 * moment, so a reload or a switch of workspace is not leaving; the alarm
417 * then says offline if no tab came back.
418 */
419 private async left(socket: WebSocket): Promise<void> {
420 if (this.presenceTabs(socket).length === 0) {
421 this.setMeta("offline_at", String(Date.now() + OFFLINE_GRACE_MS));
422 await this.schedule();
423 return;
424 }
425 await this.refresh({ closing: socket });
426 }
427
428 // ── Notifications ───────────────────────────────────────────────────────
429
430 private latest(limit: number): FeedNotification[] {
431 return this.sql
432 .exec<{ json: string }>("SELECT json FROM notifications ORDER BY at DESC LIMIT ?", limit)
433 .toArray()
434 .map((r) => JSON.parse(r.json) as FeedNotification);
435 }
436
437 /** Keeps it, unless it was told before. True when it is news. */
438 private keep(notification: FeedNotification): boolean {
439 const had = this.sql.exec("SELECT 1 FROM notifications WHERE id = ?", notification.id).toArray().length > 0;
440 if (had) return false;
441 this.sql.exec("INSERT INTO notifications (id, at, json) VALUES (?, ?, ?)", notification.id, Date.now(), JSON.stringify(notification));
442 this.sql.exec("DELETE FROM notifications WHERE id NOT IN (SELECT id FROM notifications ORDER BY at DESC LIMIT ?)", KEPT);
443 return true;
444 }
445
446 /** Tells the person: open tabs at once, then browsers and phones when none is in front of them. */
447 private async tell(notification: FeedNotification, test = false): Promise<number> {
448 if (!test && !this.keep(notification)) return 0;
449 // Somewhere to push: a browser or a phone.
450 const subscriptions = this.subscriptionCount() + this.deviceCount();
451 const now = Date.now();
452 // Do Not Disturb: kept and counted, neither toasted nor pushed.
453 const quiet = dndOn(this.kept().dnd_until, now);
454 const decision = decide({ prefs: this.preferences(), notification, tabs: this.tabs(), subscriptions, now, test, dnd: quiet });
455 this.send({ type: "notification", notification, toast: decision.toast });
456 return decision.push ? this.push(notification) : 0;
457 }
458
459 /** Pushes to every browser and phone at once; one failing does not stop the other. */
460 private async push(notification: FeedNotification): Promise<number> {
461 const payload = pushPayload(notification);
462 const results = await Promise.allSettled([this.pushBrowsers(payload), this.pushDevices(notification, payload)]);
463 let sent = 0;
464 for (const result of results) {
465 if (result.status === "fulfilled") sent += result.value;
466 else console.error("notify: pushing failed", result.reason);
467 }
468 return sent;
469 }
470
471 /** Pushes to every browser subscribed; drops the ones the push service says are gone. */
472 private async pushBrowsers(payload: PushPayload): Promise<number> {
473 const vapid = this.vapid();
474 if (!vapid) return 0;
475 const subscriptions = this.sql.exec<{ endpoint: string; p256dh: string; auth: string }>("SELECT endpoint, p256dh, auth FROM subscriptions").toArray();
476 const results = await Promise.allSettled(
477 subscriptions.map((s) => sendPush(s, payload, { vapid, urgency: payload.urgent ? "high" : "normal", topic: payload.tag, ttl: 12 * 3600 })),
478 );
479 let sent = 0;
480 for (const result of results) {
481 if (result.status === "rejected") {
482 console.error("notify: a push failed", result.reason);
483 continue;
484 }
485 if (result.value.gone) this.sql.exec("DELETE FROM subscriptions WHERE endpoint = ?", result.value.endpoint);
486 else if (result.value.status < 300) sent++;
487 else console.error("notify: a push service answered", result.value.status);
488 }
489 return sent;
490 }
491
492 /** Pushes to every phone through Expo; drops the ones Expo says the app is gone from. */
493 private async pushDevices(notification: FeedNotification, payload: PushPayload): Promise<number> {
494 if (!this.phonePush()) return 0;
495 const tokens = this.sql.exec<{ token: string }>("SELECT token FROM devices").toArray().map((r) => r.token);
496 if (!tokens.length) return 0;
497 const result = await sendExpo(tokens, payload, notification, { accessToken: this.env.EXPO_ACCESS_TOKEN });
498 for (const token of result.gone) this.sql.exec("DELETE FROM devices WHERE token = ?", token);
499 for (const error of result.errors) console.error("notify: a push to a phone failed", error);
500 return result.sent;
501 }
502
503 // ── RPC (called by the Worker, src/index.ts) ────────────────────────────
504
505 async notify(value: unknown): Promise<{ ok: boolean }> {
506 const notification = cleanNotification(value);
507 if (!notification) return { ok: false };
508 await this.tell(notification);
509 return { ok: true };
510 }
511
512 /** A batch for this person from chat: counts moved, notifications told. */
513 async deliver(items: FeedDelivery[]): Promise<{ ok: boolean }> {
514 const changed = new Set<string>();
515 const told: FeedNotification[] = [];
516 for (const item of items) {
517 const workspace = String(item.workspace ?? "").toLowerCase();
518 if (item.counts?.channel_id && workspace) {
519 const before = this.sql
520 .exec<{ unread: number; mentions: number; muted: number }>(
521 "SELECT unread, mentions, muted FROM counts WHERE workspace = ? AND channel_id = ?",
522 workspace,
523 item.counts.channel_id,
524 )
525 .toArray()[0];
526 const after = applyCounts(
527 before ? { channel_id: item.counts.channel_id, unread: before.unread, mentions: before.mentions, muted: !!before.muted } : null,
528 item.counts,
529 );
530 this.sql.exec(
531 "INSERT OR REPLACE INTO counts (workspace, channel_id, unread, mentions, muted) VALUES (?, ?, ?, ?, ?)",
532 workspace,
533 after.channel_id,
534 after.unread,
535 after.mentions,
536 after.muted ? 1 : 0,
537 );
538 changed.add(workspace);
539 }
540 const notification = item.notification ? cleanNotification(item.notification) : null;
541 if (notification) told.push(notification);
542 }
543 for (const workspace of changed) this.send(this.counts(workspace));
544 await Promise.all(told.map((n) => this.tell(n)));
545 return { ok: true };
546 }
547
548 /** The inbox count as it now is (events, after items arrive or are marked): every tab shows it at once. */
549 async setInbox(value: number): Promise<{ ok: boolean }> {
550 const unread = Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
551 if (unread === this.inboxUnread()) return { ok: true };
552 this.setMeta("inbox_unread", String(unread));
553 this.send({ type: "inbox", unread });
554 return { ok: true };
555 }
556
557 async subscribe(subscription: PushSubscriptionJson, userAgent: string | null): Promise<{ ok: boolean }> {
558 const endpoint = String(subscription?.endpoint ?? "");
559 const { p256dh, auth } = subscription?.keys ?? ({} as PushSubscriptionJson["keys"]);
560 if (!/^https:\/\//.test(endpoint) || endpoint.length > MAX_ENDPOINT || typeof p256dh !== "string" || typeof auth !== "string") return { ok: false };
561 this.sql.exec(
562 "INSERT OR REPLACE INTO subscriptions (endpoint, p256dh, auth, user_agent, created_at) VALUES (?, ?, ?, ?, ?)",
563 endpoint,
564 p256dh.slice(0, 200),
565 auth.slice(0, 100),
566 userAgent ? userAgent.slice(0, 300) : null,
567 Date.now(),
568 );
569 // The oldest go first past the limit.
570 this.sql.exec("DELETE FROM subscriptions WHERE endpoint NOT IN (SELECT endpoint FROM subscriptions ORDER BY created_at DESC LIMIT ?)", MAX_SUBSCRIPTIONS);
571 return { ok: true };
572 }
573
574 async unsubscribe(endpoint: string): Promise<{ ok: boolean }> {
575 this.sql.exec("DELETE FROM subscriptions WHERE endpoint = ?", String(endpoint ?? ""));
576 return { ok: true };
577 }
578
579 /** A phone the app runs on, by its Expo push token; registering it again moves it to the newest. */
580 async registerDevice(device: PushDevice): Promise<{ ok: boolean; off?: boolean }> {
581 if (!this.phonePush()) return { ok: false, off: true };
582 const clean = cleanDevice(device);
583 if (!clean) return { ok: false };
584 this.sql.exec(
585 "INSERT OR REPLACE INTO devices (token, platform, name, created_at) VALUES (?, ?, ?, ?)",
586 clean.token,
587 clean.platform,
588 clean.name,
589 Date.now(),
590 );
591 // The oldest go first past the limit.
592 this.sql.exec("DELETE FROM devices WHERE token NOT IN (SELECT token FROM devices ORDER BY created_at DESC LIMIT ?)", MAX_DEVICES);
593 return { ok: true };
594 }
595
596 async unregisterDevice(token: string): Promise<{ ok: boolean }> {
597 this.sql.exec("DELETE FROM devices WHERE token = ?", String(token ?? ""));
598 return { ok: true };
599 }
600
601 async status(endpoint: string | null): Promise<NotifyStatus> {
602 const subscribed = !!endpoint && this.sql.exec("SELECT 1 FROM subscriptions WHERE endpoint = ?", endpoint).toArray().length > 0;
603 return {
604 preferences: this.preferences(),
605 subscriptions: this.subscriptionCount(),
606 devices: this.deviceCount(),
607 subscribed,
608 vapid_public_key: this.env.VAPID_PUBLIC_KEY || null,
609 };
610 }
611
612 async setPreferences(change: unknown): Promise<NotifyPreferences> {
613 const preferences = mergePreferences(this.preferences(), change);
614 this.setMeta("preferences", JSON.stringify(preferences));
615 this.send({ type: "preferences", preferences });
616 return preferences;
617 }
618
619 /** Tabs open in `workspace` hear how these people now show (from its room). */
620 async presence(workspace: string, people: PresenceEntry[]): Promise<void> {
621 const text = JSON.stringify({ type: "presence", workspace, people, full: false } satisfies FeedEvent);
622 for (const socket of this.ctx.getWebSockets()) {
623 const tab = socket.deserializeAttachment() as Tab | null;
624 if (tab?.workspace !== workspace) continue;
625 try {
626 socket.send(text);
627 } catch {
628 // Closing already.
629 }
630 }
631 }
632
633 /** The person's own: presence, status, Do Not Disturb. */
634 async own(person: { user_id: string; username: string }): Promise<OwnPresence> {
635 this.remember(person);
636 const kept = current(this.kept(), Date.now());
637 return ownOf(this.entry(), kept);
638 }
639
640 /** A change to the person's own, told to their tabs and to every workspace they are in. */
641 async setPresence(person: { user_id: string; username: string }, change: PresenceChange): Promise<OwnPresence> {
642 this.remember(person);
643 const kept = applyChange(this.kept(), change, Date.now());
644 this.setMeta("presence", JSON.stringify(kept));
645 const entry = await this.refresh();
646 return ownOf(entry, current(this.kept(), Date.now()));
647 }
648
649 private remember(person: { user_id: string; username: string }): void {
650 if (person.user_id) this.setMeta("user_id", person.user_id.slice(0, 100));
651 if (person.username) this.setMeta("username", person.username.slice(0, 100));
652 }
653
654 // ── Live repository pages ───────────────────────────────────────────────
655
656 private openSockets(): WebSocket[] {
657 return this.ctx.getWebSockets().filter((socket) => socket.readyState === WebSocket.OPEN);
658 }
659
660 /**
661 * The person's tabs hear what changes in repository `repoId` (`repo` is
662 * its `owner/name`) for REPO_WATCH_MS. The site checked they may read it.
663 */
664 async watchRepo(userId: string, repoId: string, repo: string): Promise<{ ok: boolean; until: string | null }> {
665 const topics = this.env.TOPICS;
666 if (!topics) return { ok: false, until: null };
667 const now = Date.now();
668 const until = watchUntil(now);
669 this.sql.exec("DELETE FROM watching WHERE until <= ?", now);
670 this.sql.exec("INSERT OR REPLACE INTO watching (repo_id, until) VALUES (?, ?)", repoId, until);
671 this.sql.exec("DELETE FROM watching WHERE repo_id NOT IN (SELECT repo_id FROM watching ORDER BY until DESC LIMIT ?)", MAX_WATCHING);
672 await topics.get(topics.idFromName(repoId)).watch(userId, repo, until);
673 return { ok: true, until: new Date(until).toISOString() };
674 }
675
676 /**
677 * What changed in a repository, from its topic: sent to the tabs, which
678 * each keep what is about the page they show. False when nobody here is
679 * watching it any more, so the topic drops this feed.
680 */
681 async repoChanged(repoId: string, changes: RepoChange[]): Promise<boolean> {
682 const row = this.sql.exec<{ until: number }>("SELECT until FROM watching WHERE repo_id = ?", repoId).toArray()[0];
683 if (!stillWatching(row?.until, Date.now())) return false;
684 const sockets = this.openSockets();
685 if (!sockets.length) return false;
686 const text = JSON.stringify({ type: "repo", changes } satisfies FeedEvent);
687 for (const socket of sockets) {
688 try {
689 socket.send(text);
690 } catch {
691 // Closing already.
692 }
693 }
694 return true;
695 }
696
697 private visit(workspace: string): Visit | null {
698 return this.sql.exec<Visit>("SELECT seen_at, previous_at FROM visits WHERE workspace = ?", workspace).toArray()[0] ?? null;
699 }
700
701 /** When the person was last on `workspace`'s Home page, as it counts from (src/visits.ts); null when never. */
702 async lastVisit(workspace: string): Promise<{ seen_at: string | null }> {
703 const since = lastVisit(this.visit(workspace), Date.now());
704 return { seen_at: since != null ? new Date(since).toISOString() : null };
705 }
706
707 /** Marks the person's visit to `workspace`'s Home page at `at`; it only moves forward. */
708 async markVisit(workspace: string, at: unknown): Promise<{ seen_at: string | null }> {
709 const now = Date.now();
710 const next = markVisit(this.visit(workspace), at, now);
711 if (next) {
712 this.sql.exec(
713 "INSERT INTO visits (workspace, seen_at, previous_at) VALUES (?, ?, ?) ON CONFLICT (workspace) DO UPDATE SET seen_at = excluded.seen_at, previous_at = excluded.previous_at",
714 workspace,
715 next.seen_at,
716 next.previous_at,
717 );
718 }
719 return this.lastVisit(workspace);
720 }
721
722 async test(username: string): Promise<{ ok: boolean; pushed: number }> {
723 const pushed = await this.tell(
724 {
725 id: `test:${crypto.randomUUID()}`,
726 kind: "dm",
727 workspace: "",
728 title: "g1t",
729 body: `Notifications are on, ${username || "there"}. This is what a message looks like.`,
730 href: "/settings/notifications",
731 actor: { kind: "system", id: "g1t", name: "g1t", avatar: null, avatar_seed: null },
732 channel_id: null,
733 thread_root: null,
734 created_at: new Date().toISOString(),
735 },
736 true,
737 );
738 return { ok: true, pushed };
739 }
740}