Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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