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