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