From 725b5fc4e6a7541786ad1c044498a0e231e5baab Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Mon, 10 Aug 2026 10:06:03 +0100 Subject: [PATCH] feat: application-level read state and inbox triage for conversations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conversations were hard to manage because "unread" came straight from Matrix's notification_count, and nothing in the app ever sends a read receipt — so opening a conversation in the dashboard could never clear it and the unread set only ever grew. The list also collapsed three orthogonal facts into one word, labelling "they spoke last and we have not replied" as "Up to date". Read state is now ours: - conversation_read_state table (migration 19) plus ConversationReadStateRepository, kept separate from conversation_states so a read write can never be lost to mergeConversationState. The watermark is monotonic, so a stale poll cannot resurrect an unread badge. - hasUnreadInbound and needsReply are derived server-side and returned on every conversation. Matrix's `unread` stays on the contract but the dashboard no longer reads it. - POST /conversations/{id}/read|unread|status and POST /conversations/bulk-read, with optimistic cache updates. Inbox rework: - List rows are a real grid (checkbox, open button, chip strip) instead of a full-bleed absolute overlay button, which previously forced a pointer-events escape hatch for anything interactive in a row. Rows use aria-current rather than aria-pressed. Not a listbox: options must not contain interactive descendants. - Counted filter pills replace the select, independent state chips replace the collapsed status word, plus a sort control (including oldest unanswered), a per-account filter, age-coloured timestamps, and an explicit count of rows hidden by the seller filter. - Keyboard: j/k/arrows, Enter, u, e, / and Escape. Shortcuts yield to any open dialog so Escape dismisses the modal, not the conversation behind it. Multi-select with bulk mark-read and mark-done. - Detail panel: auto-mark-read on open, a "New messages" divider pinned at mount (deriving it from the live watermark made it vanish a second after opening), triage actions in the header, the automation banner moved out of the composer, day dividers with time-only stamps, and a flex column so history scrolls in its own region instead of painting over the composer. - Row order freezes while a conversation is open, with a "N new — refresh" pill, so a background poll cannot reorder rows mid-triage. Two database tests rewound user_version by an offset from the latest version, which silently re-targets a different migration whenever one is appended. They now pin absolute versions. Co-Authored-By: Claude Opus 5 --- .../conversations/conversation-api.ts | 53 ++ .../conversations/conversation-detail.tsx | 318 ++++++++--- .../conversations/conversation-queries.ts | 106 +++- .../conversations/conversation-route.tsx | 20 +- .../conversations/conversation-triage.ts | 204 ++++++++ .../conversations/conversations-page.tsx | 493 ++++++++++++++---- .../features/conversations/conversations.css | 89 +++- .../features/conversations/relative-time.ts | 37 ++ docs/api.md | 6 +- packages/api-contracts/src/chats.ts | 41 ++ src/bootstrap.ts | 5 + src/database/client.ts | 13 + src/database/repositories.ts | 121 +++++ .../reddit-account-runtime-builder.ts | 5 +- src/services/reddit-account-runtime.ts | 2 + src/web/api-catalog.ts | 23 + src/web/context.ts | 2 + src/web/routes/conversations.ts | 194 ++++++- src/web/server.ts | 5 + tests/conversation-read-state.test.ts | 110 ++++ tests/conversation-triage.test.ts | 232 +++++++++ .../conversation-read-state.test.tsx | 468 +++++++++++++++++ tests/dashboard/conversations.test.tsx | 20 +- tests/dashboard/route-filters.test.tsx | 11 +- tests/database.test.ts | 13 +- tests/web/conversations-route.test.ts | 237 ++++++++- 26 files changed, 2625 insertions(+), 203 deletions(-) create mode 100644 apps/dashboard/src/features/conversations/conversation-triage.ts create mode 100644 tests/conversation-read-state.test.ts create mode 100644 tests/conversation-triage.test.ts create mode 100644 tests/dashboard/conversation-read-state.test.tsx diff --git a/apps/dashboard/src/features/conversations/conversation-api.ts b/apps/dashboard/src/features/conversations/conversation-api.ts index 98ecf62..64cdc88 100644 --- a/apps/dashboard/src/features/conversations/conversation-api.ts +++ b/apps/dashboard/src/features/conversations/conversation-api.ts @@ -11,9 +11,16 @@ import { StartConversationResponseSchema, SuccessResponseSchema, SuggestedReplyResponseSchema, + BulkMarkConversationsReadRequestSchema, + BulkMarkConversationsReadResponseSchema, + MarkConversationReadRequestSchema, + MarkConversationUnreadRequestSchema, + SetConversationStatusRequestSchema, type ActiveChat, + type BulkMarkConversationsReadResponse, type ConversationDetailResponse, type ConversationMediaShareOptions, + type ConversationTriageStatus, type ProcessedPost, type SuccessResponse, } from '../../../../../packages/api-contracts/src/index.js'; @@ -42,6 +49,18 @@ export interface ConversationApi { sendReply(id: string, message: string): Promise; shareMedia(id: string, assetId: string): Promise; startConversation(accountId: string, username: string, message: string): Promise; + markRead(id: string, throughTimestamp?: number): Promise; + markUnread(id: string, beforeTimestamp?: number): Promise; + setStatus(id: string, status: ConversationTriageStatus, snoozeUntil?: number | null): Promise; + bulkMarkRead(ids: string[]): Promise; +} + +function jsonPost(body: unknown): RequestInit { + return { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }; } export function createConversationApi(fetchImplementation?: typeof fetch): ConversationApi { @@ -121,6 +140,40 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve ); return response.chat; }, + async markRead(id, throughTimestamp) { + const body = MarkConversationReadRequestSchema.parse( + throughTimestamp === undefined ? {} : { throughTimestamp }, + ); + return parseResponse( + await request(`${conversationPath(id)}/read`, jsonPost(body)), + SuccessResponseSchema, + ); + }, + async markUnread(id, beforeTimestamp) { + const body = MarkConversationUnreadRequestSchema.parse( + beforeTimestamp === undefined ? {} : { beforeTimestamp }, + ); + return parseResponse( + await request(`${conversationPath(id)}/unread`, jsonPost(body)), + SuccessResponseSchema, + ); + }, + async setStatus(id, status, snoozeUntil) { + const body = SetConversationStatusRequestSchema.parse( + snoozeUntil === undefined ? { status } : { status, snoozeUntil }, + ); + return parseResponse( + await request(`${conversationPath(id)}/status`, jsonPost(body)), + SuccessResponseSchema, + ); + }, + async bulkMarkRead(ids) { + const body = BulkMarkConversationsReadRequestSchema.parse({ conversationIds: ids }); + return parseResponse( + await request('/api/conversations/bulk-read', jsonPost(body)), + BulkMarkConversationsReadResponseSchema, + ); + }, }; } diff --git a/apps/dashboard/src/features/conversations/conversation-detail.tsx b/apps/dashboard/src/features/conversations/conversation-detail.tsx index 8a77eae..ce1b083 100644 --- a/apps/dashboard/src/features/conversations/conversation-detail.tsx +++ b/apps/dashboard/src/features/conversations/conversation-detail.tsx @@ -1,11 +1,18 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import type { ConversationDetailResponse, MediaLibraryAsset } from '../../../../../packages/api-contracts/src/index.js'; +import type { + ChatMessage, + ConversationDetailResponse, + MediaLibraryAsset, +} from '../../../../../packages/api-contracts/src/index.js'; import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js'; import { OriginPostCard } from '../../components/origin-post.js'; import { useConversationDetailQuery, useConversationMediaShareOptionsQuery, + useMarkConversationReadMutation, + useMarkConversationUnreadMutation, useSendReplyMutation, + useSetConversationStatusMutation, useShareConversationMediaMutation, useSuggestMediaAskMutation, useSuggestReplyMutation, @@ -13,14 +20,19 @@ import { import { SendConfirmationDialog } from './send-confirmation-dialog.js'; import { MediaShareConfirmationDialog } from './media-share-confirmation-dialog.js'; import { AccountOwnerBadge } from '../../components/account-owner-badge.js'; -import { formatExactTime } from './relative-time.js'; +import { formatDayLabel, formatExactTime, formatTimeOfDay, isSameDay } from './relative-time.js'; import { getStoredComposerDraft, storeComposerDraft } from './composer-drafts.js'; +import { SNOOZE_OPTIONS, isUnread, triageStatus } from './conversation-triage.js'; /** Reddit chat rejects messages longer than roughly this many characters. */ export const REDDIT_CHAT_MAX_MESSAGE_LENGTH = 10_000; const CHARACTER_WARNING_MARGIN = 500; const SUCCESS_NOTICE_DISMISS_MS = 5_000; const AT_BOTTOM_THRESHOLD_PX = 48; +/** Keeps a little context above the "New messages" rule when jumping to it. */ +const UNREAD_DIVIDER_HEADROOM_PX = 12; +/** Grace period before an open conversation is considered read. */ +export const AUTO_MARK_READ_DELAY_MS = 1_000; interface ConversationDetailProps { conversationId: string; @@ -40,12 +52,35 @@ function useAutoDismiss(isVisible: boolean, dismiss: () => void) { }, [isVisible, dismiss]); } -function MessageHistory({ conversation }: { conversation: ConversationDetailResponse }) { +/** + * Index of the first inbound message the operator has not read. + * + * Returns -1 unless the boundary falls *inside* the loaded history — a + * conversation being opened for the first time has no meaningful boundary, and + * a "New messages" rule above the very first message is noise rather than + * information. + */ +export function firstUnreadIndex(messages: ChatMessage[], lastReadMessageTs: number | undefined): number { + if (!lastReadMessageTs) return -1; + const index = messages.findIndex((message) => !message.isSelf && message.timestamp > lastReadMessageTs); + return index > 0 ? index : -1; +} + +function MessageHistory({ conversation, now }: { conversation: ConversationDetailResponse; now: number }) { const [revealedMedia, setRevealedMedia] = useState>(() => new Set()); const listRef = useRef(null); + const dividerRef = useRef(null); const isAtBottomRef = useRef(true); const [isAtBottom, setIsAtBottom] = useState(true); - const messageCount = conversation.messages?.length ?? 0; + const messages = conversation.messages ?? []; + const messageCount = messages.length; + // Pinned at mount: opening the conversation advances the read watermark a + // second later, and the marker must not disappear from under the operator + // while they are still reading. It resets when they switch conversations, + // because the panel is keyed on the conversation id. + const [readBoundary] = useState(() => conversation.lastReadMessageTs ?? 0); + const unreadIndex = firstUnreadIndex(messages, readBoundary); + const hasScrolledToBoundaryRef = useRef(false); const scrollToBottom = useCallback(() => { const list = listRef.current; @@ -55,11 +90,24 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp setIsAtBottom(true); }, []); - // Stick to the latest message on mount and when new messages arrive, - // unless the operator has scrolled up to read history. + // Land on the first unread message rather than the bottom, so the operator + // starts reading where they left off. Only done once per mount — after that, + // stick to the latest message unless they have scrolled up to read history. useEffect(() => { + const list = listRef.current; + const divider = dividerRef.current; + if (unreadIndex >= 0 && !hasScrolledToBoundaryRef.current && list && divider) { + hasScrolledToBoundaryRef.current = true; + // Scroll the history pane only. `scrollIntoView` would also scroll every + // ancestor scroller, yanking the whole page out of position. + const offset = divider.getBoundingClientRect().top - list.getBoundingClientRect().top; + list.scrollTop = Math.max(0, list.scrollTop + offset - UNREAD_DIVIDER_HEADROOM_PX); + isAtBottomRef.current = false; + setIsAtBottom(false); + return; + } if (isAtBottomRef.current) scrollToBottom(); - }, [messageCount, scrollToBottom]); + }, [messageCount, scrollToBottom, unreadIndex]); function handleScroll() { const list = listRef.current; @@ -69,58 +117,76 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp setIsAtBottom(atBottom); } - if (!conversation.messages || conversation.messages.length === 0) { + if (messageCount === 0) { return
No messages have been loaded for this conversation.
; } return (
    - {conversation.messages.map((message) => ( -
  1. -
    - {message.isSelf - ? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}` - : } - {message.isAi ? AI : null} - -
    -

    {message.text}

    - {message.media?.length ? ( -
    - {message.media.map((media) => { - const mediaKey = `${message.timestamp}-${media.localPath}`; - const sender = message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`; - const isRevealed = revealedMedia.has(mediaKey); + {messages.map((message, index) => { + const previous = index > 0 ? messages[index - 1] : null; + const startsNewDay = previous === null || !isSameDay(previous.timestamp, message.timestamp); + const key = message.eventId ?? `${message.timestamp}-${message.sender}-${message.text}`; - return ( -
    - {`Image - -
    - ); - })} + return [ + startsNewDay ? ( +
  2. + ) : null, + index === unreadIndex ? ( +
  3. + New messages +
  4. + ) : null, +
  5. +
    + {message.isSelf + ? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}` + : } + {message.isAi ? AI : null} +
    - ) : null} -
  6. - ))} +

    {message.text}

    + {message.media?.length ? ( +
    + {message.media.map((media) => { + const mediaKey = `${message.timestamp}-${media.localPath}`; + const sender = message.isSelf ? 'sent by you' : `received from ${redditUsernameLabel(conversation.username)}`; + const isRevealed = revealedMedia.has(mediaKey); + + return ( +
    + {`Image + +
    + ); + })} +
    + ) : null} + , + ]; + })}
{!isAtBottom ? (
-
- {conversation.automation.remainingTurns} automated {conversation.automation.remainingTurns === 1 ? 'turn' : 'turns'} remaining - {automationStatusMessage(conversation)} -