Files
RedditBot/apps/dashboard/src/features/conversations/conversations-page.tsx
T
Jamie BruntonandClaude Opus 5 725b5fc4e6 feat: application-level read state and inbox triage for conversations
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 <noreply@anthropic.com>
2026-08-10 10:06:03 +01:00

553 lines
23 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { ConversationDetail } from './conversation-detail.js';
import {
useBulkMarkConversationsReadMutation,
useConversationsQuery,
useMarkConversationReadMutation,
useMarkConversationUnreadMutation,
useSetConversationStatusMutation,
} from './conversation-queries.js';
import { StartConversationForm } from './start-conversation-form.js';
import { ProcessedUserLookup } from './processed-user-lookup.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
import './conversations.css';
import { ownedConversationId } from './conversation-identity.js';
import { conversationAge, formatExactTime, formatShortRelativeTime } from './relative-time.js';
import {
CONVERSATION_FILTERS,
CONVERSATION_SORTS,
DEFAULT_CONVERSATION_FILTERS,
accountOptions,
filterCounts,
isUnread,
needsReply,
partitionConversations,
triageStatus,
type ConversationFilter,
type ConversationFilters,
type ConversationSort,
} from './conversation-triage.js';
export type { ConversationFilter, ConversationFilters, ConversationSort } from './conversation-triage.js';
const SMALL_VIEWPORT_QUERY = '(max-width: 51.9999rem)';
export interface ConversationsPageProps {
selectedConversationId?: string | null;
onSelectedConversationChange?(conversationId: string | null): void;
filters?: ConversationFilters;
onFiltersChange?(filters: ConversationFilters): void;
}
interface ConversationRowProps {
conversation: ActiveChat;
isSelected: boolean;
isChecked: boolean;
/** Only worth the row space when more than one account is in play. */
showOwner: boolean;
now: number;
onSelect(): void;
onToggleChecked(): void;
registerRef(element: HTMLButtonElement | null): void;
}
/** Independent state chips. A conversation can be several of these at once. */
function conversationChips(conversation: ActiveChat, now: number): string[] {
const chips: string[] = [];
if (isUnread(conversation)) chips.push('Unread');
if (needsReply(conversation)) chips.push('Needs reply');
else if (conversation.isFromUs && triageStatus(conversation) === 'open') chips.push('Waiting on them');
const status = triageStatus(conversation);
if (status === 'snoozed') chips.push('Snoozed');
if (status === 'done') chips.push('Done');
if (needsReply(conversation) && conversationAge(conversation.lastMessageTime, now) !== 'fresh') {
chips.push('Overdue');
}
return chips;
}
function ConversationRow({
conversation, isSelected, isChecked, showOwner, now, onSelect, onToggleChecked, registerRef,
}: ConversationRowProps) {
const relativeTime = formatShortRelativeTime(conversation.lastMessageTime, now);
const unread = isUnread(conversation);
const overdue = needsReply(conversation) ? conversationAge(conversation.lastMessageTime, now) : 'fresh';
const chips = conversationChips(conversation, now);
const itemClasses = ['conversation-list-item'];
if (isSelected) itemClasses.push('is-selected');
if (unread) itemClasses.push('is-unread');
if (isChecked) itemClasses.push('is-checked');
const descriptors = [
redditUsernameLabel(conversation.username),
...chips,
relativeTime,
...(conversation.sellerDetected ? ['Seller'] : []),
...(conversation.hasReceivedImages ? ['Image received'] : []),
];
return (
<li className={itemClasses.join(' ')}>
<label className="conversation-list-check">
<input
aria-label={`Select ${redditUsernameLabel(conversation.username)}`}
checked={isChecked}
onChange={onToggleChecked}
type="checkbox"
/>
</label>
<button
aria-current={isSelected || undefined}
aria-label={descriptors.join(', ')}
className="conversation-list-open"
onClick={onSelect}
ref={registerRef}
type="button"
>
<span className="conversation-list-heading">
<strong>{redditUsernameLabel(conversation.username)}</strong>
<time
className={overdue === 'fresh' ? undefined : `is-${overdue}`}
dateTime={new Date(conversation.lastMessageTime).toISOString()}
title={formatExactTime(conversation.lastMessageTime)}
>
{relativeTime}
</time>
</span>
<span className="conversation-list-snippet">
{conversation.lastMessageSnippet || 'No message preview'}
</span>
</button>
<span className="conversation-list-chips">
{chips.map((chip) => (
<span className={`conversation-chip is-${chip.toLowerCase().replaceAll(' ', '-')}`} key={chip}>
{chip}
</span>
))}
{showOwner ? <AccountOwnerBadge owner={conversation.owner} /> : null}
{conversation.sellerDetected ? <span className="conversation-seller-marker">Seller</span> : null}
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
<span className="conversation-list-profile"><RedditUsernameLink username={conversation.username} /></span>
</span>
</li>
);
}
export function ConversationsPage({
selectedConversationId, onSelectedConversationChange, filters, onFiltersChange,
}: ConversationsPageProps) {
const conversationsQuery = useConversationsQuery();
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(null);
const [localFilters, setLocalFilters] = useState<ConversationFilters>(DEFAULT_CONVERSATION_FILTERS);
const [isStartingConversation, setIsStartingConversation] = useState(false);
const [now, setNow] = useState(() => Date.now());
const [checkedIds, setCheckedIds] = useState<Set<string>>(() => new Set());
const detailPanelRef = useRef<HTMLElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const rowRefs = useRef(new Map<string, HTMLButtonElement>());
const activeConversationId = selectedConversationId !== undefined ? selectedConversationId : internalSelectedId;
const activeFilters = filters ?? localFilters;
const markRead = useMarkConversationReadMutation();
const markUnread = useMarkConversationUnreadMutation();
const setStatus = useSetConversationStatusMutation();
const bulkMarkRead = useBulkMarkConversationsReadMutation();
useEffect(() => {
const interval = window.setInterval(() => setNow(Date.now()), 60_000);
return () => window.clearInterval(interval);
}, []);
// On small viewports the detail panel sits below the list; bring it into
// view when a conversation is picked so the selection isn't invisible.
useEffect(() => {
if (!activeConversationId) return;
if (typeof window.matchMedia !== 'function') return;
if (!window.matchMedia(SMALL_VIEWPORT_QUERY).matches) return;
detailPanelRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
}, [activeConversationId]);
const updateFilters = useCallback((next: ConversationFilters) => {
if (filters === undefined) setLocalFilters(next);
onFiltersChange?.(next);
}, [filters, onFiltersChange]);
const allConversations = conversationsQuery.data ?? [];
const { visible: liveConversations, hiddenSellerCount } = useMemo(
() => partitionConversations(allConversations, activeFilters),
[allConversations, activeFilters],
);
const counts = useMemo(() => filterCounts(allConversations, activeFilters), [allConversations, activeFilters]);
const accounts = useMemo(() => accountOptions(allConversations), [allConversations]);
// While a conversation is open the row order is frozen. A background poll
// re-sorting the list would move rows out from under the operator mid-triage;
// anything new is surfaced as an explicit refresh instead.
const [frozenOrder, setFrozenOrder] = useState<string[] | null>(null);
const liveIds = useMemo(
() => liveConversations.map((conversation) => ownedConversationId(conversation)),
[liveConversations],
);
useEffect(() => {
if (!activeConversationId) {
setFrozenOrder(null);
return;
}
// Capture the order as it was when the conversation was opened. `liveIds` is
// intentionally read once here rather than tracked, so later polls do not
// re-freeze against a reordered list.
setFrozenOrder((current) => current ?? liveIds);
}, [activeConversationId, liveIds]);
const visibleConversations = useMemo(() => {
if (!frozenOrder) return liveConversations;
const rank = new Map(frozenOrder.map((id, index) => [id, index]));
return liveConversations
.filter((conversation) => rank.has(ownedConversationId(conversation)))
.sort((left, right) =>
(rank.get(ownedConversationId(left)) ?? 0) - (rank.get(ownedConversationId(right)) ?? 0));
}, [liveConversations, frozenOrder]);
const pendingCount = liveConversations.length - visibleConversations.length;
const visibleIds = useMemo(
() => visibleConversations.map((conversation) => ownedConversationId(conversation)),
[visibleConversations],
);
function selectConversation(id: string | null) {
if (selectedConversationId === undefined) setInternalSelectedId(id);
onSelectedConversationChange?.(id);
}
function releaseFrozenOrder() {
setFrozenOrder(activeConversationId ? liveIds : null);
}
function toggleChecked(id: string) {
setCheckedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
const checkedVisibleIds = visibleIds.filter((id) => checkedIds.has(id));
const allVisibleChecked = visibleIds.length > 0 && checkedVisibleIds.length === visibleIds.length;
function toggleAllVisible() {
setCheckedIds(allVisibleChecked ? new Set() : new Set(visibleIds));
}
function moveFocus(offset: number) {
if (visibleIds.length === 0) return;
const focused = document.activeElement;
let index = visibleIds.findIndex((id) => rowRefs.current.get(id) === focused);
if (index < 0) index = visibleIds.findIndex((id) => id === activeConversationId);
if (index < 0) {
// Entering the list for the first time: land on the near end rather than
// stepping past it.
rowRefs.current.get(visibleIds[offset > 0 ? 0 : visibleIds.length - 1])?.focus();
return;
}
const nextIndex = Math.min(visibleIds.length - 1, Math.max(0, index + offset));
rowRefs.current.get(visibleIds[nextIndex])?.focus();
}
function focusedConversationId(): string | null {
const focused = document.activeElement;
const focusedId = visibleIds.find((id) => rowRefs.current.get(id) === focused);
return focusedId ?? activeConversationId ?? null;
}
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName);
}
/**
* Inbox keyboard shortcuts. Suppressed whenever focus is inside a text field
* so typing a reply or a search term never triggers an action.
*/
function handleShortcut(event: KeyboardEvent) {
if (event.metaKey || event.ctrlKey || event.altKey) return;
// A modal owns the keyboard while it is open — including Escape, which must
// dismiss the dialog rather than the conversation behind it.
if (event.defaultPrevented || document.querySelector('dialog[open]')) return;
const typing = isTypingTarget(event.target);
if (event.key === '/' && !typing) {
event.preventDefault();
searchRef.current?.focus();
return;
}
if (event.key === 'Escape') {
if (typing) (event.target as HTMLElement).blur();
else if (activeConversationId) selectConversation(null);
return;
}
if (typing) return;
switch (event.key) {
case 'j':
case 'ArrowDown':
event.preventDefault();
moveFocus(1);
break;
case 'k':
case 'ArrowUp':
event.preventDefault();
moveFocus(-1);
break;
case 'u': {
const id = focusedConversationId();
const conversation = allConversations.find((item) => ownedConversationId(item) === id);
if (!id || !conversation) return;
event.preventDefault();
if (isUnread(conversation)) markRead.mutate({ id });
else markUnread.mutate({ id });
break;
}
case 'e': {
const id = focusedConversationId();
if (!id) return;
event.preventDefault();
setStatus.mutate({ id, status: 'done' });
if (id === activeConversationId) selectConversation(null);
break;
}
default:
break;
}
}
// The handler closes over per-render state, so it is kept in a ref and the
// listener is registered once rather than re-bound on every render.
const shortcutRef = useRef(handleShortcut);
shortcutRef.current = handleShortcut;
useEffect(() => {
const listener = (event: KeyboardEvent) => shortcutRef.current(event);
window.addEventListener('keydown', listener);
return () => window.removeEventListener('keydown', listener);
}, []);
const lastSyncedLabel = conversationsQuery.dataUpdatedAt
? formatShortRelativeTime(conversationsQuery.dataUpdatedAt, now)
: null;
return (
<div className="conversations-page">
<header className="conversations-header">
<div>
<span className="conversation-eyebrow">Inbox</span>
<h1>Conversations</h1>
<p>Review replies, draft carefully, and send only after confirmation.</p>
</div>
<div className="conversations-header-actions">
<div className="conversation-refresh">
<button
aria-busy={conversationsQuery.isFetching || undefined}
disabled={conversationsQuery.isFetching}
onClick={() => void conversationsQuery.refetch()}
type="button"
>
{conversationsQuery.isFetching ? 'Refreshing…' : 'Refresh'}
</button>
<span className="conversation-refresh-note">
{lastSyncedLabel ? `Updated ${lastSyncedLabel} · auto every 30s` : 'Auto-refreshes every 30s'}
</span>
</div>
<button className="conversation-primary" onClick={() => setIsStartingConversation((current) => !current)} type="button">
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
</button>
</div>
</header>
{isStartingConversation ? (
<section aria-labelledby="new-conversation-heading" className="new-conversation-panel">
<h2 id="new-conversation-heading">Start a conversation</h2>
<StartConversationForm onStarted={(id) => selectConversation(id)} />
</section>
) : null}
<ProcessedUserLookup />
<div className="conversation-workspace">
<section aria-label="Conversation inbox" className="conversation-master">
<div className="conversation-toolbar">
<label className="sr-only" htmlFor="conversation-search">Search conversations</label>
<input
id="conversation-search"
onChange={(event) => updateFilters({ ...activeFilters, search: event.target.value })}
placeholder="Search conversations (/)"
ref={searchRef}
type="search"
value={activeFilters.search}
/>
<div aria-label="Filter conversations" className="conversation-filter-pills" role="group">
{CONVERSATION_FILTERS.map((option) => (
<button
aria-label={`${option.label}, ${counts[option.value]}`}
aria-pressed={activeFilters.filter === option.value}
className="conversation-filter-pill"
key={option.value}
onClick={() => updateFilters({ ...activeFilters, filter: option.value })}
type="button"
>
{option.label}
<span className="conversation-filter-count">{counts[option.value]}</span>
</button>
))}
</div>
<div className="conversation-toolbar-row">
<label className="conversation-sort">
<span>Sort</span>
<select
onChange={(event) => updateFilters({
...activeFilters,
sort: event.target.value as ConversationSort,
})}
value={activeFilters.sort}
>
{CONVERSATION_SORTS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
{accounts.length > 1 ? (
<label className="conversation-account-filter">
<span>Account</span>
<select
onChange={(event) => updateFilters({ ...activeFilters, account: event.target.value })}
value={activeFilters.account}
>
<option value="all">All accounts</option>
{accounts.map((account) => (
<option key={account.accountId} value={account.accountId}>
u/{account.redditUsername}
</option>
))}
</select>
</label>
) : null}
<label className="conversation-seller-toggle">
<input
checked={activeFilters.showSellers}
onChange={(event) => updateFilters({ ...activeFilters, showSellers: event.target.checked })}
type="checkbox"
/>
Show sellers
</label>
</div>
{visibleConversations.length > 0 ? (
<div className="conversation-bulk-bar">
<label className="conversation-bulk-select-all">
<input
aria-label="Select all visible conversations"
checked={allVisibleChecked}
onChange={toggleAllVisible}
type="checkbox"
/>
{checkedVisibleIds.length > 0 ? `${checkedVisibleIds.length} selected` : 'Select all'}
</label>
{checkedVisibleIds.length > 0 ? (
<>
<button
disabled={bulkMarkRead.isPending}
onClick={() => bulkMarkRead.mutate(checkedVisibleIds, {
onSuccess: () => setCheckedIds(new Set()),
})}
type="button"
>
{bulkMarkRead.isPending ? 'Marking read…' : 'Mark read'}
</button>
<button
disabled={setStatus.isPending}
onClick={() => {
for (const id of checkedVisibleIds) setStatus.mutate({ id, status: 'done' });
setCheckedIds(new Set());
}}
type="button"
>
Mark done
</button>
</>
) : null}
</div>
) : null}
{pendingCount > 0 ? (
<button className="conversation-pending-refresh" onClick={releaseFrozenOrder} type="button">
{pendingCount} new {pendingCount === 1 ? 'conversation' : 'conversations'} — refresh list
</button>
) : null}
</div>
{conversationsQuery.isPending ? (
<div aria-label="Loading conversations" className="conversation-list-state" role="status">Loading conversations…</div>
) : conversationsQuery.isError ? (
<div className="conversation-list-state conversation-inline-error" role="alert">
<strong>Could not load conversations</strong>
<span>{conversationsQuery.error.message}</span>
<button onClick={() => void conversationsQuery.refetch()} type="button">Retry conversations</button>
</div>
) : conversationsQuery.data.length === 0 ? (
<div className="conversation-list-state"><strong>No conversations yet.</strong><span>Start one when you are ready.</span></div>
) : visibleConversations.length === 0 ? (
<div className="conversation-list-state">
<strong>No matching conversations.</strong>
<span>Try a different search or filter.</span>
{hiddenSellerCount > 0 ? (
<span>{hiddenSellerCount} hidden by the seller filter.</span>
) : null}
</div>
) : (
<>
<ul className="conversation-list">
{visibleConversations.map((conversation) => {
const id = ownedConversationId(conversation);
return (
<ConversationRow
conversation={conversation}
isChecked={checkedIds.has(id)}
isSelected={id === activeConversationId}
key={id}
now={now}
onSelect={() => selectConversation(id)}
onToggleChecked={() => toggleChecked(id)}
showOwner={accounts.length > 1}
registerRef={(element) => {
if (element) rowRefs.current.set(id, element);
else rowRefs.current.delete(id);
}}
/>
);
})}
</ul>
<p className="conversation-list-footer">
{hiddenSellerCount > 0
? `${hiddenSellerCount} hidden by the seller filter.`
: null}
<span className="conversation-shortcut-hint">
j/k move · Enter opens · u toggles unread · e marks done · / searches
</span>
</p>
</>
)}
</section>
<section aria-label="Selected conversation" className="conversation-detail-panel" ref={detailPanelRef}>
{activeConversationId ? (
<ConversationDetail conversationId={activeConversationId} key={activeConversationId} onClose={() => selectConversation(null)} />
) : (
<div className="conversation-detail-state">
<strong>Select a conversation</strong>
<span>Message history and reply tools will appear here.</span>
</div>
)}
</section>
</div>
</div>
);
}