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.

An answer you watched is read where you watched it: Home's Summarize marks g1t's answer read and keeps it quiet (Closes #196) #211

Mergedsyntaqx merged 196-read-when-shown into main
17 files+346−350/17 viewed
+9−0
234234 it. Nobody else can read it, and it stays until you delete it or
235235 [clear it](#clear-the-conversation).
236236
237+An answer you watched is read wherever you watched it. While the Ask panel
238+is open, or [Home's summary](/guides/home/#what-changed) is in view, g1t's
239+answer counts as read once it is finished: it is not left unread in Chat's
240+sidebar, the Chat badge or the tab's title, on your other tabs, in the
241+desktop app or on your phone. Nor does it pop up, sound or show a desktop
242+notification on the page you are watching it on. A tab in the background
243+reads nothing, so an answer that finished while you were away waits for you
244+as unread.
245+
237246 - **New conversation** leaves a mark in the direct message. From then on,
238247 g1t reads only what comes after it, and the panel shows only that; what
239248 came before stays in Chat to read.
+2−1
571571 The rules behind them:
572572
573573 - Nothing sounds for the conversation you are looking at in a window that
574− is in front: what is on screen is silent.
574+ is in front: what is on screen is silent. That includes g1t's answer in
575+ the Ask panel or on Home's summary.
575576 - Nothing sounds for your own messages, in a conversation you
576577 [muted](#star-and-mute), or while Do not disturb is on.
577578 - At most one sound every 1.5 seconds: a burst of messages is one sound,
+4−0
233233 2. g1t's summary streams into the card as it is written. It reads g1t as
234234 you, as [Ask](/guides/ask/#what-it-can-read) does, so it covers only
235235 what you can see.
236+ Once it is finished, with the card in view, it counts as read, so it
237+ is not left unread in your direct message and makes no pop-up or
238+ sound (see
239+ [One conversation, in Ask and in Chat](/guides/ask/#one-conversation-in-ask-and-in-chat)).
236240 3. When it is finished:
237241 - **Ask a follow-up** opens the top bar's Ask on the same
238242 conversation, so you can ask about anything in the summary.
+3−20
66
77 import { askPage, askWhere } from "../lib/ask";
88 import { modelLabel } from "../lib/ask-model";
9+import { newestFinished } from "../lib/read-when-shown";
910 import { browserTimeZone } from "../lib/time-zone";
11+import { useReadWhenShown } from "../lib/use-read-when-shown";
1012 import { anyAnswering, applyLive, fromG1t, isClearCommand, suggestions } from "../lib/g1t-chat";
1113 import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton } from "./ai-elements/conversation";
1214 import { Message, MessageContent } from "./ai-elements/message";
147149 // Read up to the newest message while the panel is open and in view, as
148150 // the conversation in Chat does: it is the same direct message, so what
149151 // was read here is not left unread there.
150− const lastRead = useRef<string | null>(null);
151− useEffect(() => {
152− if (!open || !channelId) return;
153− const markRead = () => {
154− if (document.visibilityState !== "visible") return;
155− const newest = [...messages].reverse().find((m) => !m.pending);
156− if (!newest || newest.id === lastRead.current) return;
157− lastRead.current = newest.id;
158− void chatApi(slug, { intent: "read", channel_id: channelId, id: newest.id });
159− window.dispatchEvent(new CustomEvent("g1t:chat-read", { detail: channelId }));
160− };
161− const timer = setTimeout(markRead, 400);
162− document.addEventListener("visibilitychange", markRead);
163− window.addEventListener("focus", markRead);
164− return () => {
165− clearTimeout(timer);
166− document.removeEventListener("visibilitychange", markRead);
167− window.removeEventListener("focus", markRead);
168− };
169− }, [open, channelId, messages, slug]);
152+ useReadWhenShown(slug, channelId, newestFinished(messages), { active: open });
170153
171154 const ask = useCallback(
172155 async (text: string, pathname: string) => {
+7−1
1414 import { askPage } from "../lib/ask";
1515 import { answering, applyLive, fromG1t } from "../lib/g1t-chat";
1616 import { browserTimeZone } from "../lib/time-zone";
17+import { useReadWhenShown } from "../lib/use-read-when-shown";
1718 import { G1tAnswer } from "./chat/answer";
1819 import { useChatLive } from "./chat/live";
1920 import { Alert, AlertDescription } from "./ui/alert";
127128 return () => clearInterval(timer);
128129 }, [channelId, finished, load]);
129130
131+ // Watched here, so read here: the question is the viewer's own, and the
132+ // answer is marked once it is finished, while the card is in view.
133+ const card = useRef<HTMLDivElement>(null);
134+ useReadWhenShown(slug, channelId, finished && answer ? answer.id : null, { target: card });
135+
130136 const told = useRef(false);
131137 useEffect(() => {
132138 if (finished && !told.current) {
154160 }
155161
156162 return (
157− <div aria-live="polite" aria-busy={!finished}>
163+ <div ref={card} aria-live="polite" aria-busy={!finished}>
158164 {channelId && <LiveSocket slug={slug} channelId={channelId} onEvent={onEvent} onReconnect={() => void load()} />}
159165 {answer ? (
160166 <G1tAnswer message={answer} context={{ slug, repo: null }} />
+12−0
214214 assert.ok(!wantsDesktopToast({ ...base, notification: { kind: "inbox" } }));
215215 });
216216
217+test("an answer shown outside Chat (Home's Summarize, the Ask panel) is silent and shows no desktop notification while watched", () => {
218+ const watching = { visible: true, focused: true, viewingChannel: null, shown: new Set(["chn_dm"]) };
219+ assert.equal(cueFor(feed(notification()), ctx({ focus: watching })), null);
220+ assert.equal(cueFor(chat(message({ channel_id: "chn_dm" }), "dm"), ctx({ focus: watching })), null);
221+ assert.equal(cueFor(feed(notification({ channel_id: "chn_other" })), ctx({ focus: watching })), "direct", "another conversation still sounds");
222+ assert.equal(cueFor(feed(notification()), ctx({ focus: { ...watching, focused: false } })), "direct", "out of front, as for Chat");
223+ const base = { notification: { kind: "dm" as const, channel_id: "chn_dm" }, toast: true, permission: "granted" as const, pushOn: false };
224+ const settings = { desktop_toasts: true };
225+ assert.ok(!wantsDesktopToast({ ...base, ctx: ctx({ focus: { ...watching, focused: false } }, settings) }), "in view, if not in front");
226+ assert.ok(wantsDesktopToast({ ...base, ctx: ctx({ focus: { ...watching, visible: false, focused: false } }, settings) }), "a hidden tab watches nothing");
227+});
228+
217229 test("g1t's answer is silent while it thinks and streams, and pings once it is written", () => {
218230 const away = ctx({ focus: { visible: true, focused: false, viewingChannel: "chn_general" } });
219231 const answer = (state: "thinking" | "streaming" | "done") =>
+6−2
2929 focused: boolean;
3030 /** The conversation on screen, by channel id; null on any other page. */
3131 viewingChannel: string | null;
32+ /** Conversations shown besides, outside Chat: g1t's answer on Home, the Ask panel (lib/read-when-shown.ts). */
33+ shown?: ReadonlySet<string>;
3234 };
3335
3436 export type SoundContext = {
9496
9597 /** Whether the person is looking at the conversation right now: on screen, in a window in front. */
9698 export function lookingAt(channelId: string | null, focus: FocusState): boolean {
97− return channelId !== null && focus.visible && focus.focused && focus.viewingChannel === channelId;
99+ return channelId !== null && focus.visible && focus.focused && (focus.viewingChannel === channelId || !!focus.shown?.has(channelId));
98100 }
99101
100102 /** The cue an arrival earns, or null for silence. Every rule above, in order. */
158160 * notification once no tab is in front).
159161 */
160162 export function wantsDesktopToast(input: {
161− notification: Pick<FeedNotification, "kind">;
163+ notification: Pick<FeedNotification, "kind"> & { channel_id?: string | null };
162164 toast: boolean;
163165 ctx: Pick<SoundContext, "focus" | "dnd" | "settings">;
164166 permission: "default" | "granted" | "denied";
169171 if (!ctx.settings.desktop_toasts || input.permission !== "granted" || input.pushOn) return false;
170172 if (ctx.dnd || !input.toast) return false;
171173 if (ctx.focus.visible && ctx.focus.focused) return false;
174+ // Shown on a page in view, if not in front (an answer streaming on Home): it was watched.
175+ if (ctx.focus.visible && input.notification.channel_id && ctx.focus.shown?.has(input.notification.channel_id)) return false;
172176 return cueForNotificationKind(input.notification.kind) !== null;
173177 }
+11−1
4141 import type { DesktopSettingsBridge } from "./desktop-settings";
4242 import { openLive } from "./live-socket";
4343 import { dndOn, isIdle, mergePeople } from "./presence";
44+import { shownConversations } from "./read-when-shown";
4445 import { notificationArrived } from "./sound-events";
4546
4647 import {
5354 markReadLocally,
5455 offerPush,
5556 heldOpen,
57+ isViewing,
5658 reconnectDelay,
5759 titleWith,
5860 waitingCards,
232234 showToast(notification);
233235 return;
234236 }
237+ // Watched on a page that is showing, if not in front: not news.
238+ if (onScreenHere(notification)) return;
235239 bridge.notify({
236240 id: notification.id,
237241 title: notification.title,
391395 sendState();
392396 }
393397
398+/** Whether this tab, visible, shows the notification's conversation: on its page, or on a surface like Home's Summarize. */
399+function onScreenHere(notification: FeedNotification): boolean {
400+ if (typeof document === "undefined" || document.visibilityState !== "visible") return false;
401+ return isViewing(notification, { ...viewing, shown: shownConversations() });
402+}
403+
394404 function showToast(notification: FeedNotification): void {
395− const toasts = addToast(state.toasts, notification, viewing, Date.now());
405+ const toasts = addToast(state.toasts, notification, { ...viewing, shown: shownConversations() }, Date.now());
396406 if (toasts === state.toasts) return;
397407 const offer =
398408 state.offer ||
+8−0
7373 assert.equal(addToast([], note("1", { kind: "inbox", channel_id: null, href: "/acme/api/pull/4" }), { path: "/acme/api/pull/4" }, 1).length, 1);
7474 });
7575
76+test("no toast for a conversation another surface shows: g1t's answer on Home, the Ask panel", () => {
77+ const home = { path: "/acme", shown: new Set(["chn_1"]) };
78+ assert.ok(isViewing(note("1"), home));
79+ assert.deepEqual(addToast([], note("1"), home, 1), []);
80+ assert.equal(addToast([], note("2", { channel_id: "chn_2", href: "/acme/-/chat/dm/chn_2" }), home, 1).length, 1, "another conversation still toasts");
81+ assert.equal(addToast([], note("1"), { path: "/acme" }, 1).length, 1, "once the card is gone, it toasts again");
82+});
83+
7684 test("quick replies post to the conversation, in its thread when there is one", () => {
7785 assert.ok(canQuickReply(note("1")));
7886 assert.ok(!canQuickReply(note("1", { kind: "inbox" })));
+10−4
2323 return href.split(/[?#]/)[0].replace(/\/+$/, "").toLowerCase();
2424 }
2525
26+/** What the page shows: its path, the conversation open in Chat, and conversations shown besides (g1t's answer on Home). */
27+export type Viewing = { path: string; channel_id?: string | null; shown?: ReadonlySet<string> };
28+
2629 /**
2730 * Whether the person is looking at the conversation a notification is
28− * about right now: its page is open, or the chat page open says it shows
29− * that channel. A toast for it would only repeat what is on screen.
31+ * about right now: its page is open, the chat page open says it shows
32+ * that channel, or another surface on the page shows its messages (Home's
33+ * Summarize, the Ask panel). A toast for it would only repeat what is on
34+ * screen.
3035 */
31−export function isViewing(notification: FeedNotification, viewing: { path: string; channel_id?: string | null }): boolean {
36+export function isViewing(notification: FeedNotification, viewing: Viewing): boolean {
3237 if (notification.channel_id && viewing.channel_id && notification.channel_id === viewing.channel_id) return true;
3338 if (!notification.channel_id) return false;
39+ if (viewing.shown?.has(notification.channel_id)) return true;
3440 return pathOf(notification.href) === pathOf(viewing.path);
3541 }
3642
3844 * The toasts after one arrives: never twice, never for what is on screen,
3945 * newest last, at most `MAX_TOASTS`.
4046 */
41−export function addToast(toasts: Toast[], notification: FeedNotification, viewing: { path: string; channel_id?: string | null }, now: number): Toast[] {
47+export function addToast(toasts: Toast[], notification: FeedNotification, viewing: Viewing, now: number): Toast[] {
4248 if (toasts.some((t) => t.notification.id === notification.id)) return toasts;
4349 if (isViewing(notification, viewing)) return toasts;
4450 // A newer message in the same conversation replaces the older toast.
+41−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { newestFinished, readNow, showConversation, shownConversations } from "./read-when-shown.ts";
5+
6+const moment = { visible: true, onScreen: true, newestId: "msg_2", lastRead: null };
7+
8+test("messages are read only while the page is visible and they are on screen", () => {
9+ assert.equal(readNow(moment), "msg_2");
10+ assert.equal(readNow({ ...moment, visible: false }), null, "a hidden tab reads nothing");
11+ assert.equal(readNow({ ...moment, onScreen: false }), null, "the panel closed, the card scrolled away");
12+ assert.equal(readNow({ ...moment, newestId: null }), null, "nothing there yet");
13+});
14+
15+test("each newest id is read once, and only forward", () => {
16+ assert.equal(readNow({ ...moment, lastRead: "msg_2" }), null);
17+ assert.equal(readNow({ ...moment, lastRead: "msg_3" }), null);
18+ assert.equal(readNow({ ...moment, newestId: "msg_3", lastRead: "msg_2" }), "msg_3");
19+});
20+
21+test("an answer is read once it is finished, not while it streams", () => {
22+ const question = { id: "msg_1", answer: null, deleted_at: null };
23+ const answer = (state: "thinking" | "streaming" | "done") => ({ id: "msg_2", answer: { state } as never, deleted_at: null });
24+ assert.equal(newestFinished([question, answer("thinking")]), "msg_1");
25+ assert.equal(newestFinished([question, answer("streaming")]), "msg_1");
26+ assert.equal(newestFinished([question, answer("done")]), "msg_2");
27+ assert.equal(newestFinished([]), null);
28+ assert.equal(newestFinished([{ ...question, pending: true }]), null, "a question still being sent has no id to read");
29+ assert.equal(newestFinished([question, { id: "msg_3", answer: null, deleted_at: "2026-10-20T00:00:00Z" }]), "msg_1");
30+});
31+
32+test("a conversation is shown while any surface shows it", () => {
33+ const panel = showConversation("chn_dm");
34+ const card = showConversation("chn_dm");
35+ assert.ok(shownConversations().has("chn_dm"));
36+ panel();
37+ panel();
38+ assert.ok(shownConversations().has("chn_dm"), "the card still shows it; letting go twice counts once");
39+ card();
40+ assert.ok(!shownConversations().has("chn_dm"));
41+});
+71−0
1+/**
2+ * When messages shown outside their conversation count as read: g1t's
3+ * answer on Home's Summarize card, the top bar's Ask panel, or any other
4+ * page that shows a conversation's messages. Pure rules, and the record of
5+ * which conversations this page shows right now, so a toast, a sound or a
6+ * system notification does not repeat what is on screen (lib/notify-client,
7+ * lib/sound-events). The hook that uses them is lib/use-read-when-shown.ts.
8+ */
9+import type { ChatMessage } from "@g1t/contracts";
10+
11+/** What a surface knows when it might mark its conversation read. */
12+export type ReadMoment = {
13+ /** `document.visibilityState === "visible"`. */
14+ visible: boolean;
15+ /** The messages are on screen: the panel open, the card scrolled into view. */
16+ onScreen: boolean;
17+ /** The newest message shown and finished, or null while there is none. */
18+ newestId: string | null;
19+ /** The newest this surface has already marked read. */
20+ lastRead: string | null;
21+};
22+
23+/**
24+ * The message to mark read now, or null: only while the page is visible
25+ * and the messages are on screen, each newest id once, and only forward
26+ * (ids sort by time).
27+ */
28+export function readNow(moment: ReadMoment): string | null {
29+ if (!moment.visible || !moment.onScreen || !moment.newestId) return null;
30+ if (moment.lastRead !== null && moment.newestId <= moment.lastRead) return null;
31+ return moment.newestId;
32+}
33+
34+type Shown = Pick<ChatMessage, "id" | "answer" | "deleted_at"> & { pending?: boolean };
35+
36+/**
37+ * The newest message there to read: saved (not one still being sent) and
38+ * finished. An answer still being written is read once it is done, so the
39+ * mark lands on the final message, not before it exists.
40+ */
41+export function newestFinished(messages: readonly Shown[]): string | null {
42+ let newest: string | null = null;
43+ for (const m of messages) {
44+ if (m.pending || m.deleted_at) continue;
45+ if (m.answer?.state === "thinking" || m.answer?.state === "streaming") continue;
46+ if (newest === null || m.id > newest) newest = m.id;
47+ }
48+ return newest;
49+}
50+
51+// ── Conversations shown on this page ─────────────────────────────────────
52+
53+const shown = new Map<string, number>();
54+
55+/** This page shows `channelId`'s messages until the returned function is called. Counted: two surfaces may show the same one. */
56+export function showConversation(channelId: string): () => void {
57+ shown.set(channelId, (shown.get(channelId) ?? 0) + 1);
58+ let done = false;
59+ return () => {
60+ if (done) return;
61+ done = true;
62+ const left = (shown.get(channelId) ?? 1) - 1;
63+ if (left > 0) shown.set(channelId, left);
64+ else shown.delete(channelId);
65+ };
66+}
67+
68+/** The conversations this page shows right now, besides the one open in Chat. */
69+export function shownConversations(): ReadonlySet<string> {
70+ return new Set(shown.keys());
71+}
+2−0
2020
2121 import { type Arrival, type FocusState, SoundGate, arrivalKey, cueFor, wantsDesktopToast } from "./chat-sounds";
2222 import { claimPing } from "./one-tab";
23+import { shownConversations } from "./read-when-shown";
2324 import { play, soundSettings } from "./sounds";
2425
2526 let me: { id: string; username: string } | null = null;
5354 visible: has && document.visibilityState === "visible",
5455 focused: has && document.hasFocus(),
5556 viewingChannel: viewing,
57+ shown: shownConversations(),
5658 };
5759 }
5860
+67−0
1+import { type RefObject, useEffect, useRef, useState } from "react";
2+
3+import { readNow, showConversation } from "./read-when-shown";
4+
5+/**
6+ * Marks a conversation read up to `newestId` once its messages were seen
7+ * here: the page visible, the messages on screen (`target` in view, when
8+ * given; `active` false while the surface is closed), each newest id once.
9+ * It sends chat's `read`, which moves the read mark for every tab and
10+ * device, and fires `g1t:chat-read` so this tab's counts drop at once.
11+ * While on screen the conversation counts as shown, so its toasts, sounds
12+ * and system notifications stay quiet (lib/read-when-shown.ts).
13+ */
14+export function useReadWhenShown(
15+ slug: string,
16+ channelId: string | null,
17+ newestId: string | null,
18+ options: { target?: RefObject<Element | null>; active?: boolean } = {},
19+): void {
20+ const { target, active = true } = options;
21+ const [inView, setInView] = useState(!target);
22+
23+ useEffect(() => {
24+ const element = target?.current;
25+ if (!target || !element) return;
26+ if (typeof IntersectionObserver === "undefined") return setInView(true);
27+ const observer = new IntersectionObserver(([entry]) => setInView(!!entry?.isIntersecting));
28+ observer.observe(element);
29+ return () => observer.disconnect();
30+ }, [target, active, channelId]);
31+
32+ const onScreen = active && inView;
33+
34+ // Shown while on screen: what arrives for it here is not news.
35+ useEffect(() => {
36+ if (!channelId || !onScreen) return;
37+ return showConversation(channelId);
38+ }, [channelId, onScreen]);
39+
40+ const lastRead = useRef<{ channel: string | null; id: string | null }>({ channel: null, id: null });
41+ useEffect(() => {
42+ if (!channelId || !onScreen) return;
43+ if (lastRead.current.channel !== channelId) lastRead.current = { channel: channelId, id: null };
44+ const markRead = () => {
45+ const id = readNow({ visible: document.visibilityState === "visible", onScreen, newestId, lastRead: lastRead.current.id });
46+ if (!id) return;
47+ lastRead.current.id = id;
48+ void fetch(`/${slug}/-/chat/api`, {
49+ method: "POST",
50+ headers: { "content-type": "application/json" },
51+ body: JSON.stringify({ intent: "read", channel_id: channelId, id }),
52+ }).catch(() => {
53+ // Not marked: the next time it is shown tries again.
54+ if (lastRead.current.id === id) lastRead.current.id = null;
55+ });
56+ window.dispatchEvent(new CustomEvent("g1t:chat-read", { detail: channelId }));
57+ };
58+ const timer = setTimeout(markRead, 400);
59+ document.addEventListener("visibilitychange", markRead);
60+ window.addEventListener("focus", markRead);
61+ return () => {
62+ clearTimeout(timer);
63+ document.removeEventListener("visibilitychange", markRead);
64+ window.removeEventListener("focus", markRead);
65+ };
66+ }, [slug, channelId, newestId, onScreen]);
67+}
+2−6
100100 import { GENERAL, MAX_DM_MEMBERS, channelName, dmKey, dmMembers } from "./names.ts";
101101 import { ROOM_MEMBER_HEADER, type ChannelRoom, type RoomMember } from "./room.ts";
102102 import { mayCreateChannel, mayManageChannel, permissionsFor, rowFor, settingsChange, settingsOf, type SettingsRow } from "./settings.ts";
103+import { markReadUpTo } from "./read.ts";
103104 import { dmTitle, sidebarOrder, tally, type UnreadRow } from "./unread.ts";
104105 // Live notifications and counts (services/notify).
105106 import { notifyCleared, notifyMessage, notifyMuted, notifyRead } from "./notify.ts";
18861887 if (!member) return ok(null);
18871888 const id = typeof a.id === "string" ? a.id : "";
18881889 if (!id) return fail("invalid", "Say which message was read.");
1889− // Only forward: reading an old thread does not mark newer messages unread.
1890− const changed = await this.db
1891− .prepare("UPDATE channel_members SET last_read_id = ?1 WHERE channel_id = ?2 AND principal = ?3 AND (last_read_id IS NULL OR last_read_id < ?1)")
1892− .bind(id, channel.id, member.principal)
1893− .run();
1894− if (changed.meta.changes) {
1890+ if (await markReadUpTo(this.db, channel.id, member.principal, id)) {
18951891 this.broadcast(channel.id, {
18961892 type: "read",
18971893 channel_id: channel.id,
+72−0
1+import assert from "node:assert/strict";
2+import { readFileSync, readdirSync } from "node:fs";
3+import { DatabaseSync } from "node:sqlite";
4+import { test } from "node:test";
5+
6+import { G1T_KEY } from "@g1t/contracts/chat";
7+
8+import { markReadUpTo } from "./read.ts";
9+import { tally } from "./unread.ts";
10+
11+/** D1, as far as the read mark uses it, over node's SQLite with chat's migrations. */
12+function fakeD1(): D1Database & { raw: DatabaseSync } {
13+ const db = new DatabaseSync(":memory:");
14+ const dir = new URL("../migrations/", import.meta.url);
15+ for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8"));
16+ const statement = (sql: string, params: unknown[] = []): Record<string, unknown> => ({
17+ bind: (...values: unknown[]) => statement(sql, values),
18+ run: async () => ({ success: true, meta: { changes: Number(db.prepare(sql).run(...(params as never[])).changes) }, results: [] }),
19+ });
20+ return { raw: db, prepare: (sql: string) => statement(sql) } as unknown as D1Database & { raw: DatabaseSync };
21+}
22+
23+const AT = "2026-10-20T12:00:00.000Z";
24+const ANA = "user:u_ana";
25+
26+/** Ana's direct message with g1t: her question (msg_01) and g1t's answer (msg_02), unread from msg_00 on. */
27+function dm() {
28+ const d1 = fakeD1();
29+ d1.raw
30+ .prepare("INSERT INTO channels (id, workspace_id, kind, name, created_by, created_at, last_message_at) VALUES ('chn_dm', 'wsp_1', 'dm', NULL, ?, ?, ?)")
31+ .run(ANA, AT, AT);
32+ for (const principal of [ANA, G1T_KEY])
33+ d1.raw.prepare("INSERT INTO channel_members (channel_id, principal, last_read_id, joined_at) VALUES ('chn_dm', ?, 'msg_00', ?)").run(principal, AT);
34+ for (const [id, author] of [
35+ ["msg_01", ANA],
36+ ["msg_02", G1T_KEY],
37+ ])
38+ d1.raw.prepare("INSERT INTO messages (id, channel_id, author, body, created_at) VALUES (?, 'chn_dm', ?, 'text', ?)").run(id, author, AT);
39+ return d1;
40+}
41+
42+const mark = (d1: ReturnType<typeof dm>) =>
43+ (d1.raw.prepare("SELECT last_read_id FROM channel_members WHERE channel_id = 'chn_dm' AND principal = ?").get(ANA) as { last_read_id: string }).last_read_id;
44+
45+const unread = (d1: ReturnType<typeof dm>) => {
46+ const rows = d1.raw.prepare("SELECT channel_id, id, author, NULL AS mentions FROM messages WHERE id > ?").all(mark(d1)) as never[];
47+ return tally(rows, ANA, "ana", new Map([["chn_dm", mark(d1)]])).get("chn_dm")?.unread ?? 0;
48+};
49+
50+test("a read from any surface (Home's Summarize, the Ask panel, Chat) moves the read mark, and the answer is no longer unread", async () => {
51+ const d1 = dm();
52+ assert.equal(unread(d1), 1, "g1t's answer is unread; Ana's own question is not");
53+ assert.equal(await markReadUpTo(d1, "chn_dm", ANA, "msg_02"), true);
54+ assert.equal(mark(d1), "msg_02");
55+ assert.equal(unread(d1), 0);
56+});
57+
58+test("the read mark only moves forward, and says when it did not move", async () => {
59+ const d1 = dm();
60+ await markReadUpTo(d1, "chn_dm", ANA, "msg_02");
61+ assert.equal(await markReadUpTo(d1, "chn_dm", ANA, "msg_01"), false, "an older surface reading late does not mark the answer unread");
62+ assert.equal(await markReadUpTo(d1, "chn_dm", ANA, "msg_02"), false, "the same read twice is told once");
63+ assert.equal(mark(d1), "msg_02");
64+});
65+
66+test("a read marks only the reader's own place", async () => {
67+ const d1 = dm();
68+ assert.equal(await markReadUpTo(d1, "chn_dm", "user:u_ben", "msg_02"), false, "not a member: nothing moves");
69+ await markReadUpTo(d1, "chn_dm", ANA, "msg_02");
70+ const g1t = d1.raw.prepare("SELECT last_read_id FROM channel_members WHERE principal = ?").get(G1T_KEY) as { last_read_id: string };
71+ assert.equal(g1t.last_read_id, "msg_00");
72+});
+19−0
1+/**
2+ * The read mark: how far a member has read a conversation
3+ * (`channel_members.last_read_id`). Every surface that shows messages
4+ * marks them read through it, whichever it is: the conversation in Chat,
5+ * the Ask panel, Home's Summarize, the phone app.
6+ */
7+
8+/**
9+ * Moves `principal`'s read mark in `channelId` up to `id`. Only forward:
10+ * reading an old thread does not mark newer messages unread. Says whether
11+ * it moved, so the read is told to their tabs and devices only then.
12+ */
13+export async function markReadUpTo(db: D1Database, channelId: string, principal: string, id: string): Promise<boolean> {
14+ const changed = await db
15+ .prepare("UPDATE channel_members SET last_read_id = ?1 WHERE channel_id = ?2 AND principal = ?3 AND (last_read_id IS NULL OR last_read_id < ?1)")
16+ .bind(id, channelId, principal)
17+ .run();
18+ return changed.meta.changes > 0;
19+}