diff --git a/app/components/channel/use-read-marker.ts b/app/components/channel/use-read-marker.ts index 09a96a1..10494d1 100644 --- a/app/components/channel/use-read-marker.ts +++ b/app/components/channel/use-read-marker.ts @@ -50,18 +50,21 @@ export function useReadMarker(channelId: string, items: Item[], ready: boolean, // The mark as it was when this visit started: the line is drawn from it and // stays put, however far the reader gets afterwards. Read after paint, not // during render — the server has no localStorage, and a marker that differed - // between the two renders would be a hydration mismatch. + // between the two renders would be a hydration mismatch. Captured on mount, + // ahead of the effects below that advance it. + const markAtStart = useRef(null) + useEffect(() => { + const stored = storedSeq(channelId) + markAtStart.current = stored + readUpTo.current = Math.max(readUpTo.current, stored) + }, [channelId]) + useEffect(() => { if (!ready) return let cancelled = false void Promise.resolve().then(() => { if (cancelled) return - setMarkerAt((current) => { - if (current !== null) return current - const stored = storedSeq(channelId) - readUpTo.current = stored - return stored - }) + setMarkerAt((current) => current ?? markAtStart.current ?? storedSeq(channelId)) }) return () => { cancelled = true diff --git a/app/components/transcript.test.tsx b/app/components/transcript.test.tsx new file mode 100644 index 0000000..5d4c16c --- /dev/null +++ b/app/components/transcript.test.tsx @@ -0,0 +1,45 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { describe, expect, it } from 'vitest' +import { Transcript, unreadLineBefore, type TranscriptItem } from './transcript' + +const items: TranscriptItem[] = [ + { seq: 1, type: 'system', text: 'agent-p joined' }, + { seq: 2, type: 'message', from: { name: 'agent-p', role: 'agent' }, time: '01:14', text: 'first' }, + { seq: 3, type: 'system', text: 'agent-q joined' }, + { seq: 4, type: 'message', from: { name: 'agent-q', role: 'agent' }, time: '01:15', text: 'second' }, +] + +describe('unreadLineBefore', () => { + it('puts the line before the first item past the mark', () => { + expect(unreadLineBefore(items, 2)).toBe(3) + }) + + it('draws nothing while the mark is unknown', () => { + expect(unreadLineBefore(items, null)).toBeUndefined() + }) + + it('draws nothing when everything has been read', () => { + expect(unreadLineBefore(items, 4)).toBeUndefined() + }) + + it('draws nothing when nothing has been, since a line at the top marks nothing', () => { + expect(unreadLineBefore(items, 0)).toBeUndefined() + }) + + it('skips items without a seq when looking for the first unread one', () => { + const drafts: TranscriptItem[] = [ + ...items, + { type: 'message', from: { name: 'me', role: 'human' }, time: '', text: 'x' }, + ] + expect(unreadLineBefore(drafts, 4)).toBeUndefined() + }) +}) + +describe('Transcript', () => { + it('draws the line once, directly before the first unread item', () => { + const html = renderToStaticMarkup() + expect(html.match(/New messages below/g)).toHaveLength(1) + expect(html.indexOf('New messages below')).toBeGreaterThan(html.indexOf('first')) + expect(html.indexOf('New messages below')).toBeLessThan(html.indexOf('agent-q joined')) + }) +}) diff --git a/app/components/transcript.tsx b/app/components/transcript.tsx index 410d923..92beb08 100644 --- a/app/components/transcript.tsx +++ b/app/components/transcript.tsx @@ -174,6 +174,13 @@ function UnreadLine() { ) } +/** Nothing above the first item has been read, so a line there would mark nothing. */ +export function unreadLineBefore(items: TranscriptItem[], unreadAfter: number | null): number | undefined { + if (unreadAfter === null) return undefined + const index = items.findIndex((item) => item.seq !== undefined && item.seq > unreadAfter) + return index > 0 ? items[index].seq : undefined +} + export function Transcript({ items, animate = false, @@ -201,8 +208,7 @@ export function Transcript({ name: person.name, colour: colorFor(person.name, person.role), })) - const firstUnread = - unreadAfter === null ? undefined : items.find((item) => item.seq !== undefined && item.seq > unreadAfter)?.seq + const firstUnread = unreadLineBefore(items, unreadAfter) return (
    {items.map((item, i) => {