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.

Commit

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

Home's Summarize streamed g1t's answer into the card but never marked the direct message read, so the answer stayed unread in the sidebar, the Chat badge and the tab title, and popped up and sounded as it finished. - lib/read-when-shown.ts: the rules (read only while visible and on screen, each newest id once and only forward, an answer once it is finished) and the conversations this page shows. - lib/use-read-when-shown.ts: one hook every surface uses. It sends chat's read, fires g1t:chat-read, and while on screen counts the conversation as shown. The Ask panel's own read logic moved into it. - Home's Summarize uses it with the card in view (IntersectionObserver). - Toasts, sounds and desktop notifications leave a shown conversation alone, as they already did for the conversation open in Chat. - services/chat: the read mark update is markReadUpTo (src/read.ts), tested against chat's migrations. - Docs: Ask, Home and Chat guides.

G1t-Session:
ses_01m5c2pv0cejhsby6heeq1aqyy
syntaqxcommitted Parentcfcfeb9Browse files
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+}

Comments

Sign in to comment.