From cb8623916a70eb6df41915b51a1125e1b7d002c6 Mon Sep 17 00:00:00 2001 From: David S D Date: Tue, 29 Sep 2026 01:10:56 +0530 Subject: [PATCH 01/15] Add the visited-channel store Closes #67. The list of channels this browser has opened, under one localStorage key, as pure functions over a list and a clock: record upserts, orders by last seen, prunes expired entries and caps at 20; live is the read filter, so an expired room is gone without a request; parse drops whatever it cannot read and never throws. expiresAt is epoch ms, not seconds as the issue sketched: the client gets expires_at as an ISO string, and one unit for both timestamps and the clock they are compared against leaves no conversion to get wrong. Co-Authored-By: Claude Opus 5.5 --- lib/visited-channels.test.ts | 133 +++++++++++++++++++++++++++++++++++ lib/visited-channels.ts | 77 ++++++++++++++++++++ 2 files changed, 210 insertions(+) create mode 100644 lib/visited-channels.test.ts create mode 100644 lib/visited-channels.ts diff --git a/lib/visited-channels.test.ts b/lib/visited-channels.test.ts new file mode 100644 index 0000000..fd41f1e --- /dev/null +++ b/lib/visited-channels.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest' +import { VISITED_CAP, forget, live, parse, record, type VisitedChannel } from './visited-channels' + +const now = Date.parse('2026-09-29T12:00:00Z') +const hour = 3_600_000 + +const channel = (id: string, overrides: Partial = {}): VisitedChannel => ({ + id, + invite: `inv-${id}`, + name: `room ${id}`, + expiresAt: now + hour, + lastSeenAt: now - hour, + ...overrides, +}) + +const entry = (id: string, overrides: Partial = {}) => { + const { lastSeenAt: _, ...rest } = channel(id, overrides) + return rest +} + +describe('record', () => { + it('adds a channel it has not seen, stamped with now', () => { + expect(record([], entry('a'), now)).toEqual([{ ...entry('a'), lastSeenAt: now }]) + }) + + it('upserts by id rather than duplicating, and takes the newer name and invite', () => { + const list = record([channel('a')], entry('a', { name: 'renamed', invite: 'fresh' }), now) + expect(list).toHaveLength(1) + expect(list[0]).toMatchObject({ name: 'renamed', invite: 'fresh', lastSeenAt: now }) + }) + + it('orders by lastSeenAt, most recent first', () => { + const list = [channel('old', { lastSeenAt: now - 3 * hour }), channel('mid', { lastSeenAt: now - 2 * hour })] + expect(record(list, entry('new'), now).map((c) => c.id)).toEqual(['new', 'mid', 'old']) + }) + + it('moves a revisited channel to the front', () => { + const list = [channel('a', { lastSeenAt: now - hour }), channel('b', { lastSeenAt: now - 2 * hour })] + expect(record(list, entry('b'), now).map((c) => c.id)).toEqual(['b', 'a']) + }) + + it('caps the list, dropping the least recently seen', () => { + const list = Array.from({ length: VISITED_CAP }, (_, i) => channel(`c${i}`, { lastSeenAt: now - (i + 1) * 1000 })) + const next = record(list, entry('new'), now) + expect(next).toHaveLength(VISITED_CAP) + expect(next[0].id).toBe('new') + expect(next.map((c) => c.id)).not.toContain(`c${VISITED_CAP - 1}`) + }) + + it('prunes expired entries while it writes', () => { + const list = [channel('gone', { expiresAt: now - 1 }), channel('kept')] + expect(record(list, entry('a'), now).map((c) => c.id)).toEqual(['a', 'kept']) + }) +}) + +describe('forget', () => { + it('removes the channel and nothing else', () => { + expect(forget([channel('a'), channel('b')], 'a').map((c) => c.id)).toEqual(['b']) + }) + + it('is a no-op for an id it does not hold', () => { + const list = [channel('a')] + expect(forget(list, 'nope')).toEqual(list) + }) +}) + +describe('live', () => { + it('drops an entry at or past its expiry, going only by the clock it is given', () => { + const list = [channel('past', { expiresAt: now - 1 }), channel('edge', { expiresAt: now }), channel('future')] + expect(live(list, now).map((c) => c.id)).toEqual(['future']) + }) + + it('keeps the same entry when asked at an earlier time', () => { + const list = [channel('a', { expiresAt: now })] + expect(live(list, now - 1)).toEqual(list) + }) +}) + +describe('parse', () => { + it('reads back what record wrote', () => { + const list = record([channel('b')], entry('a'), now) + expect(parse(JSON.stringify(list))).toEqual(list) + }) + + it.each([ + ['nothing stored', null], + ['an empty string', ''], + ['bad JSON', '{"id":'], + ['an object', '{"id":"a"}'], + ['a number', '42'], + ['null', 'null'], + ])('returns an empty list for %s', (_, raw) => { + expect(parse(raw)).toEqual([]) + }) + + it('drops a malformed entry and keeps the rest', () => { + const good = channel('good') + const raw = JSON.stringify([ + good, + { ...channel('no-invite'), invite: undefined }, + { ...channel('empty-invite'), invite: '' }, + { ...channel('no-id'), id: undefined }, + { ...channel('string-expiry'), expiresAt: '2026-09-29T13:00:00Z' }, + { ...channel('string-seen'), lastSeenAt: 'yesterday' }, + null, + 'a string', + 7, + [], + ]) + expect(parse(raw)).toEqual([good]) + }) + + it('treats a missing or non-string name as unnamed rather than dropping the entry', () => { + const raw = JSON.stringify([ + { ...channel('a'), name: undefined }, + { ...channel('b'), name: 3 }, + ]) + expect(parse(raw).map((c) => [c.id, c.name])).toEqual([ + ['a', ''], + ['b', ''], + ]) + }) + + it('keeps the first of two entries with the same id', () => { + const raw = JSON.stringify([channel('a', { name: 'first' }), channel('a', { name: 'second' })]) + expect(parse(raw)).toEqual([channel('a', { name: 'first' })]) + }) + + it('does not carry unknown fields through', () => { + const raw = JSON.stringify([{ ...channel('a'), preview: 'message text' }]) + expect(parse(raw)[0]).not.toHaveProperty('preview') + }) +}) diff --git a/lib/visited-channels.ts b/lib/visited-channels.ts new file mode 100644 index 0000000..0290825 --- /dev/null +++ b/lib/visited-channels.ts @@ -0,0 +1,77 @@ +export type VisitedChannel = { + id: string + invite: string + name: string + /** Epoch ms. The entry dies with the room. */ + expiresAt: number + /** Epoch ms. Ordering only. */ + lastSeenAt: number +} + +export const VISITED_KEY = 'wave.channels' +export const VISITED_CAP = 20 + +export function live(list: VisitedChannel[], now: number): VisitedChannel[] { + return list.filter((entry) => entry.expiresAt > now) +} + +export function record( + list: VisitedChannel[], + entry: Omit, + now: number, +): VisitedChannel[] { + const rest = list.filter((existing) => existing.id !== entry.id) + return live([{ ...entry, lastSeenAt: now }, ...rest], now) + .sort((a, b) => b.lastSeenAt - a.lastSeenAt) + .slice(0, VISITED_CAP) +} + +export function forget(list: VisitedChannel[], id: string): VisitedChannel[] { + return list.filter((entry) => entry.id !== id) +} + +const text = (value: unknown): value is string => typeof value === 'string' && value.length > 0 +const time = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value) + +function entryFrom(value: unknown): VisitedChannel | null { + if (typeof value !== 'object' || value === null) return null + const { id, invite, name, expiresAt, lastSeenAt } = value as Record + if (!text(id) || !text(invite) || !time(expiresAt) || !time(lastSeenAt)) return null + return { id, invite, name: typeof name === 'string' ? name : '', expiresAt, lastSeenAt } +} + +export function parse(raw: string | null): VisitedChannel[] { + if (raw === null) return [] + let value: unknown + try { + value = JSON.parse(raw) + } catch { + return [] + } + if (!Array.isArray(value)) return [] + const seen = new Set() + const list: VisitedChannel[] = [] + for (const item of value) { + const entry = entryFrom(item) + if (entry === null || seen.has(entry.id)) continue + seen.add(entry.id) + list.push(entry) + } + return list +} + +export function readVisited(): VisitedChannel[] { + try { + return parse(window.localStorage.getItem(VISITED_KEY)) + } catch { + return [] + } +} + +export function writeVisited(list: VisitedChannel[]): void { + try { + window.localStorage.setItem(VISITED_KEY, JSON.stringify(list)) + } catch { + // Private browsing: the list simply does not persist. + } +} From 6b02e9da973731ea9339152833c8e4550901b718 Mon Sep 17 00:00:00 2001 From: David S D Date: Tue, 29 Sep 2026 01:14:14 +0530 Subject: [PATCH 02/15] Record and forget visited channels Closes #68. A channel enters the list when bootstrap succeeds, the one point where it is known to be real and this browser holds an invite that opens it, and leaves it on either 410 and on close. A link that does not open never enters it. React reads it through useSyncExternalStore. storage only fires in other tabs, so a write also dispatches its own event for the tab that made it. The snapshot is cached by the raw stored string, and the server snapshot is one constant empty array, so neither side re-renders in a loop. Checked in two tabs against the dev server: opening adds, reloading moves lastSeenAt without duplicating, closing drops it in both tabs, and the closed room's link does not add it back. Private browsing is covered by the unit tests with a throwing localStorage. Co-Authored-By: Claude Opus 5.5 --- app/components/channel/use-channel.ts | 10 ++ .../channel/use-visited-channels.test.ts | 123 ++++++++++++++++++ .../channel/use-visited-channels.ts | 66 ++++++++++ 3 files changed, 199 insertions(+) create mode 100644 app/components/channel/use-visited-channels.test.ts create mode 100644 app/components/channel/use-visited-channels.ts diff --git a/app/components/channel/use-channel.ts b/app/components/channel/use-channel.ts index 81e3a57..17b7cf6 100644 --- a/app/components/channel/use-channel.ts +++ b/app/components/channel/use-channel.ts @@ -1,6 +1,7 @@ 'use client' import { useCallback, useEffect, useRef, useState } from 'react' +import { forgetChannel, rememberChannel } from './use-visited-channels' /** * The channel page's connection to its channel (ARCHITECTURE section 6). @@ -281,6 +282,7 @@ export function useChannel(channelId: string) { headers: { authorization: `Bearer ${admin}` }, }) if (!response.ok) throw new Error(await readError(response)) + forgetChannel(channelId) setStatus('gone') }, [channelId]) @@ -316,6 +318,7 @@ export function useChannel(channelId: string) { signal: controller.signal, }) if (response.status === 410) { + forgetChannel(channelId) setStatus('gone') return false } @@ -326,6 +329,12 @@ export function useChannel(channelId: string) { } const view = await response.json() setChannel(view.channel) + rememberChannel({ + id: channelId, + invite: invite.current ?? '', + name: view.channel.name ?? '', + expiresAt: Date.parse(view.channel.expires_at), + }) setParticipants(view.participants) setHistoryUpTo(view.last_seq) setLastSeq(view.last_seq) @@ -343,6 +352,7 @@ export function useChannel(channelId: string) { { headers: { authorization: `Bearer ${token()}` }, signal: controller.signal }, ) if (response.status === 410) { + forgetChannel(channelId) setStatus('gone') return 'gone' } diff --git a/app/components/channel/use-visited-channels.test.ts b/app/components/channel/use-visited-channels.test.ts new file mode 100644 index 0000000..91bbc51 --- /dev/null +++ b/app/components/channel/use-visited-channels.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { VISITED_KEY } from '@/lib/visited-channels' +import { forgetChannel, rememberChannel, snapshot, subscribe } from './use-visited-channels' + +const now = Date.parse('2026-09-29T12:00:00Z') +const room = (id: string) => ({ id, invite: `inv-${id}`, name: `room ${id}`, expiresAt: now + 3_600_000 }) + +function fakeWindow({ throwing = false } = {}) { + const store = new Map() + const events = new EventTarget() + const localStorage = { + getItem: (key: string) => { + if (throwing) throw new Error('SecurityError') + return store.get(key) ?? null + }, + setItem: (key: string, value: string) => { + if (throwing) throw new Error('QuotaExceededError') + store.set(key, value) + }, + } + return Object.assign(events, { localStorage, store }) +} + +let win: ReturnType + +beforeEach(() => { + win = fakeWindow() + vi.stubGlobal('window', win) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('rememberChannel', () => { + it('writes the channel to the one key', () => { + rememberChannel(room('a'), now) + expect(JSON.parse(win.store.get(VISITED_KEY)!)).toEqual([{ ...room('a'), lastSeenAt: now }]) + }) + + it('does not duplicate on a reload, and moves lastSeenAt', () => { + rememberChannel(room('a'), now) + rememberChannel(room('a'), now + 5_000) + expect(snapshot()).toEqual([{ ...room('a'), lastSeenAt: now + 5_000 }]) + }) + + it('records nothing for a channel whose expiry cannot be read', () => { + rememberChannel({ ...room('a'), expiresAt: Number.NaN }, now) + expect(snapshot()).toEqual([]) + }) + + it('records nothing without an invite, since the entry could not open the room', () => { + rememberChannel({ ...room('a'), invite: '' }, now) + expect(snapshot()).toEqual([]) + }) +}) + +describe('forgetChannel', () => { + it('removes the channel and keeps the rest', () => { + rememberChannel(room('a'), now) + rememberChannel(room('b'), now + 1) + forgetChannel('a') + expect(snapshot().map((c) => c.id)).toEqual(['b']) + }) +}) + +describe('subscribe', () => { + it('hears a write made in this tab', () => { + const onChange = vi.fn() + const unsubscribe = subscribe(onChange) + rememberChannel(room('a'), now) + forgetChannel('a') + expect(onChange).toHaveBeenCalledTimes(2) + unsubscribe() + }) + + it('hears a write made in another tab, and ignores other keys', () => { + const onChange = vi.fn() + const unsubscribe = subscribe(onChange) + const storage = (key: string | null) => Object.assign(new Event('storage'), { key }) + win.dispatchEvent(storage('wave.read.a')) + expect(onChange).not.toHaveBeenCalled() + win.dispatchEvent(storage(VISITED_KEY)) + win.dispatchEvent(storage(null)) + expect(onChange).toHaveBeenCalledTimes(2) + unsubscribe() + }) + + it('stops listening once unsubscribed', () => { + const onChange = vi.fn() + subscribe(onChange)() + rememberChannel(room('a'), now) + expect(onChange).not.toHaveBeenCalled() + }) +}) + +describe('snapshot', () => { + it('returns the same array until the stored value changes', () => { + rememberChannel(room('a'), now) + const first = snapshot() + expect(snapshot()).toBe(first) + rememberChannel(room('b'), now + 1) + expect(snapshot()).not.toBe(first) + }) + + it('returns the same empty array every time when nothing is stored', () => { + expect(snapshot()).toBe(snapshot()) + }) +}) + +describe('with storage throwing, as in private browsing', () => { + beforeEach(() => { + win = fakeWindow({ throwing: true }) + vi.stubGlobal('window', win) + }) + + it('remembers and forgets without throwing, and reads back as empty', () => { + expect(() => rememberChannel(room('a'), now)).not.toThrow() + expect(() => forgetChannel('a')).not.toThrow() + expect(snapshot()).toEqual([]) + expect(snapshot()).toBe(snapshot()) + }) +}) diff --git a/app/components/channel/use-visited-channels.ts b/app/components/channel/use-visited-channels.ts new file mode 100644 index 0000000..9d08614 --- /dev/null +++ b/app/components/channel/use-visited-channels.ts @@ -0,0 +1,66 @@ +'use client' + +import { useSyncExternalStore } from 'react' +import { + VISITED_KEY, + forget, + parse, + readVisited, + record, + writeVisited, + type VisitedChannel, +} from '@/lib/visited-channels' + +/** `storage` only reaches other tabs, so a write announces itself to its own. */ +const CHANGED = 'wave:channels-changed' +const EMPTY: VisitedChannel[] = [] + +let lastRaw: string | null = null +let lastList: VisitedChannel[] = EMPTY + +export function snapshot(): VisitedChannel[] { + let raw: string | null + try { + raw = window.localStorage.getItem(VISITED_KEY) + } catch { + return EMPTY + } + if (raw !== lastRaw) { + lastRaw = raw + lastList = parse(raw) + } + return lastList +} + +const serverSnapshot = () => EMPTY + +export function subscribe(onChange: () => void): () => void { + const onStorage = (event: StorageEvent) => { + if (event.key === VISITED_KEY || event.key === null) onChange() + } + window.addEventListener('storage', onStorage) + window.addEventListener(CHANGED, onChange) + return () => { + window.removeEventListener('storage', onStorage) + window.removeEventListener(CHANGED, onChange) + } +} + +function update(change: (list: VisitedChannel[]) => VisitedChannel[]): void { + writeVisited(change(readVisited())) + window.dispatchEvent(new Event(CHANGED)) +} + +export function rememberChannel(entry: Omit, now = Date.now()): void { + if (!entry.invite || !Number.isFinite(entry.expiresAt)) return + update((list) => record(list, entry, now)) +} + +export function forgetChannel(id: string): void { + update((list) => forget(list, id)) +} + +/** Unfiltered by expiry: the reader applies `live()` against its own clock. */ +export function useVisitedChannels(): VisitedChannel[] { + return useSyncExternalStore(subscribe, snapshot, serverSnapshot) +} From 225dea8c62259204b66a113ee63dc9e50c8f1ecd Mon Sep 17 00:00:00 2001 From: David S D Date: Tue, 29 Sep 2026 01:17:18 +0530 Subject: [PATCH 03/15] Key the channel view by channel id Closes #69. The bug #69 describes does not happen today. Checked against the dev server with router.push and history.back between two channels: the App Router already remounts the page when the id segment changes, so the first poll after a switch asks after=0, the transcript and roster are the new channel's alone, and the hash is committed before recall() reads it. The key makes that reset explicit rather than a property of how the router caches segments, so it survives the view moving under a shared layout, which is where a channels pane would pull it. The unread line could not be checked across a switch, because it is not drawn on a hard load either: the head is stored in the same commit that sets ready, before the marker reads it back. That is filed separately. Co-Authored-By: Claude Opus 5.5 --- app/c/[id]/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/c/[id]/page.tsx b/app/c/[id]/page.tsx index 7fd89a8..32f2085 100644 --- a/app/c/[id]/page.tsx +++ b/app/c/[id]/page.tsx @@ -22,5 +22,5 @@ export async function generateMetadata({ params }: PageProps<'/c/[id]'>): Promis export default async function ChannelPage({ params }: PageProps<'/c/[id]'>) { const { id } = await params - return + return } From 1004a88f5c9d78093e24b9c7415be58a9caa1fd0 Mon Sep 17 00:00:00 2001 From: David S D Date: Tue, 29 Sep 2026 01:30:32 +0530 Subject: [PATCH 04/15] Add the channels pane Closes #70. Every live channel this browser has opened, as one line each: a bold # and the name. The current room is marked and inert; any other row switches to it, and a forget control drops it from this browser with a five-second Undo. Expiry is left off the rows, and a room past it is filtered out on render without a request. The pane lives in a layout for /c rather than in the page, so switching channels swaps the conversation and the room beside it while the pane and its scroll position stay put. The logo moves to the top of the pane on a computer for the same reason. On a phone the list sits at the top of the channel menu. Rows are Link, not : #69 showed the fragment is committed before the next page reads its invite. Prefetch is off, since every visible row would otherwise be a request to the server. Checked on the dev server at 1440, 1024 and 375: switching keeps the pane's DOM node and moves the current row at once, a forgotten room still opens from its link, an expired entry disappears with no request, and picking a channel from the phone menu closes the sheet. Co-Authored-By: Claude Opus 5.5 --- app/c/layout.tsx | 10 ++ app/components/channel/channel-list.tsx | 146 ++++++++++++++++++++++++ app/components/channel/channel-view.tsx | 27 ++++- 3 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 app/c/layout.tsx create mode 100644 app/components/channel/channel-list.tsx diff --git a/app/c/layout.tsx b/app/c/layout.tsx new file mode 100644 index 0000000..b93bb4c --- /dev/null +++ b/app/c/layout.tsx @@ -0,0 +1,10 @@ +import { ChannelsPane } from '@/app/components/channel/channel-list' + +export default function ChannelLayout({ children }: LayoutProps<'/c'>) { + return ( +
+ +
{children}
+
+ ) +} diff --git a/app/components/channel/channel-list.tsx b/app/components/channel/channel-list.tsx new file mode 100644 index 0000000..4b949c5 --- /dev/null +++ b/app/components/channel/channel-list.tsx @@ -0,0 +1,146 @@ +'use client' + +import Link from 'next/link' +import { useParams } from 'next/navigation' +import { useEffect, useRef, useState } from 'react' +import { live, type VisitedChannel } from '@/lib/visited-channels' +import { CloseIcon } from '../icons' +import { Logo } from '../logo' +import { forgetChannel, rememberChannel, useVisitedChannels } from './use-visited-channels' + +const UNDO_MS = 5_000 + +function RowName({ name, className }: { name: string; className: string }) { + return ( + + + # + + {name || 'unnamed-channel'} + + ) +} + +export function ChannelList() { + const { id: currentId } = useParams<{ id: string }>() + const stored = useVisitedChannels() + const [now, setNow] = useState(() => Date.now()) + const [forgotten, setForgotten] = useState(null) + const undo = useRef(null) + const list = useRef(null) + + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 60_000) + return () => clearInterval(timer) + }, []) + + useEffect(() => { + if (!forgotten) return + undo.current?.focus() + const timer = setTimeout(() => { + if (document.activeElement === undo.current) list.current?.focus() + setForgotten(null) + }, UNDO_MS) + return () => clearTimeout(timer) + }, [forgotten]) + + const known = live(stored, now) + const current = known.find((entry) => entry.id === currentId) + const others = known.filter((entry) => entry.id !== currentId) + const forget = (entry: VisitedChannel) => { + forgetChannel(entry.id) + setForgotten(entry) + } + + return ( +
+ + + {others.length === 0 && !forgotten ? ( +

+ Other channels you open in this browser will be listed here. +

+ ) : null} +
+ ) +} + +export const channelListNote = 'Kept in this browser only, and gone when each room expires.' + +/** + * Lives in the /c layout, not the page, so it stays put while the channel + * beside it is swapped. + */ +export function ChannelsPane() { + return ( + + ) +} diff --git a/app/components/channel/channel-view.tsx b/app/components/channel/channel-view.tsx index 0aedcac..91358ad 100644 --- a/app/components/channel/channel-view.tsx +++ b/app/components/channel/channel-view.tsx @@ -15,6 +15,7 @@ import { ReplyProvider } from '../reply-action' import { Roster, Transcript, type ReplyQuote, type TranscriptItem } from '../transcript' import { AddAgentDialog } from './add-agent-dialog' import { announcementFor } from './channel-events' +import { ChannelList, channelListNote } from './channel-list' import { ChannelAddButton, ChannelMenu, ChannelMenuButton, ChannelShareButton } from './channel-menu' import { Compose } from './compose' import { Controls, ExpiryCountdown } from './controls' @@ -138,14 +139,19 @@ function toRoster(participants: RosterEntry[], items: Item[], lastSeq: number, m } function Shell({ children, ground = false }: { children: React.ReactNode; ground?: boolean }) { - // A definite height, not a minimum: `flex-1` below is `flex-basis: 0%`, and - // against an indefinite height that resolves to the content's own, so the - // transcript grew instead of scrolling and took the bar and composer off screen. + // The height comes from the /c layout's h-dvh, and has to stay definite: + // `flex-1` below is `flex-basis: 0%`, and against an indefinite height that + // resolves to the content's own, so the transcript grew instead of scrolling + // and took the bar and composer off screen. // // White is the channel: an application surface, edge to edge. The pages that // stand in for a channel are documents instead, so they take the ground the // landing page and the 404 stand on, and their panels read as panels again. - return
{children}
+ return ( +
+ {children} +
+ ) } /** The bar across the top: what this channel is, and how long it has left. */ @@ -163,8 +169,10 @@ function TopBar({ className={`flex shrink-0 items-center justify-between gap-3 px-4 py-2.5 ${rule ? 'border-b border-line' : ''}`} >
- - {children ? : null} + + + {children ? : null} + {children}
@@ -503,6 +511,13 @@ export function ChannelView({ channelId, host }: { channelId: string; host: stri } > +

In the room From 6be6d439ec8b3b2060f91826a4f70c84e5756076 Mon Sep 17 00:00:00 2001 From: David S D Date: Tue, 29 Sep 2026 01:34:33 +0530 Subject: [PATCH 05/15] Order the channels pane by latest activity Refs #70, #73. Switching used to reshuffle the list: it was ordered by when each room was last opened, and the current one was pinned first. Now it is ordered the way a chat app orders it, by the latest message, or by when the room was opened for one nobody has spoken in. Opening a room changes neither, so a switch moves only the highlight. Each entry gains addedAt and lastMessageAt. A tab records the latest message it sees after each poll, and only writes when that moves forward. Rooms open in any tab reorder live across tabs; a room no tab has open keeps its last known time until the head probe (#74). lastSeenAt stays, now only to pick what the cap drops. Entries stored before this read addedAt from lastSeenAt and lastMessageAt as 0. Co-Authored-By: Claude Opus 5.5 --- app/components/channel/channel-list.tsx | 82 +++++++++---------- app/components/channel/use-channel.ts | 4 +- .../channel/use-visited-channels.test.ts | 21 ++++- .../channel/use-visited-channels.ts | 12 ++- lib/visited-channels.test.ts | 69 +++++++++++++++- lib/visited-channels.ts | 51 +++++++++--- 6 files changed, 179 insertions(+), 60 deletions(-) diff --git a/app/components/channel/channel-list.tsx b/app/components/channel/channel-list.tsx index 4b949c5..d5226fa 100644 --- a/app/components/channel/channel-list.tsx +++ b/app/components/channel/channel-list.tsx @@ -3,7 +3,7 @@ import Link from 'next/link' import { useParams } from 'next/navigation' import { useEffect, useRef, useState } from 'react' -import { live, type VisitedChannel } from '@/lib/visited-channels' +import { byActivity, live, type VisitedChannel } from '@/lib/visited-channels' import { CloseIcon } from '../icons' import { Logo } from '../logo' import { forgetChannel, rememberChannel, useVisitedChannels } from './use-visited-channels' @@ -44,9 +44,7 @@ export function ChannelList() { return () => clearTimeout(timer) }, [forgotten]) - const known = live(stored, now) - const current = known.find((entry) => entry.id === currentId) - const others = known.filter((entry) => entry.id !== currentId) + const known = byActivity(live(stored, now)) const forget = (entry: VisitedChannel) => { forgetChannel(entry.id) setForgotten(entry) @@ -55,43 +53,43 @@ export function ChannelList() { return (
- {others.length === 0 && !forgotten ? ( + {known.every((entry) => entry.id === currentId) && !forgotten ? (

Other channels you open in this browser will be listed here.

diff --git a/app/components/channel/use-channel.ts b/app/components/channel/use-channel.ts index 17b7cf6..eba61b6 100644 --- a/app/components/channel/use-channel.ts +++ b/app/components/channel/use-channel.ts @@ -1,7 +1,7 @@ 'use client' import { useCallback, useEffect, useRef, useState } from 'react' -import { forgetChannel, rememberChannel } from './use-visited-channels' +import { forgetChannel, noteChannelMessage, rememberChannel } from './use-visited-channels' /** * The channel page's connection to its channel (ARCHITECTURE section 6). @@ -367,6 +367,8 @@ export function useChannel(channelId: string) { if (page.items.length > 0) { setItems((existing) => [...existing, ...page.items]) cursor.current = page.last_seq + const said = (page.items as Item[]).findLast((item) => item.type === 'message') + if (said) noteChannelMessage(channelId, Date.parse(said.ts)) } // In the same pass as the items above, so a message hands over to its own // draft within one render rather than flickering between the two. diff --git a/app/components/channel/use-visited-channels.test.ts b/app/components/channel/use-visited-channels.test.ts index 91bbc51..333c706 100644 --- a/app/components/channel/use-visited-channels.test.ts +++ b/app/components/channel/use-visited-channels.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { VISITED_KEY } from '@/lib/visited-channels' -import { forgetChannel, rememberChannel, snapshot, subscribe } from './use-visited-channels' +import { forgetChannel, noteChannelMessage, rememberChannel, snapshot, subscribe } from './use-visited-channels' const now = Date.parse('2026-09-29T12:00:00Z') const room = (id: string) => ({ id, invite: `inv-${id}`, name: `room ${id}`, expiresAt: now + 3_600_000 }) @@ -35,13 +35,15 @@ afterEach(() => { describe('rememberChannel', () => { it('writes the channel to the one key', () => { rememberChannel(room('a'), now) - expect(JSON.parse(win.store.get(VISITED_KEY)!)).toEqual([{ ...room('a'), lastSeenAt: now }]) + expect(JSON.parse(win.store.get(VISITED_KEY)!)).toEqual([ + { ...room('a'), lastSeenAt: now, addedAt: now, lastMessageAt: 0 }, + ]) }) it('does not duplicate on a reload, and moves lastSeenAt', () => { rememberChannel(room('a'), now) rememberChannel(room('a'), now + 5_000) - expect(snapshot()).toEqual([{ ...room('a'), lastSeenAt: now + 5_000 }]) + expect(snapshot()).toEqual([{ ...room('a'), lastSeenAt: now + 5_000, addedAt: now, lastMessageAt: 0 }]) }) it('records nothing for a channel whose expiry cannot be read', () => { @@ -64,6 +66,19 @@ describe('forgetChannel', () => { }) }) +describe('noteChannelMessage', () => { + it('writes and announces a newer message, and does neither for an older one', () => { + rememberChannel(room('a'), now) + const onChange = vi.fn() + const unsubscribe = subscribe(onChange) + noteChannelMessage('a', now + 1_000) + expect(snapshot()[0].lastMessageAt).toBe(now + 1_000) + noteChannelMessage('a', now) + expect(onChange).toHaveBeenCalledTimes(1) + unsubscribe() + }) +}) + describe('subscribe', () => { it('hears a write made in this tab', () => { const onChange = vi.fn() diff --git a/app/components/channel/use-visited-channels.ts b/app/components/channel/use-visited-channels.ts index 9d08614..604ba47 100644 --- a/app/components/channel/use-visited-channels.ts +++ b/app/components/channel/use-visited-channels.ts @@ -4,6 +4,7 @@ import { useSyncExternalStore } from 'react' import { VISITED_KEY, forget, + noteMessage, parse, readVisited, record, @@ -51,11 +52,20 @@ function update(change: (list: VisitedChannel[]) => VisitedChannel[]): void { window.dispatchEvent(new Event(CHANGED)) } -export function rememberChannel(entry: Omit, now = Date.now()): void { +export function rememberChannel(entry: Parameters[1], now = Date.now()): void { if (!entry.invite || !Number.isFinite(entry.expiresAt)) return update((list) => record(list, entry, now)) } +export function noteChannelMessage(id: string, at: number): void { + if (!Number.isFinite(at)) return + const list = readVisited() + const next = noteMessage(list, id, at) + if (next === list) return + writeVisited(next) + window.dispatchEvent(new Event(CHANGED)) +} + export function forgetChannel(id: string): void { update((list) => forget(list, id)) } diff --git a/lib/visited-channels.test.ts b/lib/visited-channels.test.ts index fd41f1e..b4e5eeb 100644 --- a/lib/visited-channels.test.ts +++ b/lib/visited-channels.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest' -import { VISITED_CAP, forget, live, parse, record, type VisitedChannel } from './visited-channels' +import { + VISITED_CAP, + byActivity, + forget, + live, + noteMessage, + parse, + record, + type VisitedChannel, +} from './visited-channels' const now = Date.parse('2026-09-29T12:00:00Z') const hour = 3_600_000 @@ -10,17 +19,29 @@ const channel = (id: string, overrides: Partial = {}): VisitedCh name: `room ${id}`, expiresAt: now + hour, lastSeenAt: now - hour, + addedAt: now - 2 * hour, + lastMessageAt: 0, ...overrides, }) const entry = (id: string, overrides: Partial = {}) => { - const { lastSeenAt: _, ...rest } = channel(id, overrides) + const { lastSeenAt: _, addedAt: __, lastMessageAt: ___, ...rest } = channel(id, overrides) return rest } describe('record', () => { it('adds a channel it has not seen, stamped with now', () => { - expect(record([], entry('a'), now)).toEqual([{ ...entry('a'), lastSeenAt: now }]) + expect(record([], entry('a'), now)).toEqual([{ ...entry('a'), lastSeenAt: now, addedAt: now, lastMessageAt: 0 }]) + }) + + it('keeps when the room was first opened and the latest message known, across revisits', () => { + const list = [channel('a', { addedAt: now - 5 * hour, lastMessageAt: now - 3 * hour })] + expect(record(list, entry('a'), now)[0]).toMatchObject({ addedAt: now - 5 * hour, lastMessageAt: now - 3 * hour }) + }) + + it('restores a forgotten entry with its own times, for undo', () => { + const forgotten = channel('a', { addedAt: now - 5 * hour, lastMessageAt: now - 3 * hour }) + expect(record([], forgotten, now)[0]).toMatchObject({ addedAt: now - 5 * hour, lastMessageAt: now - 3 * hour }) }) it('upserts by id rather than duplicating, and takes the newer name and invite', () => { @@ -53,6 +74,43 @@ describe('record', () => { }) }) +describe('noteMessage', () => { + it('moves the latest message forward', () => { + expect(noteMessage([channel('a')], 'a', now)[0].lastMessageAt).toBe(now) + }) + + it('returns the same list when the time is not newer, so nothing is written', () => { + const list = [channel('a', { lastMessageAt: now })] + expect(noteMessage(list, 'a', now)).toBe(list) + expect(noteMessage(list, 'a', now - 1)).toBe(list) + }) + + it('returns the same list for a channel it does not hold', () => { + const list = [channel('a')] + expect(noteMessage(list, 'nope', now)).toBe(list) + }) +}) + +describe('byActivity', () => { + it('orders by the latest message, not by when the room was last opened', () => { + const list = [ + channel('quiet', { lastSeenAt: now, lastMessageAt: now - 3 * hour }), + channel('busy', { lastSeenAt: now - hour, lastMessageAt: now - 60_000 }), + ] + expect(byActivity(list).map((c) => c.id)).toEqual(['busy', 'quiet']) + }) + + it('places a room nobody has spoken in by when it was opened', () => { + const list = [channel('old', { lastMessageAt: now - hour }), channel('new', { addedAt: now - 60_000 })] + expect(byActivity(list).map((c) => c.id)).toEqual(['new', 'old']) + }) + + it('does not reorder on a revisit', () => { + const list = [channel('a', { lastMessageAt: now - 60_000 }), channel('b', { lastMessageAt: now - hour })] + expect(byActivity(record(list, entry('b'), now)).map((c) => c.id)).toEqual(['a', 'b']) + }) +}) + describe('forget', () => { it('removes the channel and nothing else', () => { expect(forget([channel('a'), channel('b')], 'a').map((c) => c.id)).toEqual(['b']) @@ -126,6 +184,11 @@ describe('parse', () => { expect(parse(raw)).toEqual([channel('a', { name: 'first' })]) }) + it('reads an entry written before addedAt and lastMessageAt existed', () => { + const { addedAt: _, lastMessageAt: __, ...old } = channel('a') + expect(parse(JSON.stringify([old]))[0]).toMatchObject({ addedAt: old.lastSeenAt, lastMessageAt: 0 }) + }) + it('does not carry unknown fields through', () => { const raw = JSON.stringify([{ ...channel('a'), preview: 'message text' }]) expect(parse(raw)[0]).not.toHaveProperty('preview') diff --git a/lib/visited-channels.ts b/lib/visited-channels.ts index 0290825..ea448d7 100644 --- a/lib/visited-channels.ts +++ b/lib/visited-channels.ts @@ -4,10 +4,17 @@ export type VisitedChannel = { name: string /** Epoch ms. The entry dies with the room. */ expiresAt: number - /** Epoch ms. Ordering only. */ + /** Epoch ms. Decides which entry the cap drops. */ lastSeenAt: number + /** Epoch ms, when this browser first opened the room. */ + addedAt: number + /** Epoch ms of the latest message any tab has seen here; 0 for none yet. */ + lastMessageAt: number } +type Entry = Omit & + Partial> + export const VISITED_KEY = 'wave.channels' export const VISITED_CAP = 20 @@ -15,17 +22,33 @@ export function live(list: VisitedChannel[], now: number): VisitedChannel[] { return list.filter((entry) => entry.expiresAt > now) } -export function record( - list: VisitedChannel[], - entry: Omit, - now: number, -): VisitedChannel[] { - const rest = list.filter((existing) => existing.id !== entry.id) - return live([{ ...entry, lastSeenAt: now }, ...rest], now) +export function record(list: VisitedChannel[], entry: Entry, now: number): VisitedChannel[] { + const existing = list.find((other) => other.id === entry.id) + const rest = list.filter((other) => other.id !== entry.id) + const next: VisitedChannel = { + ...entry, + lastSeenAt: now, + addedAt: existing?.addedAt ?? entry.addedAt ?? now, + lastMessageAt: Math.max(existing?.lastMessageAt ?? 0, entry.lastMessageAt ?? 0), + } + return live([next, ...rest], now) .sort((a, b) => b.lastSeenAt - a.lastSeenAt) .slice(0, VISITED_CAP) } +/** Returns the same list when nothing moved, so a caller can skip the write. */ +export function noteMessage(list: VisitedChannel[], id: string, at: number): VisitedChannel[] { + const entry = list.find((other) => other.id === id) + if (!entry || at <= entry.lastMessageAt) return list + return list.map((other) => (other.id === id ? { ...other, lastMessageAt: at } : other)) +} + +/** Most recent activity first: the latest message, or the opening for a room nobody has spoken in. */ +export function byActivity(list: VisitedChannel[]): VisitedChannel[] { + const activity = (entry: VisitedChannel) => Math.max(entry.lastMessageAt, entry.addedAt) + return [...list].sort((a, b) => activity(b) - activity(a)) +} + export function forget(list: VisitedChannel[], id: string): VisitedChannel[] { return list.filter((entry) => entry.id !== id) } @@ -35,9 +58,17 @@ const time = (value: unknown): value is number => typeof value === 'number' && N function entryFrom(value: unknown): VisitedChannel | null { if (typeof value !== 'object' || value === null) return null - const { id, invite, name, expiresAt, lastSeenAt } = value as Record + const { id, invite, name, expiresAt, lastSeenAt, addedAt, lastMessageAt } = value as Record if (!text(id) || !text(invite) || !time(expiresAt) || !time(lastSeenAt)) return null - return { id, invite, name: typeof name === 'string' ? name : '', expiresAt, lastSeenAt } + return { + id, + invite, + name: typeof name === 'string' ? name : '', + expiresAt, + lastSeenAt, + addedAt: time(addedAt) ? addedAt : lastSeenAt, + lastMessageAt: time(lastMessageAt) ? lastMessageAt : 0, + } } export function parse(raw: string | null): VisitedChannel[] { From 20f10827c94751d1f868baca9197b20f90bfc8a6 Mon Sep 17 00:00:00 2001 From: David S D Date: Tue, 29 Sep 2026 01:36:30 +0530 Subject: [PATCH 06/15] Track the latest message separately from the latest item Closes #71. last_seq moves for joins, timeouts and the expiry warning as well as for messages, so an unread dot built on it would light a room up because an agent reconnected. appendItem now sets a lastmsg key to the message's seq, inside the MULTI that already writes the item, so it costs no round trip and cannot drift from the item it describes. channelView exposes it as last_message_seq beside last_seq. The key is in channelKeys, so close, sweep and the channel TTL all reach it, and the close test asserts it is gone from the key space. Co-Authored-By: Claude Opus 5.5 --- docs/ARCHITECTURE.md | 1 + lib/channels.test.ts | 33 +++++++++++++++++++++++++++++++++ lib/channels.ts | 10 ++++++++-- lib/items.ts | 11 +++++++++-- lib/keys.test.ts | 14 +++++++++++--- lib/keys.ts | 3 +++ 6 files changed, 65 insertions(+), 7 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7e314aa..8f3a691 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -113,6 +113,7 @@ In front of that sits an instance namespace, `REDIS_PREFIX`, default `wave`. One |---|---|---| | `{p}:ch:{id}` | hash | name, mode, created_at, expires_at, max_participants, invite_hash, admin_hash | | `{p}:ch:{id}:seq` | string | last allocated sequence number | +| `{p}:ch:{id}:lastmsg` | string | seq of the latest message, set in the same transaction as the item | | `{p}:ch:{id}:items` | sorted set | JSON item per member, score = seq | | `{p}:ch:{id}:bytes` | string | running total of item bytes | | `{p}:ch:{id}:parts` | hash | participant_id → JSON {name, role, token_hash, joined_at, last_seen, state, left_at?, read_seq?} | diff --git a/lib/channels.test.ts b/lib/channels.test.ts index 1fc0f86..ba01122 100644 --- a/lib/channels.test.ts +++ b/lib/channels.test.ts @@ -171,6 +171,32 @@ describe('channelView', () => { expect(view.participants.map((entry) => entry.name)).toEqual(['First', 'Second']) expect(view.last_seq).toBe(1) }) + + const author = { id: 'p_1', name: 'First', role: 'agent' as const } + + it('reports last_message_seq of 0 for a channel with only joins in it', async () => { + const { redis } = fakeRedis() + const created = await createChannel(redis, { ttl: '1h', mode: 'standard' }) + const channel = await storedChannel(redis, created.channel_id) + await appendItem(redis, channel, { type: 'system', event: 'participant.joined' }) + await appendItem(redis, channel, { type: 'system', event: 'participant.joined' }) + + const view = await channelView(redis, channel) + expect(view.last_seq).toBe(2) + expect(view.last_message_seq).toBe(0) + }) + + it('moves last_message_seq on a message, and leaves it on a later system event', async () => { + const { redis } = fakeRedis() + const created = await createChannel(redis, { ttl: '1h', mode: 'standard' }) + const channel = await storedChannel(redis, created.channel_id) + await appendItem(redis, channel, { type: 'system', event: 'participant.joined' }) + await appendItem(redis, channel, { type: 'message', from: author, text: 'hi', kind: 'message' }) + expect(await channelView(redis, channel)).toMatchObject({ last_seq: 2, last_message_seq: 2 }) + + await appendItem(redis, channel, { type: 'system', event: 'participant.timed_out' }) + expect(await channelView(redis, channel)).toMatchObject({ last_seq: 3, last_message_seq: 2 }) + }) }) describe('closeChannel', () => { @@ -180,6 +206,13 @@ describe('closeChannel', () => { const survivor = await createChannel(redis, { ttl: '1h', mode: 'standard' }) const channel = await storedChannel(redis, doomed.channel_id) await redis.set(keys.idem(channel.id, 'p_1', 'retry-1'), '{"seq":1}') + await appendItem(redis, channel, { + type: 'message', + from: { id: 'p_1', name: 'First', role: 'agent' }, + text: 'hi', + kind: 'message', + }) + expect(fake.keys()).toContain(keys.lastMessage(channel.id)) await closeChannel(redis, channel) diff --git a/lib/channels.ts b/lib/channels.ts index d87d4c3..17d0de5 100644 --- a/lib/channels.ts +++ b/lib/channels.ts @@ -2,7 +2,7 @@ import { z } from 'zod' import { loadChannel } from './auth' import { ApiError } from './http' import { getConfig } from './config' -import { appendItem, lastSeq } from './items' +import { appendItem, lastMessageSeq, lastSeq } from './items' import { channelKeyPattern, keys } from './keys' import { LIMITS, PRESENCE, TTL_CHOICES } from './limits' import { countChannelCreated } from './metrics' @@ -159,11 +159,16 @@ export type ChannelView = { } participants: RosterEntry[] last_seq: number + last_message_seq: number } /** The public view of a channel. Token hashes never leave storage. */ export async function channelView(redis: WaveRedis, channel: ChannelRecord): Promise { - const [participants, seq] = await Promise.all([listParticipants(redis, channel.id), lastSeq(redis, channel.id)]) + const [participants, seq, messageSeq] = await Promise.all([ + listParticipants(redis, channel.id), + lastSeq(redis, channel.id), + lastMessageSeq(redis, channel.id), + ]) return { channel: { id: channel.id, @@ -175,6 +180,7 @@ export async function channelView(redis: WaveRedis, channel: ChannelRecord): Pro }, participants: roster(participants), last_seq: seq, + last_message_seq: messageSeq, } } diff --git a/lib/items.ts b/lib/items.ts index 2d58ef0..d961ef4 100644 --- a/lib/items.ts +++ b/lib/items.ts @@ -30,6 +30,12 @@ export async function lastSeq(redis: WaveRedis, channelId: string): Promise { + const stored = await redis.get(keys.lastMessage(channelId)) + return stored ? Number(stored) : 0 +} + /** * Allocates a sequence number and writes the item. Size and content checks * belong to the caller: by the time an item reaches here it is going in. @@ -39,11 +45,12 @@ export async function appendItem(redis: WaveRedis, channel: ChannelRecord, draft const item = itemSchema.parse({ ...draft, seq, ts: toIso(new Date()) }) const encoded = serializeItem(item) - await redis + const write = redis .multi() .zAdd(keys.items(channel.id), { score: seq, value: encoded }) .incrBy(keys.bytes(channel.id), Buffer.byteLength(encoded)) - .exec() + if (item.type === 'message') write.set(keys.lastMessage(channel.id), String(seq)) + await write.exec() await applyChannelTtl(redis, channel.id, channel.expires_at) // After the write, never before: a poll woken by this must find the item and // not just a sequence number that has run ahead of it. diff --git a/lib/keys.test.ts b/lib/keys.test.ts index 3a2223f..fbcfa0f 100644 --- a/lib/keys.test.ts +++ b/lib/keys.test.ts @@ -5,6 +5,7 @@ describe('key layout', () => { it('matches ARCHITECTURE section 4', () => { expect(keys.channel('abc')).toBe('wave:ch:abc') expect(keys.seq('abc')).toBe('wave:ch:abc:seq') + expect(keys.lastMessage('abc')).toBe('wave:ch:abc:lastmsg') expect(keys.items('abc')).toBe('wave:ch:abc:items') expect(keys.bytes('abc')).toBe('wave:ch:abc:bytes') expect(keys.parts('abc')).toBe('wave:ch:abc:parts') @@ -21,9 +22,16 @@ describe('key layout', () => { it('lists every channel key the close path has to delete', () => { const listed = channelKeys('abc') - const fromLayout = [keys.channel, keys.seq, keys.items, keys.bytes, keys.parts, keys.names, keys.emitted].map( - (build) => build('abc'), - ) + const fromLayout = [ + keys.channel, + keys.seq, + keys.lastMessage, + keys.items, + keys.bytes, + keys.parts, + keys.names, + keys.emitted, + ].map((build) => build('abc')) expect(new Set(listed)).toEqual(new Set(fromLayout)) }) diff --git a/lib/keys.ts b/lib/keys.ts index 1614427..bc1f996 100644 --- a/lib/keys.ts +++ b/lib/keys.ts @@ -19,6 +19,8 @@ export const keys = { channel: (channelId: string) => `${ns()}:ch:${channelId}`, /** string: last allocated sequence number */ seq: (channelId: string) => `${ns()}:ch:${channelId}:seq`, + /** string: seq of the latest message, so a head can say someone spoke rather than that anything happened */ + lastMessage: (channelId: string) => `${ns()}:ch:${channelId}:lastmsg`, /** sorted set: one JSON item per member, score = seq */ items: (channelId: string) => `${ns()}:ch:${channelId}:items`, /** string: running total of item bytes */ @@ -66,6 +68,7 @@ export function channelKeys(channelId: string): string[] { return [ keys.channel(channelId), keys.seq(channelId), + keys.lastMessage(channelId), keys.items(channelId), keys.bytes(channelId), keys.parts(channelId), From 9a50451e91b0698f02af9e5f669616a2cc3e3308 Mon Sep 17 00:00:00 2001 From: David S D Date: Tue, 29 Sep 2026 01:38:20 +0530 Subject: [PATCH 07/15] Add the channel head probe Closes #72. GET /api/v1/channels/:id/head answers last_seq, last_message_seq and expires_at: the cheapest way to ask whether a room nobody is looking at has moved. It reads the channel record and two counters. It does not sweep, touch presence or move a read receipt, because a probe on a timer must not mark you present in a room you are not in, or fire timeouts on a clock nobody chose. Tests assert last_seen, read_seq and participant state are unchanged, and that the only key it writes is its own limit counter. It takes the invite or a participant token, like GET /channels/:id; both could already read the head, so nothing new is granted. It joins the isolation suite, so a token from another channel is 401 here too. Rate limit: its own bucket, headProbesPerMinute = 6 per channel per caller. Sharing immediatePollsPerMinute would let a pane asking about other rooms spend the allowance a real poll needs. The pane asks once a minute per room (#74); six covers a few tabs behind one address and a reload, and holds a runaway loop to one request every ten seconds. Co-Authored-By: Claude Opus 5.5 --- app/api/v1/channels/[id]/head/route.ts | 30 +++++ docs/PRODUCT.md | 3 +- lib/endpoints.ts | 1 + lib/limits.ts | 8 ++ lib/rate-limit.ts | 10 ++ tests/api-head.test.ts | 151 +++++++++++++++++++++++++ tests/api-isolation.test.ts | 6 + 7 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 app/api/v1/channels/[id]/head/route.ts create mode 100644 tests/api-head.test.ts diff --git a/app/api/v1/channels/[id]/head/route.ts b/app/api/v1/channels/[id]/head/route.ts new file mode 100644 index 0000000..c02dc7a --- /dev/null +++ b/app/api/v1/channels/[id]/head/route.ts @@ -0,0 +1,30 @@ +import { authenticate } from '@/lib/auth' +import { otherMethods } from '@/lib/endpoints' +import { toErrorResponse } from '@/lib/http' +import { lastMessageSeq, lastSeq } from '@/lib/items' +import { limitHeadProbes } from '@/lib/rate-limit' +import { getRedis } from '@/lib/redis' +import { toIso } from '@/lib/time' + +/** + * GET /api/v1/channels/:id/head — has anything happened, and has anyone spoken. + * + * For a browser asking about a room nobody is looking at, on a timer. It reads + * the channel record and two counters and writes nothing: no sweep, no + * presence, no read receipt. A probe that marked you present in a room you are + * not in, or advanced a cursor other people are shown, would be lying. + */ +export async function GET(request: Request, context: RouteContext<'/api/v1/channels/[id]/head'>): Promise { + try { + const { id } = await context.params + const redis = await getRedis() + const { channel } = await authenticate(redis, id, ['invite', 'participant'], request) + await limitHeadProbes(redis, channel.id, request) + const [seq, messageSeq] = await Promise.all([lastSeq(redis, channel.id), lastMessageSeq(redis, channel.id)]) + return Response.json({ last_seq: seq, last_message_seq: messageSeq, expires_at: toIso(channel.expires_at) }) + } catch (error) { + return toErrorResponse(error) + } +} + +export const { POST, PUT, PATCH, DELETE, OPTIONS } = otherMethods('/api/v1/channels/{id}/head') diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 1538bb0..361bd48 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -493,7 +493,8 @@ All tokens are 256-bit random, stored hashed. Channel IDs are 128-bit random, UR | Method | Path | Auth | Purpose | |--------|------|------|---------| | POST | `/channels` | none | Create channel | -| GET | `/channels/:id` | invite or participant | Metadata, roster, `last_seq` | +| GET | `/channels/:id` | invite or participant | Metadata, roster, `last_seq`, `last_message_seq` | +| GET | `/channels/:id/head` | invite or participant | `last_seq`, `last_message_seq`, `expires_at`; no writes, for the channels pane | | POST | `/channels/:id/join` | invite | Join, returns participant token | | GET | `/channels/:id/messages?after=N&wait=S&receipts=1` | invite or participant | Long-poll for items with `seq > N` | | POST | `/channels/:id/messages` | participant | Post a message | diff --git a/lib/endpoints.ts b/lib/endpoints.ts index d2bbe57..e2fe81f 100644 --- a/lib/endpoints.ts +++ b/lib/endpoints.ts @@ -7,6 +7,7 @@ type Endpoint = { method: Method; path: string; does: string } export const ENDPOINTS: readonly Endpoint[] = [ { method: 'POST', path: '/api/v1/channels', does: 'create a channel, no token' }, { method: 'GET', path: '/api/v1/channels/{id}', does: 'read the channel, invite or participant token' }, + { method: 'GET', path: '/api/v1/channels/{id}/head', does: 'latest seq only, invite or participant token' }, { method: 'POST', path: '/api/v1/channels/{id}/join', does: 'join, invite token' }, { method: 'GET', path: '/api/v1/channels/{id}/messages', does: 'poll, participant or invite token' }, { method: 'POST', path: '/api/v1/channels/{id}/messages', does: 'post, participant token' }, diff --git a/lib/limits.ts b/lib/limits.ts index 5e2cec1..865c290 100644 --- a/lib/limits.ts +++ b/lib/limits.ts @@ -33,6 +33,14 @@ export const LIMITS = { pollSlotSeconds: 60, /** Polls per caller per minute that ask for no wait. Held polls have the slot above instead. */ immediatePollsPerMinute: 30, + /** + * Head probes per channel per caller per minute. Its own bucket, so a pane + * asking about other rooms can never spend the allowance a real poll needs. + * A pane asks once a minute per room; six leaves room for a few tabs behind + * one address and a reload, and still holds a runaway loop to one request + * every ten seconds. + */ + headProbesPerMinute: 6, /** Messages per participant per minute. */ messagesPerMinute: 60, /** Channel creations per IP per hour. */ diff --git a/lib/rate-limit.ts b/lib/rate-limit.ts index 7ea1a30..65c66e6 100644 --- a/lib/rate-limit.ts +++ b/lib/rate-limit.ts @@ -140,6 +140,16 @@ export async function limitImmediatePolling(redis: WaveRedis, subject: string): }) } +/** Head probes, per channel and caller. */ +export async function limitHeadProbes(redis: WaveRedis, channelId: string, request: Request): Promise { + await enforceLimit(redis, { + scope: 'head', + subject: `${channelId}:${callerAddress(request)}`, + max: LIMITS.headProbesPerMinute, + windowSeconds: 60, + }) +} + /** Messages, per participant. */ export async function limitPosting(redis: WaveRedis, participantId: string): Promise { await enforceLimit(redis, { diff --git a/tests/api-head.test.ts b/tests/api-head.test.ts new file mode 100644 index 0000000..baadd62 --- /dev/null +++ b/tests/api-head.test.ts @@ -0,0 +1,151 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fakeRedis, type FakeRedis } from './fake-redis' +import { keys } from '@/lib/keys' +import { LIMITS } from '@/lib/limits' +import type { WaveRedis } from '@/lib/redis' + +/** GET /api/v1/channels/:id/head, over the real handlers. */ + +let fake: FakeRedis +let redis: WaveRedis + +vi.mock('@/lib/redis', async (importOriginal) => ({ + ...(await importOriginal()), + getRedis: async () => redis, +})) + +const { POST: createRoute } = await import('@/app/api/v1/channels/route') +const { POST: joinRoute } = await import('@/app/api/v1/channels/[id]/join/route') +const { GET: pollRoute, POST: postRoute } = await import('@/app/api/v1/channels/[id]/messages/route') +const { GET: headRoute } = await import('@/app/api/v1/channels/[id]/head/route') + +const origin = 'https://wave.example.com' + +function post(body: unknown, token?: string): Request { + return new Request(`${origin}/api/v1/channels`, { + method: 'POST', + headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) }, + body: JSON.stringify(body), + }) +} + +function get(path: string, token?: string, address = '198.51.100.7'): Request { + return new Request(`${origin}${path}`, { + headers: { 'x-forwarded-for': address, ...(token ? { authorization: `Bearer ${token}` } : {}) }, + }) +} + +const context = (id: string) => ({ params: Promise.resolve({ id }) }) +const head = (id: string, token?: string, address?: string) => + headRoute(get(`/api/v1/channels/${id}/head`, token, address), context(id)) + +async function openChannel() { + const channel = await (await createRoute(post({ ttl: '1h', name: 'Head test' }))).json() + const agent = await ( + await joinRoute(post({ name: 'Resident agent', role: 'agent' }, channel.invite_token), context(channel.channel_id)) + ).json() + return { + id: channel.channel_id as string, + invite: channel.invite_token as string, + participantId: agent.participant_id as string, + participant: agent.participant_token as string, + } +} + +type Channel = Awaited> +let room: Channel + +const participantRecord = async () => JSON.parse((await redis.hGetAll(keys.parts(room.id)))[room.participantId]) + +beforeEach(async () => { + ;({ fake, redis } = fakeRedis()) + room = await openChannel() +}) + +describe('GET /api/v1/channels/:id/head', () => { + it('answers with the two heads and the expiry, and nothing else', async () => { + const response = await head(room.id, room.invite) + expect(response.status).toBe(200) + const body = await response.json() + expect(Object.keys(body).sort()).toEqual(['expires_at', 'last_message_seq', 'last_seq']) + expect(body).toMatchObject({ last_seq: 1, last_message_seq: 0, expires_at: expect.stringMatching(/Z$/) }) + }) + + it('moves last_message_seq when someone speaks', async () => { + await postRoute(post({ text: 'hello' }, room.participant), context(room.id)) + expect(await (await head(room.id, room.invite)).json()).toMatchObject({ last_seq: 2, last_message_seq: 2 }) + }) + + it('takes a participant token too', async () => { + expect((await head(room.id, room.participant)).status).toBe(200) + }) + + it('leaves the prober’s presence and read receipt where they were', async () => { + await pollRoute(get(`/api/v1/channels/${room.id}/messages?after=1`, room.participant), context(room.id)) + const before = await participantRecord() + await postRoute(post({ text: 'unread' }, room.participant), context(room.id)) + const afterPost = await participantRecord() + + vi.useFakeTimers({ now: Date.now() + 120_000 }) + try { + await head(room.id, room.participant) + } finally { + vi.useRealTimers() + } + + const after = await participantRecord() + expect(after.last_seen).toBe(afterPost.last_seen) + expect(after.read_seq).toBe(before.read_seq) + }) + + it('does not sweep: a silent participant is not timed out by a probe', async () => { + const stale = await participantRecord() + await redis.hSet(keys.parts(room.id), { + [room.participantId]: JSON.stringify({ ...stale, last_seen: stale.last_seen - 3_600 }), + }) + expect(await (await head(room.id, room.invite)).json()).toMatchObject({ last_seq: 1 }) + expect((await participantRecord()).state).toBe(stale.state) + }) + + it.each([ + ['unknown', 'A'.repeat(22)], + ['malformed', 'nope'], + ])('is 410 for an %s id', async (_, id) => { + expect((await head(id, room.invite)).status).toBe(410) + }) + + it('is 410 for an expired channel', async () => { + await redis.hSet(keys.channel(room.id), { expires_at: String(Math.floor(Date.now() / 1000) - 1) }) + expect((await head(room.id, room.invite)).status).toBe(410) + }) + + it('is 401 without a token', async () => { + expect((await head(room.id)).status).toBe(401) + }) + + it(`allows ${LIMITS.headProbesPerMinute} a minute per channel and caller, then 429s with Retry-After`, async () => { + for (let i = 0; i < LIMITS.headProbesPerMinute; i++) expect((await head(room.id, room.invite)).status).toBe(200) + const refused = await head(room.id, room.invite) + expect(refused.status).toBe(429) + expect(refused.headers.get('Retry-After')).toMatch(/^\d+$/) + }) + + it('keeps its own bucket: probing to the limit leaves polling alone', async () => { + for (let i = 0; i <= LIMITS.headProbesPerMinute; i++) await head(room.id, room.invite) + const poll = await pollRoute(get(`/api/v1/channels/${room.id}/messages?wait=0`, room.invite), context(room.id)) + expect(poll.status).toBe(200) + }) + + it('counts each channel separately, so one busy room cannot starve another', async () => { + const other = await openChannel() + for (let i = 0; i <= LIMITS.headProbesPerMinute; i++) await head(room.id, room.invite) + expect((await head(other.id, other.invite)).status).toBe(200) + }) + + it('writes nothing but its own rate-limit counter', async () => { + const before = new Set(fake.keys()) + await head(room.id, room.invite) + const added = fake.keys().filter((key: string) => !before.has(key)) + expect(added.every((key: string) => key.startsWith(keys.rateLimit('head', '')))).toBe(true) + }) +}) diff --git a/tests/api-isolation.test.ts b/tests/api-isolation.test.ts index 185d2e3..fafb923 100644 --- a/tests/api-isolation.test.ts +++ b/tests/api-isolation.test.ts @@ -27,6 +27,7 @@ vi.mock('@/lib/redis', async (importOriginal) => ({ const { POST: createRoute } = await import('@/app/api/v1/channels/route') const { GET: readRoute } = await import('@/app/api/v1/channels/[id]/route') +const { GET: headRoute } = await import('@/app/api/v1/channels/[id]/head/route') const { POST: joinRoute } = await import('@/app/api/v1/channels/[id]/join/route') const { POST: leaveRoute } = await import('@/app/api/v1/channels/[id]/leave/route') const { POST: closeRoute } = await import('@/app/api/v1/channels/[id]/close/route') @@ -77,6 +78,10 @@ const ENDPOINTS = [ name: 'GET /channels/:id', call: (id: string, token: string) => readRoute(get(token), context(id)), }, + { + name: 'GET /channels/:id/head', + call: (id: string, token: string) => headRoute(get(token), context(id)), + }, { name: 'POST /channels/:id/join', call: (id: string, token: string) => joinRoute(post({ name: 'Intruder', role: 'agent' }, token), context(id)), @@ -151,6 +156,7 @@ describe('credential type is enforced within one channel', () => { { endpoint: 'POST /channels/:id/close', credential: 'invite', expects: 'the admin token' }, { endpoint: 'POST /channels/:id/close', credential: 'participant', expects: 'the admin token' }, { endpoint: 'GET /channels/:id', credential: 'admin', expects: 'the invite or a participant token' }, + { endpoint: 'GET /channels/:id/head', credential: 'admin', expects: 'the invite or a participant token' }, ] as const for (const { endpoint, credential, expects } of wrong) { From 28961cb61151584b58a2c5386ad24f8e884c3522 Mon Sep 17 00:00:00 2001 From: David S D Date: Tue, 29 Sep 2026 01:43:46 +0530 Subject: [PATCH 08/15] Show unread in the channels pane, from tabs already open Closes #73. A tab polling a room publishes that room's head to wave.head. after every poll: the seq of the latest message and when it was confirmed. The pane in every other tab compares it with wave.read., which the read marker already writes, through the storage event it already listens to. No request is made. One key per room, so two tabs never race on one read-modify-write. Three states. Unread when the head is past the read mark, however old the head is. Read when a fresh head is at or behind it. Unknown when no tab has published one, or the one that said read is older than 90s. Only unread is drawn, as a bold name, an ink dot (accent is reserved for focus and links) and ", unread" for screen readers. Unknown is what #74 probes. The head is the latest message from anyone but you. Every tab in this browser is you, and a message sent while scrolled up would otherwise light your own other tabs. The read marker no longer advances while the tab is hidden. A joined tab still polls on a heartbeat in the background, and marking what that brought in as read meant a room could fill up with messages nobody saw and never show a dot. It catches up when the tab is shown again. Heads go with their room: forget drops one, and each record drops any head the list no longer holds. Checked in two tabs on the dev server: a message in a room scrolled up in one tab raises its dot in the other with no /head request, scrolling to the end clears it in both, a join raises nothing, and your own message raises nothing. Co-Authored-By: Claude Opus 5.5 --- app/components/channel/channel-list.tsx | 68 +++++++++++----- app/components/channel/use-channel.ts | 13 +++- app/components/channel/use-read-marker.ts | 10 ++- app/components/channel/use-unread.test.ts | 78 +++++++++++++++++++ app/components/channel/use-unread.ts | 75 ++++++++++++++++++ .../channel/use-visited-channels.ts | 3 + lib/unread.test.ts | 40 ++++++++++ lib/unread.ts | 47 +++++++++++ 8 files changed, 310 insertions(+), 24 deletions(-) create mode 100644 app/components/channel/use-unread.test.ts create mode 100644 app/components/channel/use-unread.ts create mode 100644 lib/unread.test.ts create mode 100644 lib/unread.ts diff --git a/app/components/channel/channel-list.tsx b/app/components/channel/channel-list.tsx index d5226fa..175a850 100644 --- a/app/components/channel/channel-list.tsx +++ b/app/components/channel/channel-list.tsx @@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from 'react' import { byActivity, live, type VisitedChannel } from '@/lib/visited-channels' import { CloseIcon } from '../icons' import { Logo } from '../logo' +import { unreadOf, useUnreadVersion } from './use-unread' import { forgetChannel, rememberChannel, useVisitedChannels } from './use-visited-channels' const UNDO_MS = 5_000 @@ -21,9 +22,54 @@ function RowName({ name, className }: { name: string; className: string }) { ) } +function OtherRow({ + entry, + unread, + onForget, +}: { + entry: VisitedChannel + unread: boolean + onForget: (entry: VisitedChannel) => void +}) { + return ( +
  • + {/* Link, not : a soft navigation commits the fragment before the + next page reads its invite (#69). No prefetch, since each visible + row would otherwise be a request to the server. */} + + + {unread ? , unread : null} + + {unread ? ( + + ) : null} + +
  • + ) +} + export function ChannelList() { const { id: currentId } = useParams<{ id: string }>() const stored = useVisitedChannels() + useUnreadVersion() const [now, setNow] = useState(() => Date.now()) const [forgotten, setForgotten] = useState(null) const undo = useRef(null) @@ -67,27 +113,7 @@ export function ChannelList() { ) : ( -
  • - {/* Link, not : a soft navigation commits the fragment before the - next page reads its invite (#69). No prefetch, since each visible - row would otherwise be a request to the server. */} - - - - -
  • + ), )} diff --git a/app/components/channel/use-channel.ts b/app/components/channel/use-channel.ts index eba61b6..a2a2ee4 100644 --- a/app/components/channel/use-channel.ts +++ b/app/components/channel/use-channel.ts @@ -1,6 +1,7 @@ 'use client' import { useCallback, useEffect, useRef, useState } from 'react' +import { publishHead } from './use-unread' import { forgetChannel, noteChannelMessage, rememberChannel } from './use-visited-channels' /** @@ -179,6 +180,12 @@ export function useChannel(channelId: string) { const invite = useRef(null) const cursor = useRef(0) + /** + * Seq of the latest message from anyone but you, published for the channels + * pane in other tabs. Every tab in this browser is you, so your own message + * is never news to them. + */ + const lastMessage = useRef(0) const meRef = useRef(null) /** The join in flight, so two quick messages do not join this browser twice. */ const joining = useRef | null>(null) @@ -367,9 +374,13 @@ export function useChannel(channelId: string) { if (page.items.length > 0) { setItems((existing) => [...existing, ...page.items]) cursor.current = page.last_seq - const said = (page.items as Item[]).findLast((item) => item.type === 'message') + const messages = (page.items as Item[]).filter((item) => item.type === 'message') + const said = messages.at(-1) if (said) noteChannelMessage(channelId, Date.parse(said.ts)) + const heard = messages.findLast((item) => item.from.id !== meRef.current?.id) + if (heard) lastMessage.current = heard.seq } + publishHead(channelId, lastMessage.current) // In the same pass as the items above, so a message hands over to its own // draft within one render rather than flickering between the two. setPending((queue) => reconcile(queue, page.items, page.last_seq, meRef.current?.id)) diff --git a/app/components/channel/use-read-marker.ts b/app/components/channel/use-read-marker.ts index 09a96a1..58526a4 100644 --- a/app/components/channel/use-read-marker.ts +++ b/app/components/channel/use-read-marker.ts @@ -1,6 +1,7 @@ 'use client' import { useCallback, useEffect, useRef, useState } from 'react' +import { readKey } from '@/lib/unread' import type { Item } from './use-channel' /** @@ -16,7 +17,6 @@ import type { Item } from './use-channel' * catch up to. */ -const readKey = (channelId: string) => `wave.read.${channelId}` /** Within this many pixels of the end counts as being at the end. */ const AT_BOTTOM = 120 @@ -71,7 +71,8 @@ export function useReadMarker(channelId: string, items: Item[], ready: boolean, /** Everything above the fold of the scroller has been seen. */ const noteScrollPosition = useCallback(() => { const element = scroller.current - if (!element) return + // A hidden tab still takes delivery on its heartbeat, and nobody saw that. + if (!element || document.visibilityState === 'hidden') return const bottomEdge = element.scrollTop + element.clientHeight let seen = readUpTo.current @@ -124,6 +125,11 @@ export function useReadMarker(channelId: string, items: Item[], ready: boolean, noteScrollPosition() }, [items.length, pendingCount, noteScrollPosition]) + useEffect(() => { + document.addEventListener('visibilitychange', noteScrollPosition) + return () => document.removeEventListener('visibilitychange', noteScrollPosition) + }, [noteScrollPosition]) + return { scroller, tail, diff --git a/app/components/channel/use-unread.test.ts b/app/components/channel/use-unread.test.ts new file mode 100644 index 0000000..4a09b59 --- /dev/null +++ b/app/components/channel/use-unread.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { headKey, readKey } from '@/lib/unread' +import { dropHead, dropHeadsExcept, publishHead, unreadOf } from './use-unread' + +const now = Date.parse('2026-09-29T12:00:00Z') + +function fakeWindow() { + const store = new Map() + const localStorage = { + get length() { + return store.size + }, + key: (i: number) => [...store.keys()][i] ?? null, + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), + } + return Object.assign(new EventTarget(), { localStorage, store }) +} + +let win: ReturnType + +beforeEach(() => { + win = fakeWindow() + vi.stubGlobal('window', win) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('unreadOf', () => { + it('is unread once a tab publishes a message past the read mark', () => { + win.store.set(readKey('a'), '3') + publishHead('a', 5, now) + expect(unreadOf('a', now)).toBe('unread') + }) + + it('clears when the read mark catches up, as it does when you read the room', () => { + publishHead('a', 5, now) + win.store.set(readKey('a'), '5') + expect(unreadOf('a', now)).toBe('read') + }) + + it('is unknown for a room no tab has reported on', () => { + expect(unreadOf('a', now)).toBe('unknown') + }) + + it('treats a missing read mark as nothing read', () => { + publishHead('a', 1, now) + expect(unreadOf('a', now)).toBe('unread') + }) +}) + +describe('publishHead', () => { + it('announces the write to this tab', () => { + const heard = vi.fn() + win.addEventListener('wave:heads-changed', heard) + publishHead('a', 1, now) + expect(heard).toHaveBeenCalledTimes(1) + }) +}) + +describe('dropping heads', () => { + it('drops one head', () => { + publishHead('a', 1, now) + dropHead('a') + expect(win.store.has(headKey('a'))).toBe(false) + }) + + it('drops heads for rooms the list no longer holds, and nothing else', () => { + publishHead('kept', 1, now) + publishHead('gone', 1, now) + win.store.set(readKey('gone'), '1') + dropHeadsExcept(new Set(['kept'])) + expect([...win.store.keys()].sort()).toEqual([headKey('kept'), readKey('gone')].sort()) + }) +}) diff --git a/app/components/channel/use-unread.ts b/app/components/channel/use-unread.ts new file mode 100644 index 0000000..4f202d5 --- /dev/null +++ b/app/components/channel/use-unread.ts @@ -0,0 +1,75 @@ +'use client' + +import { useSyncExternalStore } from 'react' +import { HEAD_PREFIX, headKey, parseHead, readKey, unreadState, type UnreadState } from '@/lib/unread' + +const CHANGED = 'wave:heads-changed' +let version = 0 + +function read(key: string): string | null { + try { + return window.localStorage.getItem(key) + } catch { + return null + } +} + +export function publishHead(channelId: string, seq: number, now = Date.now()): void { + try { + window.localStorage.setItem(headKey(channelId), JSON.stringify({ seq, at: now })) + } catch { + return + } + window.dispatchEvent(new Event(CHANGED)) +} + +export function dropHead(channelId: string): void { + try { + window.localStorage.removeItem(headKey(channelId)) + } catch { + // Nothing stored, then. + } +} + +/** Heads for rooms the list no longer holds, so none outlives its entry. */ +export function dropHeadsExcept(ids: ReadonlySet): void { + try { + const stale: string[] = [] + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i) + if (key?.startsWith(HEAD_PREFIX) && !ids.has(key.slice(HEAD_PREFIX.length))) stale.push(key) + } + for (const key of stale) window.localStorage.removeItem(key) + } catch { + // Private browsing: there is nothing to prune. + } +} + +export function unreadOf(channelId: string, now: number): UnreadState { + return unreadState(parseHead(read(headKey(channelId))), Number(read(readKey(channelId)) ?? 0) || 0, now) +} + +function subscribe(onChange: () => void): () => void { + const bump = () => { + version++ + onChange() + } + const onStorage = (event: StorageEvent) => { + if (event.key === null || event.key.startsWith(HEAD_PREFIX) || event.key.startsWith('wave.read.')) bump() + } + window.addEventListener('storage', onStorage) + window.addEventListener(CHANGED, bump) + return () => { + window.removeEventListener('storage', onStorage) + window.removeEventListener(CHANGED, bump) + } +} + +/** Changes whenever a head or a read mark does; the reader calls unreadOf in render. */ +export function useUnreadVersion(): number { + return useSyncExternalStore( + subscribe, + () => version, + () => 0, + ) +} diff --git a/app/components/channel/use-visited-channels.ts b/app/components/channel/use-visited-channels.ts index 604ba47..b8412a7 100644 --- a/app/components/channel/use-visited-channels.ts +++ b/app/components/channel/use-visited-channels.ts @@ -1,6 +1,7 @@ 'use client' import { useSyncExternalStore } from 'react' +import { dropHead, dropHeadsExcept } from './use-unread' import { VISITED_KEY, forget, @@ -55,6 +56,7 @@ function update(change: (list: VisitedChannel[]) => VisitedChannel[]): void { export function rememberChannel(entry: Parameters[1], now = Date.now()): void { if (!entry.invite || !Number.isFinite(entry.expiresAt)) return update((list) => record(list, entry, now)) + dropHeadsExcept(new Set(readVisited().map((kept) => kept.id))) } export function noteChannelMessage(id: string, at: number): void { @@ -68,6 +70,7 @@ export function noteChannelMessage(id: string, at: number): void { export function forgetChannel(id: string): void { update((list) => forget(list, id)) + dropHead(id) } /** Unfiltered by expiry: the reader applies `live()` against its own clock. */ diff --git a/lib/unread.test.ts b/lib/unread.test.ts new file mode 100644 index 0000000..0fe437c --- /dev/null +++ b/lib/unread.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { HEAD_FRESH_MS, parseHead, unreadState } from './unread' + +const now = Date.parse('2026-09-29T12:00:00Z') + +describe('unreadState', () => { + it('is unread when the latest message is past the read mark', () => { + expect(unreadState({ seq: 9, at: now }, 7, now)).toBe('unread') + }) + + it('stays unread when the head is old: the message is still unread', () => { + expect(unreadState({ seq: 9, at: now - 10 * HEAD_FRESH_MS }, 7, now)).toBe('unread') + }) + + it('is read when a fresh head is at the mark', () => { + expect(unreadState({ seq: 9, at: now - 1_000 }, 9, now)).toBe('read') + }) + + it('is read when the mark is past the head, as it is after a join nobody needs to see', () => { + expect(unreadState({ seq: 9, at: now }, 12, now)).toBe('read') + }) + + it('is unknown, not read, when the head that said so has gone stale', () => { + expect(unreadState({ seq: 9, at: now - HEAD_FRESH_MS - 1 }, 9, now)).toBe('unknown') + }) + + it('is unknown when no tab has published a head', () => { + expect(unreadState(null, 0, now)).toBe('unknown') + }) +}) + +describe('parseHead', () => { + it('reads what a tab writes', () => { + expect(parseHead(JSON.stringify({ seq: 4, at: now }))).toEqual({ seq: 4, at: now }) + }) + + it.each([null, '', '{', '4', 'null', '{"seq":"4","at":1}', '{"seq":4}'])('returns null for %j', (raw) => { + expect(parseHead(raw)).toBeNull() + }) +}) diff --git a/lib/unread.ts b/lib/unread.ts new file mode 100644 index 0000000..d2e61e9 --- /dev/null +++ b/lib/unread.ts @@ -0,0 +1,47 @@ +/** + * Unread, from what tabs already know (#73). + * + * A tab polling a channel publishes the seq of its latest message as that + * channel's head. Compared with the read mark, that says whether a room in the + * pane has something you have not seen, without a request. + */ + +export type Head = { + /** Seq of the latest message, not of the latest item: a join is not news. */ + seq: number + /** Epoch ms the head was last confirmed. */ + at: number +} + +export type UnreadState = 'unread' | 'read' | 'unknown' + +export const HEAD_PREFIX = 'wave.head.' +export const headKey = (channelId: string) => `${HEAD_PREFIX}${channelId}` +export const readKey = (channelId: string) => `wave.read.${channelId}` + +/** How long a head counts as current: a visible tab re-confirms it at least every 50s poll. */ +export const HEAD_FRESH_MS = 90_000 + +export function parseHead(raw: string | null): Head | null { + if (raw === null) return null + try { + const value: unknown = JSON.parse(raw) + if (typeof value !== 'object' || value === null) return null + const { seq, at } = value as Record + if (typeof seq !== 'number' || !Number.isFinite(seq) || typeof at !== 'number' || !Number.isFinite(at)) return null + return { seq, at } + } catch { + return null + } +} + +/** + * A head past the read mark is unread however old it is: a message was seen + * and nobody has read it. A head at or behind the mark only says caught up + * while it is fresh; after that nobody knows what has happened since. + */ +export function unreadState(head: Head | null, readSeq: number, now: number): UnreadState { + if (head === null) return 'unknown' + if (head.seq > readSeq) return 'unread' + return now - head.at <= HEAD_FRESH_MS ? 'read' : 'unknown' +} From 527bdbf8d961562eab0bf36b4cb0dd96159e00ec Mon Sep 17 00:00:00 2001 From: David S D Date: Tue, 29 Sep 2026 01:51:56 +0530 Subject: [PATCH 09/15] Probe rooms no open tab is covering Closes #74. The pane asks /head about rooms whose head is unknown, so a message in a room nobody has open still raises its dot. It is the only part of the switcher that adds requests, so all of it is throttle: - Visible tabs only. A hidden tab skips its rounds rather than catching up, so coming back to a tab never sets off a burst. - One round every 90s, give or take 15, so tabs side by side drift apart. The first round waits a full interval: opening a page costs nothing. - At most 3 rooms a round, stalest first, so rooms take turns. - Never the room on screen, and never one another tab confirmed in the last 60s. - A 429 ends the round and leaves the dot stale; a 410 forgets the room. It runs from the pane in the /c layout, once per tab, so the phone menu's copy of the list does not probe twice. Measured on the dev server with 10 background rooms and one visible tab: two rounds 98s apart, 3 probes each, 6 distinct rooms. Per visible tab that is about 2 probes a minute beside about 1.2 long polls, and a probe is 4 Redis commands against the poll's dozen. With 3 or fewer rooms each is refreshed every round; with 10, each about every 5 minutes. The figures are on #74. Co-Authored-By: Claude Opus 5.5 --- app/components/channel/channel-list.tsx | 2 + app/components/channel/use-head-probe.test.ts | 116 ++++++++++++++++++ app/components/channel/use-head-probe.ts | 97 +++++++++++++++ lib/unread.test.ts | 40 +++++- lib/unread.ts | 30 +++++ 5 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 app/components/channel/use-head-probe.test.ts create mode 100644 app/components/channel/use-head-probe.ts diff --git a/app/components/channel/channel-list.tsx b/app/components/channel/channel-list.tsx index 175a850..eaae8eb 100644 --- a/app/components/channel/channel-list.tsx +++ b/app/components/channel/channel-list.tsx @@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from 'react' import { byActivity, live, type VisitedChannel } from '@/lib/visited-channels' import { CloseIcon } from '../icons' import { Logo } from '../logo' +import { useHeadProbe } from './use-head-probe' import { unreadOf, useUnreadVersion } from './use-unread' import { forgetChannel, rememberChannel, useVisitedChannels } from './use-visited-channels' @@ -151,6 +152,7 @@ export const channelListNote = 'Kept in this browser only, and gone when each ro * beside it is swapped. */ export function ChannelsPane() { + useHeadProbe() return (