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
| 234 | 234 | it. Nobody else can read it, and it stays until you delete it or | |
| 235 | 235 | [clear it](#clear-the-conversation). | |
| 236 | 236 | ||
| 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 | + | ||
| 237 | 246 | - **New conversation** leaves a mark in the direct message. From then on, | |
| 238 | 247 | g1t reads only what comes after it, and the panel shows only that; what | |
| 239 | 248 | came before stays in Chat to read. |
| 571 | 571 | The rules behind them: | |
| 572 | 572 | ||
| 573 | 573 | - 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. | |
| 575 | 576 | - Nothing sounds for your own messages, in a conversation you | |
| 576 | 577 | [muted](#star-and-mute), or while Do not disturb is on. | |
| 577 | 578 | - At most one sound every 1.5 seconds: a burst of messages is one sound, |
| 233 | 233 | 2. g1t's summary streams into the card as it is written. It reads g1t as | |
| 234 | 234 | you, as [Ask](/guides/ask/#what-it-can-read) does, so it covers only | |
| 235 | 235 | 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)). | |
| 236 | 240 | 3. When it is finished: | |
| 237 | 241 | - **Ask a follow-up** opens the top bar's Ask on the same | |
| 238 | 242 | conversation, so you can ask about anything in the summary. |
| 6 | 6 | ||
| 7 | 7 | import { askPage, askWhere } from "../lib/ask"; | |
| 8 | 8 | import { modelLabel } from "../lib/ask-model"; | |
| 9 | + | import { newestFinished } from "../lib/read-when-shown"; | |
| 9 | 10 | import { browserTimeZone } from "../lib/time-zone"; | |
| 11 | + | import { useReadWhenShown } from "../lib/use-read-when-shown"; | |
| 10 | 12 | import { anyAnswering, applyLive, fromG1t, isClearCommand, suggestions } from "../lib/g1t-chat"; | |
| 11 | 13 | import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton } from "./ai-elements/conversation"; | |
| 12 | 14 | import { Message, MessageContent } from "./ai-elements/message"; | |
| ⋯ | |||
| 147 | 149 | // Read up to the newest message while the panel is open and in view, as | |
| 148 | 150 | // the conversation in Chat does: it is the same direct message, so what | |
| 149 | 151 | // 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 }); | |
| 170 | 153 | ||
| 171 | 154 | const ask = useCallback( | |
| 172 | 155 | async (text: string, pathname: string) => { | |
| 14 | 14 | import { askPage } from "../lib/ask"; | |
| 15 | 15 | import { answering, applyLive, fromG1t } from "../lib/g1t-chat"; | |
| 16 | 16 | import { browserTimeZone } from "../lib/time-zone"; | |
| 17 | + | import { useReadWhenShown } from "../lib/use-read-when-shown"; | |
| 17 | 18 | import { G1tAnswer } from "./chat/answer"; | |
| 18 | 19 | import { useChatLive } from "./chat/live"; | |
| 19 | 20 | import { Alert, AlertDescription } from "./ui/alert"; | |
| ⋯ | |||
| 127 | 128 | return () => clearInterval(timer); | |
| 128 | 129 | }, [channelId, finished, load]); | |
| 129 | 130 | ||
| 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 | + | ||
| 130 | 136 | const told = useRef(false); | |
| 131 | 137 | useEffect(() => { | |
| 132 | 138 | if (finished && !told.current) { | |
| ⋯ | |||
| 154 | 160 | } | |
| 155 | 161 | ||
| 156 | 162 | return ( | |
| 157 | − | <div aria-live="polite" aria-busy={!finished}> | |
| 163 | + | <div ref={card} aria-live="polite" aria-busy={!finished}> | |
| 158 | 164 | {channelId && <LiveSocket slug={slug} channelId={channelId} onEvent={onEvent} onReconnect={() => void load()} />} | |
| 159 | 165 | {answer ? ( | |
| 160 | 166 | <G1tAnswer message={answer} context={{ slug, repo: null }} /> | |
| 214 | 214 | assert.ok(!wantsDesktopToast({ ...base, notification: { kind: "inbox" } })); | |
| 215 | 215 | }); | |
| 216 | 216 | ||
| 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 | + | ||
| 217 | 229 | test("g1t's answer is silent while it thinks and streams, and pings once it is written", () => { | |
| 218 | 230 | const away = ctx({ focus: { visible: true, focused: false, viewingChannel: "chn_general" } }); | |
| 219 | 231 | const answer = (state: "thinking" | "streaming" | "done") => |
| 29 | 29 | focused: boolean; | |
| 30 | 30 | /** The conversation on screen, by channel id; null on any other page. */ | |
| 31 | 31 | 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>; | |
| 32 | 34 | }; | |
| 33 | 35 | ||
| 34 | 36 | export type SoundContext = { | |
| ⋯ | |||
| 94 | 96 | ||
| 95 | 97 | /** Whether the person is looking at the conversation right now: on screen, in a window in front. */ | |
| 96 | 98 | 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)); | |
| 98 | 100 | } | |
| 99 | 101 | ||
| 100 | 102 | /** The cue an arrival earns, or null for silence. Every rule above, in order. */ | |
| ⋯ | |||
| 158 | 160 | * notification once no tab is in front). | |
| 159 | 161 | */ | |
| 160 | 162 | export function wantsDesktopToast(input: { | |
| 161 | − | notification: Pick<FeedNotification, "kind">; | |
| 163 | + | notification: Pick<FeedNotification, "kind"> & { channel_id?: string | null }; | |
| 162 | 164 | toast: boolean; | |
| 163 | 165 | ctx: Pick<SoundContext, "focus" | "dnd" | "settings">; | |
| 164 | 166 | permission: "default" | "granted" | "denied"; | |
| ⋯ | |||
| 169 | 171 | if (!ctx.settings.desktop_toasts || input.permission !== "granted" || input.pushOn) return false; | |
| 170 | 172 | if (ctx.dnd || !input.toast) return false; | |
| 171 | 173 | 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; | |
| 172 | 176 | return cueForNotificationKind(input.notification.kind) !== null; | |
| 173 | 177 | } | |
| 41 | 41 | import type { DesktopSettingsBridge } from "./desktop-settings"; | |
| 42 | 42 | import { openLive } from "./live-socket"; | |
| 43 | 43 | import { dndOn, isIdle, mergePeople } from "./presence"; | |
| 44 | + | import { shownConversations } from "./read-when-shown"; | |
| 44 | 45 | import { notificationArrived } from "./sound-events"; | |
| 45 | 46 | ||
| 46 | 47 | import { | |
| ⋯ | |||
| 53 | 54 | markReadLocally, | |
| 54 | 55 | offerPush, | |
| 55 | 56 | heldOpen, | |
| 57 | + | isViewing, | |
| 56 | 58 | reconnectDelay, | |
| 57 | 59 | titleWith, | |
| 58 | 60 | waitingCards, | |
| ⋯ | |||
| 232 | 234 | showToast(notification); | |
| 233 | 235 | return; | |
| 234 | 236 | } | |
| 237 | + | // Watched on a page that is showing, if not in front: not news. | |
| 238 | + | if (onScreenHere(notification)) return; | |
| 235 | 239 | bridge.notify({ | |
| 236 | 240 | id: notification.id, | |
| 237 | 241 | title: notification.title, | |
| ⋯ | |||
| 391 | 395 | sendState(); | |
| 392 | 396 | } | |
| 393 | 397 | ||
| 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 | + | ||
| 394 | 404 | 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()); | |
| 396 | 406 | if (toasts === state.toasts) return; | |
| 397 | 407 | const offer = | |
| 398 | 408 | state.offer || | |
| 73 | 73 | assert.equal(addToast([], note("1", { kind: "inbox", channel_id: null, href: "/acme/api/pull/4" }), { path: "/acme/api/pull/4" }, 1).length, 1); | |
| 74 | 74 | }); | |
| 75 | 75 | ||
| 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 | + | ||
| 76 | 84 | test("quick replies post to the conversation, in its thread when there is one", () => { | |
| 77 | 85 | assert.ok(canQuickReply(note("1"))); | |
| 78 | 86 | assert.ok(!canQuickReply(note("1", { kind: "inbox" }))); |
| 23 | 23 | return href.split(/[?#]/)[0].replace(/\/+$/, "").toLowerCase(); | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 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 | + | ||
| 26 | 29 | /** | |
| 27 | 30 | * 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. | |
| 30 | 35 | */ | |
| 31 | − | export function isViewing(notification: FeedNotification, viewing: { path: string; channel_id?: string | null }): boolean { | |
| 36 | + | export function isViewing(notification: FeedNotification, viewing: Viewing): boolean { | |
| 32 | 37 | if (notification.channel_id && viewing.channel_id && notification.channel_id === viewing.channel_id) return true; | |
| 33 | 38 | if (!notification.channel_id) return false; | |
| 39 | + | if (viewing.shown?.has(notification.channel_id)) return true; | |
| 34 | 40 | return pathOf(notification.href) === pathOf(viewing.path); | |
| 35 | 41 | } | |
| 36 | 42 | ||
| ⋯ | |||
| 38 | 44 | * The toasts after one arrives: never twice, never for what is on screen, | |
| 39 | 45 | * newest last, at most `MAX_TOASTS`. | |
| 40 | 46 | */ | |
| 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[] { | |
| 42 | 48 | if (toasts.some((t) => t.notification.id === notification.id)) return toasts; | |
| 43 | 49 | if (isViewing(notification, viewing)) return toasts; | |
| 44 | 50 | // A newer message in the same conversation replaces the older toast. | |
| 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 | + | }); |
| 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 | + | } |
| 20 | 20 | ||
| 21 | 21 | import { type Arrival, type FocusState, SoundGate, arrivalKey, cueFor, wantsDesktopToast } from "./chat-sounds"; | |
| 22 | 22 | import { claimPing } from "./one-tab"; | |
| 23 | + | import { shownConversations } from "./read-when-shown"; | |
| 23 | 24 | import { play, soundSettings } from "./sounds"; | |
| 24 | 25 | ||
| 25 | 26 | let me: { id: string; username: string } | null = null; | |
| ⋯ | |||
| 53 | 54 | visible: has && document.visibilityState === "visible", | |
| 54 | 55 | focused: has && document.hasFocus(), | |
| 55 | 56 | viewingChannel: viewing, | |
| 57 | + | shown: shownConversations(), | |
| 56 | 58 | }; | |
| 57 | 59 | } | |
| 58 | 60 | ||
| 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 | + | } |
| 100 | 100 | import { GENERAL, MAX_DM_MEMBERS, channelName, dmKey, dmMembers } from "./names.ts"; | |
| 101 | 101 | import { ROOM_MEMBER_HEADER, type ChannelRoom, type RoomMember } from "./room.ts"; | |
| 102 | 102 | import { mayCreateChannel, mayManageChannel, permissionsFor, rowFor, settingsChange, settingsOf, type SettingsRow } from "./settings.ts"; | |
| 103 | + | import { markReadUpTo } from "./read.ts"; | |
| 103 | 104 | import { dmTitle, sidebarOrder, tally, type UnreadRow } from "./unread.ts"; | |
| 104 | 105 | // Live notifications and counts (services/notify). | |
| 105 | 106 | import { notifyCleared, notifyMessage, notifyMuted, notifyRead } from "./notify.ts"; | |
| ⋯ | |||
| 1886 | 1887 | if (!member) return ok(null); | |
| 1887 | 1888 | const id = typeof a.id === "string" ? a.id : ""; | |
| 1888 | 1889 | 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)) { | |
| 1895 | 1891 | this.broadcast(channel.id, { | |
| 1896 | 1892 | type: "read", | |
| 1897 | 1893 | channel_id: channel.id, | |
| 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 | + | }); |
| 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.