| 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 | } |