17 files+346−350/17 viewed
| 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.