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>
This commit is contained in:
Jamie BruntonandClaude Opus 5 committed 2026-08-10 10:06:03 +01:00
1 parent c8689af04d
commit 725b5fc4e6
26 files changed
+2625 -203

No files matched your search

@@ -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<SuccessResponse>;
shareMedia(id: string, assetId: string): Promise<SuccessResponse>;
startConversation(accountId: string, username: string, message: string): Promise<ActiveChat>;
markRead(id: string, throughTimestamp?: number): Promise<SuccessResponse>;
markUnread(id: string, beforeTimestamp?: number): Promise<SuccessResponse>;
setStatus(id: string, status: ConversationTriageStatus, snoozeUntil?: number | null): Promise<SuccessResponse>;
bulkMarkRead(ids: string[]): Promise<BulkMarkConversationsReadResponse>;
}
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,
);
},
};
}
@@ -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<Set<string>>(() => new Set());
const listRef = useRef<HTMLOListElement>(null);
const dividerRef = useRef<HTMLLIElement>(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 <div className="conversation-empty-detail">No messages have been loaded for this conversation.</div>;
}
return (
<div className="conversation-messages-shell">
<ol aria-label="Message history" className="conversation-messages" onScroll={handleScroll} ref={listRef}>
{conversation.messages.map((message) => (
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={message.eventId ?? `${message.timestamp}-${message.sender}-${message.text}`}>
<div>
<strong>{message.isSelf
? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}`
: <RedditUsernameLink username={conversation.username} />}</strong>
{message.isAi ? <span aria-label="Sent by AI" className="conversation-ai-badge">AI</span> : null}
<time dateTime={new Date(message.timestamp).toISOString()}>{formatExactTime(message.timestamp)}</time>
</div>
<p>{message.text}</p>
{message.media?.length ? (
<div className="conversation-message-images">
{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 (
<div className="conversation-message-image" key={mediaKey}>
<img
alt={`Image ${sender}`}
className={isRevealed ? undefined : 'is-blurred'}
loading="lazy"
src={mediaPath(media.localPath)}
/>
<button
aria-pressed={isRevealed}
className="conversation-image-reveal"
onClick={() => setRevealedMedia((current) => {
const next = new Set(current);
if (next.has(mediaKey)) next.delete(mediaKey);
else next.add(mediaKey);
return next;
})}
type="button"
>
{isRevealed ? 'Blur conversation image' : 'Reveal conversation image'}
</button>
</div>
);
})}
return [
startsNewDay ? (
<li aria-hidden="true" className="conversation-day-divider" key={`day-${key}`}>
<span>{formatDayLabel(message.timestamp, now)}</span>
</li>
) : null,
index === unreadIndex ? (
<li className="conversation-unread-divider" key={`unread-${key}`} ref={dividerRef}>
<span>New messages</span>
</li>
) : null,
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={key}>
<div>
<strong>{message.isSelf
? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}`
: <RedditUsernameLink username={conversation.username} />}</strong>
{message.isAi ? <span aria-label="Sent by AI" className="conversation-ai-badge">AI</span> : null}
<time dateTime={new Date(message.timestamp).toISOString()} title={formatExactTime(message.timestamp)}>
{formatTimeOfDay(message.timestamp)}
</time>
</div>
) : null}
</li>
))}
<p>{message.text}</p>
{message.media?.length ? (
<div className="conversation-message-images">
{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 (
<div className="conversation-message-image" key={mediaKey}>
<img
alt={`Image ${sender}`}
className={isRevealed ? undefined : 'is-blurred'}
loading="lazy"
src={mediaPath(media.localPath)}
/>
<button
aria-pressed={isRevealed}
className="conversation-image-reveal"
onClick={() => setRevealedMedia((current) => {
const next = new Set(current);
if (next.has(mediaKey)) next.delete(mediaKey);
else next.add(mediaKey);
return next;
})}
type="button"
>
{isRevealed ? 'Blur conversation image' : 'Reveal conversation image'}
</button>
</div>
);
})}
</div>
) : null}
</li>,
];
})}
</ol>
{!isAtBottom ? (
<button className="conversation-jump-latest" onClick={scrollToBottom} type="button">
@@ -225,13 +291,6 @@ function ConversationComposer({ conversation, conversationId }: { conversation:
{mediaAskMutation.isPending ? 'Generating media ask…' : 'Suggest media ask'}
</button>
</div>
<div
className={`conversation-automation-status is-${conversation.automation.status}`}
role="status"
>
<strong>{conversation.automation.remainingTurns} automated {conversation.automation.remainingTurns === 1 ? 'turn' : 'turns'} remaining</strong>
<span>{automationStatusMessage(conversation)}</span>
</div>
<label className="sr-only" htmlFor="conversation-reply">Reply to {redditUsernameLabel(conversation.username)}</label>
<textarea
id="conversation-reply"
@@ -375,8 +434,120 @@ function automationStatusMessage(conversation: ConversationDetailResponse): stri
}
}
/**
* Triage controls for the open conversation. These live in the header rather
* than the composer because they act on the conversation, not on the draft.
*/
function ConversationTriageActions({ conversation, conversationId, onResolved }: {
conversation: ConversationDetailResponse;
conversationId: string;
onResolved(): void;
}) {
const [isSnoozeOpen, setIsSnoozeOpen] = useState(false);
const markRead = useMarkConversationReadMutation();
const markUnread = useMarkConversationUnreadMutation();
const setStatus = useSetConversationStatusMutation();
const status = triageStatus(conversation);
const unread = isUnread(conversation);
return (
<div className="conversation-triage-actions">
<button
disabled={markRead.isPending || markUnread.isPending}
onClick={() => (unread
? markRead.mutate({ id: conversationId })
: markUnread.mutate({ id: conversationId }))}
type="button"
>
{unread ? 'Mark read' : 'Mark unread'}
</button>
<div className="conversation-snooze">
<button
aria-expanded={isSnoozeOpen}
onClick={() => setIsSnoozeOpen((current) => !current)}
type="button"
>
{status === 'snoozed' ? 'Snoozed' : 'Snooze'}
</button>
{isSnoozeOpen ? (
<div aria-label="Snooze options" className="conversation-snooze-menu" role="group">
{SNOOZE_OPTIONS.map((option) => (
<button
key={option.label}
onClick={() => {
setStatus.mutate({ id: conversationId, status: 'snoozed', snoozeUntil: Date.now() + option.ms });
setIsSnoozeOpen(false);
onResolved();
}}
type="button"
>
{option.label}
</button>
))}
{status === 'snoozed' ? (
<button
onClick={() => {
setStatus.mutate({ id: conversationId, status: 'open' });
setIsSnoozeOpen(false);
}}
type="button"
>
Unsnooze
</button>
) : null}
</div>
) : null}
</div>
{status === 'done' ? (
<button onClick={() => setStatus.mutate({ id: conversationId, status: 'open' })} type="button">
Reopen
</button>
) : (
<button
onClick={() => {
setStatus.mutate({ id: conversationId, status: 'done' });
onResolved();
}}
type="button"
>
Mark done
</button>
)}
</div>
);
}
/**
* Advances the read watermark once the operator has actually had the
* conversation open for a moment. Debounced so switching quickly through rows
* does not mark everything read, and keyed on the newest message so a reply
* arriving while the panel is open is also picked up.
*/
function useAutoMarkRead(conversationId: string, conversation: ConversationDetailResponse | undefined) {
const markRead = useMarkConversationReadMutation();
const markedRef = useRef<string | null>(null);
const newestTimestamp = conversation?.messages?.length
? conversation.messages[conversation.messages.length - 1].timestamp
: conversation?.lastMessageTime ?? 0;
const lastReadMessageTs = conversation?.lastReadMessageTs ?? 0;
const mutate = markRead.mutate;
useEffect(() => {
if (!conversation || newestTimestamp <= lastReadMessageTs) return;
const key = `${conversationId}:${newestTimestamp}`;
if (markedRef.current === key) return;
const timer = window.setTimeout(() => {
markedRef.current = key;
mutate({ id: conversationId, throughTimestamp: newestTimestamp });
}, AUTO_MARK_READ_DELAY_MS);
return () => window.clearTimeout(timer);
}, [conversation, conversationId, newestTimestamp, lastReadMessageTs, mutate]);
}
export function ConversationDetail({ conversationId, onClose }: ConversationDetailProps) {
const detailQuery = useConversationDetailQuery(conversationId);
const [now] = useState(() => Date.now());
useAutoMarkRead(conversationId, detailQuery.data);
if (detailQuery.isPending) {
return <div aria-label="Loading conversation" className="conversation-detail-state" role="status">Loading conversation…</div>;
@@ -392,18 +563,37 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
}
const conversation = detailQuery.data;
const status = triageStatus(conversation);
return (
<article className="conversation-detail">
<header>
<div>
<div className="conversation-detail-identity">
<span className="conversation-eyebrow">Conversation</span>
<h2><RedditUsernameLink username={conversation.username} /></h2>
<AccountOwnerBadge owner={conversation.owner} />
{conversation.sellerDetected ? <span className="conversation-seller-marker">Seller</span> : null}
{conversation.unread ? <span className="conversation-badge">Unread</span> : null}
<span className="conversation-detail-badges">
<AccountOwnerBadge owner={conversation.owner} />
{conversation.sellerDetected ? <span className="conversation-seller-marker">Seller</span> : null}
{isUnread(conversation) ? <span className="conversation-badge">Unread</span> : null}
{status === 'snoozed' ? <span className="conversation-badge is-snoozed">Snoozed</span> : null}
{status === 'done' ? <span className="conversation-badge is-done">Done</span> : null}
</span>
</div>
<div className="conversation-detail-controls">
<ConversationTriageActions
conversation={conversation}
conversationId={conversationId}
onResolved={onClose}
/>
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
</div>
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
</header>
<div
className={`conversation-automation-status is-${conversation.automation.status}`}
role="status"
>
<strong>{conversation.automation.remainingTurns} automated {conversation.automation.remainingTurns === 1 ? 'turn' : 'turns'} remaining</strong>
<span>{automationStatusMessage(conversation)}</span>
</div>
{conversation.syncInProgress ? (
<div className="conversation-sync-notice" role="status">
A {conversation.syncInProgress.kind ?? 'chat'} sync is in progress. Messages may be stale.
@@ -420,7 +610,7 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
}} />
</div>
) : null}
<MessageHistory conversation={conversation} />
<MessageHistory conversation={conversation} now={now} />
<ConversationComposer conversation={conversation} conversationId={conversationId} />
</article>
);
@@ -1,5 +1,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query';
import type { ActiveChat, ConversationTriageStatus } from '../../../../../packages/api-contracts/src/index.js';
import { conversationApi } from './conversation-api.js';
import { ownedConversationId } from './conversation-identity.js';
export const conversationQueryKeys = {
all: ['conversations'] as const,
@@ -78,6 +80,108 @@ export function useShareConversationMediaMutation(id: string) {
});
}
/**
* Applies a triage patch to the cached list and detail entries for one
* conversation. Read-state writes are optimistic because the list only polls
* every 30s — waiting for a refetch would make marking read feel broken.
*/
function patchCachedConversation(
queryClient: QueryClient,
id: string,
patch: Partial<ActiveChat>,
): void {
queryClient.setQueryData<ActiveChat[]>(conversationQueryKeys.all, (current) =>
current?.map((conversation) => (ownedConversationId(conversation) === id
? { ...conversation, ...patch }
: conversation)));
queryClient.setQueryData<ActiveChat>(
conversationQueryKeys.detail(id),
(current) => (current ? { ...current, ...patch } : current),
);
}
function cachedConversation(queryClient: QueryClient, id: string): ActiveChat | undefined {
return queryClient.getQueryData<ActiveChat[]>(conversationQueryKeys.all)
?.find((conversation) => ownedConversationId(conversation) === id);
}
export function useMarkConversationReadMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, throughTimestamp }: { id: string; throughTimestamp?: number }) =>
conversationApi.markRead(id, throughTimestamp),
onMutate({ id, throughTimestamp }) {
const watermark = throughTimestamp ?? cachedConversation(queryClient, id)?.lastMessageTime ?? 0;
patchCachedConversation(queryClient, id, { hasUnreadInbound: false, lastReadMessageTs: watermark });
},
async onSettled() {
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
},
});
}
export function useMarkConversationUnreadMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, beforeTimestamp }: { id: string; beforeTimestamp?: number }) =>
conversationApi.markUnread(id, beforeTimestamp),
onMutate({ id, beforeTimestamp }) {
const boundary = beforeTimestamp ?? cachedConversation(queryClient, id)?.lastMessageTime ?? 0;
patchCachedConversation(queryClient, id, {
hasUnreadInbound: true,
triageStatus: 'open',
snoozedUntil: null,
lastReadMessageTs: Math.max(0, boundary - 1),
});
},
async onSettled() {
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
},
});
}
export function useSetConversationStatusMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status, snoozeUntil }: {
id: string;
status: ConversationTriageStatus;
snoozeUntil?: number | null;
}) => conversationApi.setStatus(id, status, snoozeUntil),
onMutate({ id, status, snoozeUntil }) {
const conversation = cachedConversation(queryClient, id);
patchCachedConversation(queryClient, id, {
triageStatus: status,
snoozedUntil: status === 'snoozed' ? snoozeUntil ?? null : null,
// Resolving a conversation implies you have read it.
...(status === 'done'
? { hasUnreadInbound: false, lastReadMessageTs: conversation?.lastMessageTime ?? 0 }
: {}),
needsReply: status === 'open' && conversation?.isFromUs === false,
});
},
async onSettled() {
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
},
});
}
export function useBulkMarkConversationsReadMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (ids: string[]) => conversationApi.bulkMarkRead(ids),
onMutate(ids) {
for (const id of ids) {
const watermark = cachedConversation(queryClient, id)?.lastMessageTime ?? 0;
patchCachedConversation(queryClient, id, { hasUnreadInbound: false, lastReadMessageTs: watermark });
}
},
async onSettled() {
await queryClient.invalidateQueries({ queryKey: conversationQueryKeys.all });
},
});
}
export function useStartConversationMutation() {
const queryClient = useQueryClient();
return useMutation({
@@ -1,20 +1,24 @@
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { ConversationsPage, type ConversationFilters } from './conversations-page.js';
import { ConversationsPage } from './conversations-page.js';
import {
DEFAULT_CONVERSATION_FILTERS,
isConversationFilter,
isConversationSort,
type ConversationFilters,
} from './conversation-triage.js';
export function ConversationRoute() {
const navigate = useNavigate();
const { conversationId } = useParams<{ conversationId: string }>();
const [searchParams, setSearchParams] = useSearchParams();
const filterValue = searchParams.get('filter');
const sortValue = searchParams.get('sort');
const filters: ConversationFilters = {
search: searchParams.get('q') ?? '',
showSellers: searchParams.get('showSellers') === 'true',
filter:
filterValue === 'unread'
|| filterValue === 'awaiting-reply'
|| filterValue === 'received-images'
? filterValue
: 'all',
filter: isConversationFilter(filterValue) ? filterValue : DEFAULT_CONVERSATION_FILTERS.filter,
sort: isConversationSort(sortValue) ? sortValue : DEFAULT_CONVERSATION_FILTERS.sort,
account: searchParams.get('account') ?? DEFAULT_CONVERSATION_FILTERS.account,
};
function selectConversation(nextConversationId: string | null) {
@@ -31,6 +35,8 @@ export function ConversationRoute() {
if (next.search) params.set('q', next.search); else params.delete('q');
if (next.filter !== 'all') params.set('filter', next.filter); else params.delete('filter');
if (next.showSellers) params.set('showSellers', 'true'); else params.delete('showSellers');
if (next.sort !== DEFAULT_CONVERSATION_FILTERS.sort) params.set('sort', next.sort); else params.delete('sort');
if (next.account !== 'all') params.set('account', next.account); else params.delete('account');
setSearchParams(params, { replace: true });
}
@@ -0,0 +1,204 @@
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
/**
* Inbox buckets. These are the operator's actual work queues, derived from
* application read state rather than Matrix's unread count.
*
* `all` deliberately excludes resolved and snoozed conversations — the default
* view is "what is still on my plate", and the two terminal buckets have their
* own pills so nothing becomes unreachable.
*/
export type ConversationFilter =
| 'all'
| 'needs-reply'
| 'unread'
| 'awaiting-reply'
| 'received-images'
| 'snoozed'
| 'done';
export type ConversationSort = 'recent' | 'oldest-unanswered';
export interface ConversationFilters {
search: string;
filter: ConversationFilter;
showSellers: boolean;
sort: ConversationSort;
/** Reddit account id to narrow to, or `all`. */
account: string;
}
export const DEFAULT_CONVERSATION_FILTERS: ConversationFilters = {
search: '',
filter: 'all',
showSellers: false,
sort: 'recent',
account: 'all',
};
export const CONVERSATION_FILTERS: ReadonlyArray<{ value: ConversationFilter; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'needs-reply', label: 'Needs reply' },
{ value: 'unread', label: 'Unread' },
{ value: 'awaiting-reply', label: 'Waiting on them' },
{ value: 'received-images', label: 'Images' },
{ value: 'snoozed', label: 'Snoozed' },
{ value: 'done', label: 'Done' },
];
export const CONVERSATION_SORTS: ReadonlyArray<{ value: ConversationSort; label: string }> = [
{ value: 'recent', label: 'Newest activity' },
{ value: 'oldest-unanswered', label: 'Oldest unanswered' },
];
export function isConversationFilter(value: string | null): value is ConversationFilter {
return CONVERSATION_FILTERS.some((option) => option.value === value);
}
export function isConversationSort(value: string | null): value is ConversationSort {
return CONVERSATION_SORTS.some((option) => option.value === value);
}
export function triageStatus(conversation: ActiveChat): 'open' | 'snoozed' | 'done' {
return conversation.triageStatus ?? 'open';
}
/**
* True when the newest message is inbound and newer than the read watermark.
*
* Falls back to Matrix's `unread` only when the server has not supplied read
* state at all, so an older backend keeps behaving as before rather than
* showing an inbox with nothing unread.
*/
export function isUnread(conversation: ActiveChat): boolean {
if (conversation.hasUnreadInbound !== undefined) return conversation.hasUnreadInbound;
return conversation.unread;
}
export function needsReply(conversation: ActiveChat): boolean {
if (conversation.needsReply !== undefined) return conversation.needsReply;
return !conversation.isFromUs && triageStatus(conversation) === 'open';
}
/** We have replied and are waiting on them. */
export function isWaitingOnThem(conversation: ActiveChat): boolean {
return conversation.isFromUs && triageStatus(conversation) === 'open';
}
export function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
const status = triageStatus(conversation);
switch (filter) {
case 'needs-reply':
return needsReply(conversation);
case 'unread':
return status === 'open' && isUnread(conversation);
case 'awaiting-reply':
return isWaitingOnThem(conversation);
case 'received-images':
return status === 'open' && conversation.hasReceivedImages === true;
case 'snoozed':
return status === 'snoozed';
case 'done':
return status === 'done';
default:
return status === 'open';
}
}
export function matchesSearch(conversation: ActiveChat, search: string): boolean {
const normalized = search.trim().toLowerCase();
if (normalized.length === 0) return true;
if (conversation.username.toLowerCase().includes(normalized)) return true;
if (conversation.lastMessageSnippet.toLowerCase().includes(normalized)) return true;
// Message text is only present for conversations whose history has been
// loaded, so this is a bonus rather than a guarantee.
return (conversation.messages ?? []).some((message) => message.text.toLowerCase().includes(normalized));
}
function matchesAccount(conversation: ActiveChat, account: string): boolean {
return account === 'all' || conversation.owner?.accountId === account;
}
export interface ConversationListPartition {
/** Conversations passing every active filter, in the requested order. */
visible: ActiveChat[];
/** How many were removed purely by the seller toggle, so the UI can say so. */
hiddenSellerCount: number;
}
/**
* `oldest-unanswered` puts the longest-waiting conversation that needs a reply
* first — the order you actually want when draining a queue — and keeps
* everything else in newest-first order behind it.
*/
function compareConversations(sort: ConversationSort) {
return (left: ActiveChat, right: ActiveChat): number => {
if (sort === 'oldest-unanswered') {
const leftNeeds = needsReply(left);
const rightNeeds = needsReply(right);
if (leftNeeds !== rightNeeds) return leftNeeds ? -1 : 1;
if (leftNeeds && rightNeeds) return left.lastMessageTime - right.lastMessageTime;
}
return right.lastMessageTime - left.lastMessageTime;
};
}
export function partitionConversations(
conversations: readonly ActiveChat[],
filters: ConversationFilters,
): ConversationListPartition {
let hiddenSellerCount = 0;
const visible = conversations.filter((conversation) => {
if (!matchesSearch(conversation, filters.search)) return false;
if (!matchesFilter(conversation, filters.filter)) return false;
if (!matchesAccount(conversation, filters.account)) return false;
if (!filters.showSellers && conversation.sellerDetected === true) {
hiddenSellerCount += 1;
return false;
}
return true;
});
return { visible: visible.sort(compareConversations(filters.sort)), hiddenSellerCount };
}
/**
* Per-bucket counts for the filter pills. Counts respect search, account and
* the seller toggle so a pill never promises rows the list will not show.
*/
export function filterCounts(
conversations: readonly ActiveChat[],
filters: ConversationFilters,
): Record<ConversationFilter, number> {
const candidates = conversations.filter((conversation) =>
matchesSearch(conversation, filters.search)
&& matchesAccount(conversation, filters.account)
&& (filters.showSellers || conversation.sellerDetected !== true));
const counts = {} as Record<ConversationFilter, number>;
for (const { value } of CONVERSATION_FILTERS) {
counts[value] = candidates.filter((conversation) => matchesFilter(conversation, value)).length;
}
return counts;
}
export interface ConversationAccountOption {
accountId: string;
redditUsername: string;
}
/** Distinct owning accounts present in the list, for the account switcher. */
export function accountOptions(conversations: readonly ActiveChat[]): ConversationAccountOption[] {
const seen = new Map<string, string>();
for (const conversation of conversations) {
if (conversation.owner) seen.set(conversation.owner.accountId, conversation.owner.redditUsername);
}
return [...seen]
.map(([accountId, redditUsername]) => ({ accountId, redditUsername }))
.sort((left, right) => left.redditUsername.localeCompare(right.redditUsername));
}
export const SNOOZE_OPTIONS: ReadonlyArray<{ label: string; ms: number }> = [
{ label: '1 hour', ms: 60 * 60_000 },
{ label: 'Tomorrow', ms: 24 * 60 * 60_000 },
{ label: '3 days', ms: 3 * 24 * 60 * 60_000 },
{ label: '1 week', ms: 7 * 24 * 60 * 60_000 },
];
@@ -1,25 +1,39 @@
import { useEffect, useMemo, useRef, useState } from 'react';
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 { useConversationsQuery } from './conversation-queries.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 { formatExactTime, formatShortRelativeTime } from './relative-time.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 = 'all' | 'unread' | 'awaiting-reply' | 'received-images';
export type { ConversationFilter, ConversationFilters, ConversationSort } from './conversation-triage.js';
const SMALL_VIEWPORT_QUERY = '(max-width: 51.9999rem)';
export interface ConversationFilters {
search: string;
filter: ConversationFilter;
showSellers: boolean;
}
export interface ConversationsPageProps {
selectedConversationId?: string | null;
onSelectedConversationChange?(conversationId: string | null): void;
@@ -27,59 +41,118 @@ export interface ConversationsPageProps {
onFiltersChange?(filters: ConversationFilters): void;
}
function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
if (filter === 'unread') return conversation.unread;
if (filter === 'awaiting-reply') return conversation.isFromUs;
if (filter === 'received-images') return conversation.hasReceivedImages === true;
return true;
}
function ConversationListItem({ conversation, isSelected, now, onSelect }: {
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;
}) {
const state = conversation.unread ? 'Unread' : conversation.isFromUs ? 'Awaiting reply' : 'Up to date';
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 (conversation.unread) itemClasses.push('is-unread');
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-label={`${redditUsernameLabel(conversation.username)}, ${state}, ${relativeTime}${conversation.sellerDetected ? ', Seller' : ''}${conversation.hasReceivedImages ? ', Image received' : ''}`}
aria-pressed={isSelected}
className="conversation-list-target"
aria-current={isSelected || undefined}
aria-label={descriptors.join(', ')}
className="conversation-list-open"
onClick={onSelect}
ref={registerRef}
type="button"
/>
<span className="conversation-list-heading">
<strong><RedditUsernameLink username={conversation.username} /></strong>
<span className="conversation-list-meta">
<small>{state}</small>
<time dateTime={new Date(conversation.lastMessageTime).toISOString()} title={formatExactTime(conversation.lastMessageTime)}>
>
<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>
<AccountOwnerBadge owner={conversation.owner} />
{conversation.sellerDetected ? <span className="conversation-seller-marker">Seller</span> : null}
{conversation.hasReceivedImages ? <span className="conversation-image-marker">Image received</span> : null}
<span>{conversation.lastMessageSnippet || 'No message preview'}</span>
</li>
);
}
export function ConversationsPage({ selectedConversationId, onSelectedConversationChange, filters, onFiltersChange }: ConversationsPageProps) {
export function ConversationsPage({
selectedConversationId, onSelectedConversationChange, filters, onFiltersChange,
}: ConversationsPageProps) {
const conversationsQuery = useConversationsQuery();
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(null);
const [localFilters, setLocalFilters] = useState<ConversationFilters>({ search: '', filter: 'all', showSellers: false });
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);
@@ -95,28 +168,54 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
detailPanelRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
}, [activeConversationId]);
function updateFilters(next: ConversationFilters) {
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(() => {
const normalizedSearch = activeFilters.search.trim().toLowerCase();
return (conversationsQuery.data ?? [])
.filter((conversation) => {
const matchesSearch = normalizedSearch.length === 0
|| conversation.username.toLowerCase().includes(normalizedSearch)
|| conversation.lastMessageSnippet.toLowerCase().includes(normalizedSearch);
return matchesSearch
&& matchesFilter(conversation, activeFilters.filter)
&& (activeFilters.showSellers || conversation.sellerDetected !== true);
})
.sort((left, right) => right.lastMessageTime - left.lastMessageTime);
}, [activeFilters, conversationsQuery.data]);
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 unreadCount = useMemo(
() => (conversationsQuery.data ?? []).filter((conversation) => conversation.unread).length,
[conversationsQuery.data],
const pendingCount = liveConversations.length - visibleConversations.length;
const visibleIds = useMemo(
() => visibleConversations.map((conversation) => ownedConversationId(conversation)),
[visibleConversations],
);
function selectConversation(id: string | null) {
@@ -124,6 +223,121 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
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">
@@ -142,7 +356,9 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
>
{conversationsQuery.isFetching ? 'Refreshing…' : 'Refresh'}
</button>
<span className="conversation-refresh-note">Auto-refreshes every 30s</span>
<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'}
@@ -165,30 +381,107 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
<input
id="conversation-search"
onChange={(event) => updateFilters({ ...activeFilters, search: event.target.value })}
placeholder="Search conversations"
placeholder="Search conversations (/)"
ref={searchRef}
type="search"
value={activeFilters.search}
/>
<label className="sr-only" htmlFor="conversation-filter">Filter conversations</label>
<select
id="conversation-filter"
onChange={(event) => updateFilters({ ...activeFilters, filter: event.target.value as ConversationFilter })}
value={activeFilters.filter}
>
<option value="all">All</option>
<option value="unread">Unread</option>
<option value="awaiting-reply">Awaiting reply</option>
<option value="received-images">Received images</option>
</select>
<span className="conversation-unread-count">{unreadCount} unread</span>
<label className="conversation-seller-toggle">
<input
checked={activeFilters.showSellers}
onChange={(event) => updateFilters({ ...activeFilters, showSellers: event.target.checked })}
type="checkbox"
/>
Show sellers
</label>
<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>
@@ -201,19 +494,45 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
) : 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></div>
<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) => (
<ConversationListItem
conversation={conversation}
isSelected={ownedConversationId(conversation) === activeConversationId}
key={ownedConversationId(conversation)}
now={now}
onSelect={() => selectConversation(ownedConversationId(conversation))}
/>
))}
</ul>
<>
<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>
@@ -40,39 +40,88 @@
.conversation-field input:focus, .conversation-field textarea:focus, .conversation-toolbar input:focus, .conversation-toolbar select:focus, .conversation-composer textarea:focus { outline: 2px solid var(--accent); outline-offset: 2px; }
.conversation-workspace { display: grid; gap: .75rem; }
.conversation-master, .conversation-detail-panel { min-width: 0; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; overflow: hidden; }
.conversation-toolbar { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem; padding: .75rem; border-bottom: 1px solid var(--border); }
.conversation-unread-count { grid-column: 1 / -1; color: var(--accent); font-size: .75rem; font-weight: 800; }
.conversation-seller-toggle { grid-column: 1 / -1; display: inline-flex; align-items: center; gap: .45rem; color: var(--muted); font-size: .78rem; font-weight: 700; }
.conversation-toolbar { display: grid; gap: .55rem; padding: .75rem; border-bottom: 1px solid var(--border); }
.conversation-toolbar-row { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; }
.conversation-seller-toggle { display: inline-flex; align-items: center; gap: .45rem; color: var(--muted); font-size: .78rem; font-weight: 700; white-space: nowrap; }
.conversation-seller-toggle input { width: auto; accent-color: var(--accent); }
.conversation-sort, .conversation-account-filter { display: inline-flex; align-items: center; gap: .4rem; color: var(--muted); font-size: .78rem; font-weight: 700; }
.conversation-sort select, .conversation-account-filter select { padding: .35rem .45rem; color: #e9f1ec; background: #0b1410; border: 1px solid var(--border); border-radius: .45rem; font-size: .78rem; }
/* Filter pills replace a select so every bucket's count is visible at a glance. */
.conversation-filter-pills { display: flex; flex-wrap: wrap; gap: .35rem; }
.conversation-filter-pill { display: inline-flex; align-items: center; gap: .4rem; min-height: 2rem !important; padding: .3rem .6rem !important; border-radius: 999px !important; font-size: .78rem; font-weight: 700; }
.conversation-filter-pill[aria-pressed='true'] { color: #06140b !important; background: var(--accent) !important; border-color: transparent !important; }
.conversation-filter-count { padding: .05rem .35rem; border-radius: 999px; background: rgba(255, 255, 255, .12); font-size: .7rem; font-weight: 800; font-variant-numeric: tabular-nums; }
.conversation-filter-pill[aria-pressed='true'] .conversation-filter-count { background: rgba(0, 0, 0, .22); }
.conversation-bulk-bar { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; }
.conversation-bulk-select-all { display: inline-flex; align-items: center; gap: .45rem; color: var(--muted); font-size: .78rem; font-weight: 700; white-space: nowrap; }
.conversation-bulk-select-all input { accent-color: var(--accent); }
.conversation-bulk-bar button { min-height: 2rem !important; padding: .3rem .6rem !important; font-size: .78rem; }
.conversation-pending-refresh { justify-self: start; min-height: 2rem !important; padding: .3rem .65rem !important; color: var(--warning-text) !important; background: var(--warning-bg) !important; border-color: var(--warning-border) !important; font-size: .78rem; font-weight: 700; }
.conversation-list { display: grid; gap: .35rem; max-height: 46rem; padding: .5rem; margin: 0; overflow: auto; list-style: none; }
.conversation-list-item { position: relative; display: grid; gap: .4rem; width: 100%; padding: .8rem; text-align: left; background: transparent; border: 1px solid transparent; border-radius: .6rem; }
/*
* The row is a grid of real siblings: a selection checkbox, a primary open
* button, and a chip strip. The previous full-bleed absolute overlay button made
* every per-row control need a pointer-events escape hatch.
*/
.conversation-list-item { position: relative; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: .15rem .5rem; width: 100%; padding: .6rem .7rem; background: transparent; border: 1px solid transparent; border-radius: .6rem; }
.conversation-list-item:hover { background: var(--surface-raised); }
.conversation-list-item.is-selected { background: var(--accent-dark); border-color: #28593d; }
.conversation-list-item.is-unread { padding-left: 1.4rem; }
.conversation-list-item.is-unread::before { content: ''; position: absolute; top: 1.2rem; left: .55rem; width: .45rem; height: .45rem; background: var(--accent); border-radius: 50%; }
.conversation-list-item.is-checked { border-color: var(--accent); }
.conversation-list-item.is-unread .conversation-list-heading strong { font-weight: 900; }
.conversation-list-target { position: absolute; inset: 0; z-index: 0; min-height: 0 !important; padding: 0 !important; background: transparent !important; border: 0 !important; }
.conversation-list-target:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.conversation-list-item > span { position: relative; z-index: 1; pointer-events: none; }
.conversation-list-item .reddit-username-link { pointer-events: auto; }
.conversation-list-item > span:last-child { overflow: hidden; color: var(--muted); font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
.conversation-list-item.is-unread .conversation-list-heading strong::before { content: ''; display: inline-block; width: .45rem; height: .45rem; margin-right: .45rem; vertical-align: middle; background: var(--accent); border-radius: 50%; }
.conversation-list-check { display: flex; align-items: start; padding-top: .3rem; }
.conversation-list-check input { accent-color: var(--accent); }
.conversation-list-open { display: grid; gap: .25rem; width: 100%; min-height: 0 !important; padding: 0 !important; text-align: left; background: transparent !important; border: 0 !important; }
.conversation-list-open:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.conversation-list-snippet { overflow: hidden; color: var(--muted); font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
.conversation-list-heading { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
.conversation-list-heading small { color: var(--accent); }
.conversation-list-meta { display: flex; align-items: center; gap: .45rem; }
.conversation-list-meta time { color: var(--muted); font-size: .7rem; white-space: nowrap; }
.conversation-list-heading time { color: var(--muted); font-size: .7rem; white-space: nowrap; }
.conversation-list-heading time.is-stale { color: var(--warning-text); font-weight: 700; }
.conversation-list-heading time.is-critical { color: var(--danger-text); font-weight: 800; }
.conversation-list-chips { grid-column: 2; display: flex; flex-wrap: wrap; align-items: center; gap: .3rem; }
.conversation-list-profile { font-size: .7rem; }
.conversation-chip { padding: .1rem .4rem; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-raised); color: var(--muted); font-size: .68rem; font-weight: 750; }
.conversation-chip.is-unread { color: var(--accent); border-color: #28593d; background: var(--accent-dark); }
.conversation-chip.is-needs-reply { color: #cfe8ff; border-color: #315d85; background: #18324d; }
.conversation-chip.is-overdue { color: var(--warning-text); border-color: var(--warning-border); background: var(--warning-bg); }
.conversation-chip.is-snoozed, .conversation-chip.is-done { opacity: .85; }
.conversation-list-footer { display: flex; flex-wrap: wrap; justify-content: space-between; gap: .5rem; padding: .5rem .75rem .75rem; margin: 0; color: var(--muted); font-size: .72rem; }
.conversation-shortcut-hint { margin-left: auto; opacity: .8; }
.conversation-image-marker { justify-self: start; padding: .18rem .45rem; color: #d8e8ff !important; background: #18324d; border: 1px solid #315d85; border-radius: 999px; font-size: .68rem !important; font-weight: 750; }
.conversation-seller-marker { display: inline-flex; justify-self: start; padding: .18rem .45rem; margin-right: .35rem; color: #ffd6d0 !important; background: #4b1e1a; border: 1px solid #874139; border-radius: 999px; font-size: .68rem !important; font-weight: 800; }
.conversation-list-state, .conversation-detail-state { display: grid; place-items: center; align-content: center; gap: .5rem; min-height: 16rem; padding: 1.5rem; color: var(--muted); text-align: center; }
.conversation-list-state strong, .conversation-detail-state strong { color: #e9f1ec; }
.conversation-detail { min-height: 32rem; }
.conversation-detail > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: 1rem; border-bottom: 1px solid var(--border); }
/* Column layout: history takes the slack, the composer stays reachable. */
.conversation-detail { display: flex; flex-direction: column; min-height: 32rem; max-height: min(48rem, calc(100vh - 6rem)); }
.conversation-detail > header { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: .75rem; padding: 1rem; border-bottom: 1px solid var(--border); }
.conversation-detail h2 { display: inline; margin-right: .6rem; }
.conversation-detail-identity { min-width: 0; }
.conversation-detail-badges { display: inline-flex; flex-wrap: wrap; align-items: center; gap: .35rem; }
.conversation-detail-controls { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
.conversation-triage-actions { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; }
.conversation-triage-actions button, .conversation-detail-controls > button { min-height: 2.1rem !important; padding: .35rem .6rem !important; font-size: .8rem; }
.conversation-snooze { position: relative; }
.conversation-snooze-menu { position: absolute; right: 0; z-index: 3; display: grid; gap: .2rem; min-width: 9rem; padding: .35rem; margin-top: .25rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .55rem; box-shadow: 0 .5rem 1.5rem rgba(0, 0, 0, .45); }
.conversation-snooze-menu button { width: 100%; text-align: left; }
.conversation-badge { display: inline-flex; padding: .2rem .45rem; color: #aaf0c4; background: #163a26; border-radius: 999px; font-size: .7rem; font-weight: 800; }
.conversation-badge.is-snoozed { color: var(--warning-text); background: var(--warning-bg); }
.conversation-badge.is-done { color: var(--muted); background: var(--surface-raised); }
.conversation-sync-notice { padding: .75rem 1rem; color: #ffe1a0; background: #2a2111; border-bottom: 1px solid #59451e; }
.triggering-post { margin: 1rem; padding: .75rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .65rem; }
.conversation-messages-shell { position: relative; }
.conversation-messages-shell { position: relative; display: flex; flex: 1 1 auto; min-height: 0; overflow: hidden; }
.conversation-jump-latest { position: absolute; right: 1rem; bottom: .75rem; z-index: 2; padding: .35rem .7rem !important; min-height: 2rem !important; color: #06140b !important; background: var(--accent) !important; border-color: transparent !important; border-radius: 999px !important; font-size: .78rem; font-weight: 800; box-shadow: 0 .4rem 1.2rem rgba(0, 0, 0, .35); }
.conversation-messages { display: grid; gap: .75rem; max-height: 30rem; padding: 1rem; margin: 0; overflow: auto; list-style: none; }
.conversation-messages { display: grid; flex: 1 1 auto; align-content: start; gap: .75rem; min-height: 0; padding: 1rem; margin: 0; overflow-y: auto; list-style: none; }
/* Day dividers let the reader see silences; the unread rule marks where to resume. */
.conversation-day-divider, .conversation-unread-divider { display: flex; align-items: center; gap: .6rem; justify-self: stretch; max-width: none !important; padding: 0 !important; background: none !important; }
.conversation-day-divider::before, .conversation-day-divider::after, .conversation-unread-divider::before, .conversation-unread-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.conversation-day-divider span { color: var(--muted); font-size: .7rem; font-weight: 750; text-transform: uppercase; letter-spacing: .06em; }
.conversation-unread-divider::before, .conversation-unread-divider::after { background: var(--accent); opacity: .6; }
.conversation-unread-divider span { padding: .1rem .5rem; border-radius: 999px; background: var(--accent); color: #06140b; font-size: .68rem; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; }
.conversation-messages li { max-width: 85%; padding: .7rem .8rem; border-radius: .8rem; }
.conversation-messages li.is-self { justify-self: end; background: var(--accent-dark); }
.conversation-messages li.is-recipient { justify-self: start; background: var(--surface-raised); }
@@ -86,7 +135,7 @@
.conversation-image-reveal { position: absolute; bottom: .5rem; left: .5rem; min-height: 2rem !important; padding: .35rem .6rem !important; color: white !important; background: rgba(0, 0, 0, .76) !important; border-color: rgba(255, 255, 255, .45) !important; font-size: .75rem; font-weight: 800; }
.conversation-image-reveal:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.conversation-empty-detail { padding: 2rem; color: var(--muted); text-align: center; }
.conversation-composer { display: grid; gap: .65rem; padding: 1rem; border-top: 1px solid var(--border); }
.conversation-composer { flex: 0 0 auto; display: grid; gap: .65rem; padding: 1rem; overflow-y: auto; border-top: 1px solid var(--border); }
.conversation-section-heading, .conversation-actions { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
.conversation-section-heading h3 { margin: 0; }
.conversation-actions > span { color: var(--muted); font-size: .75rem; }
@@ -124,7 +173,7 @@ dialog.conversation-media-confirmation { width: min(34rem, calc(100% - 2rem)); }
@media (min-width: 52rem) {
.conversations-page { padding: 3rem 2rem 5rem; }
.start-conversation-form { grid-template-columns: minmax(12rem, .5fr) minmax(18rem, 1fr) auto; align-items: end; }
.conversation-workspace { grid-template-columns: minmax(17rem, .75fr) minmax(28rem, 1.5fr); }
.conversation-workspace { grid-template-columns: minmax(21rem, .85fr) minmax(28rem, 1.5fr); }
}
.conversation-automation-status {
display: flex;
@@ -18,3 +18,40 @@ export function formatShortRelativeTime(timestamp: number, now: number): string
export function formatExactTime(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
}
/** Time of day only. Paired with a day divider so history is not full of dates. */
export function formatTimeOfDay(timestamp: number): string {
return new Intl.DateTimeFormat(undefined, { timeStyle: 'short' }).format(timestamp);
}
export function isSameDay(left: number, right: number): boolean {
const a = new Date(left);
const b = new Date(right);
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate();
}
/** "Today" / "Yesterday" / an explicit date, for message-history day dividers. */
export function formatDayLabel(timestamp: number, now: number): string {
if (isSameDay(timestamp, now)) return 'Today';
if (isSameDay(timestamp, now - DAY_MS)) return 'Yesterday';
return new Intl.DateTimeFormat(undefined, { dateStyle: 'full' }).format(timestamp);
}
export const STALE_REPLY_THRESHOLD_MS = 12 * HOUR_MS;
export const CRITICAL_REPLY_THRESHOLD_MS = 48 * HOUR_MS;
export type ConversationAge = 'fresh' | 'stale' | 'critical';
/**
* How overdue an unanswered conversation is. Only meaningful for conversations
* waiting on us — "3d ago" on a conversation we already replied to is fine, but
* on one awaiting a reply it is the thing the operator most needs to notice.
*/
export function conversationAge(timestamp: number, now: number): ConversationAge {
const elapsed = Math.max(0, now - timestamp);
if (elapsed >= CRITICAL_REPLY_THRESHOLD_MS) return 'critical';
if (elapsed >= STALE_REPLY_THRESHOLD_MS) return 'stale';
return 'fresh';
}
+5 -1
View File
@@ -75,9 +75,13 @@ an administrator credential.
| `POST` | `/api/conversations/{id}/suggest-reply` | Generate a reply suggestion | admin | yes |
| `POST` | `/api/conversations/{id}/suggest-media-ask` | Generate a media request suggestion | admin | yes |
| `POST` | `/api/conversations/{id}/send-reply` | Send a conversation reply | admin | yes |
| `GET` | `/api/conversations/{id}/shareable-media` | List exchange-library images allowed for the latest inbound request | read | no |
| `GET` | `/api/conversations/{id}/shareable-media` | List every enabled exchange-library image not yet shared in this conversation | read | no |
| `POST` | `/api/conversations/{id}/share-media` | Send a selected exchange-library image | admin | yes |
| `POST` | `/api/conversations/start` | Start a new conversation | admin | yes |
| `POST` | `/api/conversations/{id}/read` | Advance the operator read watermark | admin | yes |
| `POST` | `/api/conversations/{id}/unread` | Return a conversation to the unread queue | admin | yes |
| `POST` | `/api/conversations/{id}/status` | Snooze, resolve or reopen a conversation | admin | yes |
| `POST` | `/api/conversations/bulk-read` | Mark many conversations read in one request | admin | yes |
| `GET` | `/api/media` | List received private media | read | no |
| `GET` | `/api/media/sync` | Get media sync state | read | no |
| `POST` | `/api/media/sync` | Trigger media sync | admin | yes |
+41
View File
@@ -18,12 +18,20 @@ export const ChatMessageSchema = z.strictObject({
isAi: z.boolean().optional(),
media: z.array(ChatMediaSchema).optional(),
});
/** Operator-maintained triage state. Independent of Matrix's own unread count. */
export const ConversationTriageStatusSchema = z.enum(['open', 'snoozed', 'done']);
export const ActiveChatSchema = z.strictObject({
username: z.string(),
roomName: z.string(),
lastMessageTime: z.number(),
lastMessageSnippet: z.string(),
isFromUs: z.boolean(),
/**
* Matrix's own notification count. Retained for compatibility but not a
* reliable signal — the app never sends read receipts, so it never clears.
* The dashboard reads `hasUnreadInbound` instead.
*/
unread: z.boolean(),
url: z.string().optional(),
roomId: z.string().optional(),
@@ -32,6 +40,14 @@ export const ActiveChatSchema = z.strictObject({
hasReceivedImages: z.boolean().optional(),
sellerDetected: z.boolean().optional(),
owner: RedditAccountOwnerSchema.optional(),
/** True when the newest message is inbound and newer than the read watermark. */
hasUnreadInbound: z.boolean().optional(),
/** True when the newest message is inbound and the conversation is still open. */
needsReply: z.boolean().optional(),
triageStatus: ConversationTriageStatusSchema.optional(),
snoozedUntil: z.number().nullable().optional(),
/** Timestamp of the newest message the operator has read. */
lastReadMessageTs: z.number().optional(),
});
export const ChatsResponseSchema = z.array(ActiveChatSchema);
export const ConversationsResponseSchema = ChatsResponseSchema;
@@ -102,6 +118,28 @@ export const ConversationMediaShareOptionsResponseSchema = z.strictObject({
warning: z.string().nullable(),
});
export const ShareConversationMediaRequestSchema = z.strictObject({ assetId: z.string().uuid() });
export const MarkConversationReadRequestSchema = z.strictObject({
/** Defaults to the conversation's newest message when omitted. */
throughTimestamp: z.number().int().nonnegative().optional(),
});
export const MarkConversationUnreadRequestSchema = z.strictObject({
/** Defaults to the conversation's newest message when omitted. */
beforeTimestamp: z.number().int().nonnegative().optional(),
});
export const SetConversationStatusRequestSchema = z.strictObject({
status: ConversationTriageStatusSchema,
/** Required when status is `snoozed`. */
snoozeUntil: z.number().int().positive().nullable().optional(),
});
export const BulkMarkConversationsReadRequestSchema = z.strictObject({
conversationIds: z.array(z.string().min(1)).min(1).max(500),
});
export const BulkMarkConversationsReadResponseSchema = z.strictObject({
success: z.literal(true),
updated: z.number().int().nonnegative(),
failed: z.array(z.string()),
});
export const StartConversationRequestSchema = z.strictObject({
accountId: z.string().min(1),
username: z.string().min(1),
@@ -122,6 +160,9 @@ export const FetchAllChatsResponseSchema = z.strictObject({
});
export type ActiveChat = z.infer<typeof ActiveChatSchema>;
export type ChatMessage = z.infer<typeof ChatMessageSchema>;
export type ConversationTriageStatus = z.infer<typeof ConversationTriageStatusSchema>;
export type BulkMarkConversationsReadResponse = z.infer<typeof BulkMarkConversationsReadResponseSchema>;
export type ChatSyncStatus = z.infer<typeof ChatSyncStatusSchema>;
export type ConversationDetailResponse = z.infer<typeof ConversationDetailResponseSchema>;
export type ConversationMediaShareOptions = z.infer<typeof ConversationMediaShareOptionsResponseSchema>;
+5
View File
@@ -10,6 +10,7 @@ import {
ActivityLogRepository,
RedditUsersRepository,
ConversationStateRepository,
ConversationReadStateRepository,
ConversationOutcomesRepository,
DeliveryOutboxRepository,
RecipientHistoryRepository,
@@ -70,6 +71,7 @@ export interface AppContainer {
successfulMessagesRepo: SuccessfulMessagesRepository;
deliveryOutboxRepo: DeliveryOutboxRepository;
conversationStateRepo: ConversationStateRepository;
conversationReadStateRepo: ConversationReadStateRepository;
conversationOutcomesRepo: ConversationOutcomesRepository;
recipientDeduplicator: RecipientDeduplicator;
candidateQueueRepo: CandidateQueueRepository;
@@ -130,6 +132,7 @@ export async function bootstrap(
const redditUsersRepo = new RedditUsersRepository(db);
const deliveryOutboxRepo = new DeliveryOutboxRepository(db, bootstrapAccountId);
const conversationStateRepo = new ConversationStateRepository(db, bootstrapAccountId);
const conversationReadStateRepo = new ConversationReadStateRepository(db, bootstrapAccountId);
const conversationOutcomesRepo = new ConversationOutcomesRepository(db, bootstrapAccountId);
const recipientDeduplicator = new RecipientDeduplicator(new RecipientHistoryRepository(db));
const candidateQueueRepo = new CandidateQueueRepository(db);
@@ -447,6 +450,7 @@ export async function bootstrap(
webServer.setRedditAccountsService(redditAccounts);
webServer.setAccountRuntimeManager(accountRuntimeManager);
webServer.setConversationStateRepository(conversationStateRepo);
webServer.setConversationReadStateRepository(conversationReadStateRepo);
webServer.setCandidateQueueRepository(candidateQueueRepo);
webServer.setConfigStore(new ConfigStore(db));
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
@@ -480,6 +484,7 @@ export async function bootstrap(
successfulMessagesRepo,
deliveryOutboxRepo,
conversationStateRepo,
conversationReadStateRepo,
conversationOutcomesRepo,
recipientDeduplicator,
candidateQueueRepo,
+13
View File
@@ -412,6 +412,19 @@ export function runMigrations(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_media_library_sends_conversation
ON media_library_sends(account_id, conversation_id, requested_at);
`),
() => db.exec(`
CREATE TABLE IF NOT EXISTS conversation_read_state (
account_id TEXT NOT NULL,
conversation_id TEXT NOT NULL,
last_read_message_ts INTEGER NOT NULL DEFAULT 0,
last_read_at INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open', 'snoozed', 'done')),
snoozed_until INTEGER,
PRIMARY KEY (account_id, conversation_id)
);
CREATE INDEX IF NOT EXISTS idx_conversation_read_state_status
ON conversation_read_state(account_id, status, snoozed_until);
`),
];
let currentVersion = db.pragma('user_version', { simple: true }) as number;
+121
View File
@@ -1180,6 +1180,127 @@ export class ConversationStateRepository {
}
}
/**
* Operator-facing triage state for a conversation.
*
* This is deliberately separate from `conversation_states`: that blob is owned
* by the automation pipeline and goes through `mergeConversationState`, whereas
* read state is written by the dashboard on every open and must never lose a
* write to a merge. Matrix's own `unread_notifications` count is not usable for
* this — nothing in the app sends read receipts, so it never clears.
*/
export type ConversationTriageStatus = 'open' | 'snoozed' | 'done';
export interface ConversationReadStateRecord {
conversationId: string;
lastReadMessageTs: number;
lastReadAt: number;
status: ConversationTriageStatus;
snoozedUntil: number | null;
}
interface ConversationReadStateRow {
conversation_id: string;
last_read_message_ts: number;
last_read_at: number;
status: ConversationTriageStatus;
snoozed_until: number | null;
}
function toReadStateRecord(row: ConversationReadStateRow): ConversationReadStateRecord {
return {
conversationId: row.conversation_id,
lastReadMessageTs: row.last_read_message_ts,
lastReadAt: row.last_read_at,
status: row.status,
snoozedUntil: row.snoozed_until,
};
}
export class ConversationReadStateRepository {
constructor(
private readonly db: Database.Database,
private readonly accountId: string = LEGACY_UNASSIGNED_ACCOUNT_ID,
) {}
get(conversationId: string): ConversationReadStateRecord | undefined {
const row = this.db.prepare(`
SELECT conversation_id, last_read_message_ts, last_read_at, status, snoozed_until
FROM conversation_read_state WHERE account_id = ? AND conversation_id = ?
`).get(this.accountId, conversationId) as ConversationReadStateRow | undefined;
return row ? toReadStateRecord(row) : undefined;
}
list(): ConversationReadStateRecord[] {
const rows = this.db.prepare(`
SELECT conversation_id, last_read_message_ts, last_read_at, status, snoozed_until
FROM conversation_read_state WHERE account_id = ?
`).all(this.accountId) as ConversationReadStateRow[];
return rows.map(toReadStateRecord);
}
/**
* Advances the read watermark. Never moves backwards, so a stale poll landing
* after the operator has read further cannot resurrect an unread badge.
*/
markRead(conversationId: string, throughTimestamp: number, now = Date.now()): void {
this.db.prepare(`
INSERT INTO conversation_read_state (account_id, conversation_id, last_read_message_ts, last_read_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(account_id, conversation_id) DO UPDATE SET
last_read_message_ts = MAX(last_read_message_ts, excluded.last_read_message_ts),
last_read_at = excluded.last_read_at
`).run(this.accountId, conversationId, Math.max(0, throughTimestamp), now);
}
/**
* Pushes a conversation back onto the queue by rewinding the watermark to just
* before the message the operator wants to see as new again.
*/
markUnread(conversationId: string, beforeTimestamp: number, now = Date.now()): void {
const watermark = Math.max(0, beforeTimestamp - 1);
this.db.prepare(`
INSERT INTO conversation_read_state (account_id, conversation_id, last_read_message_ts, last_read_at, status)
VALUES (?, ?, ?, ?, 'open')
ON CONFLICT(account_id, conversation_id) DO UPDATE SET
last_read_message_ts = excluded.last_read_message_ts,
last_read_at = excluded.last_read_at,
status = 'open',
snoozed_until = NULL
`).run(this.accountId, conversationId, watermark, now);
}
setStatus(
conversationId: string,
status: ConversationTriageStatus,
snoozedUntil: number | null = null,
now = Date.now(),
): void {
this.db.prepare(`
INSERT INTO conversation_read_state (account_id, conversation_id, last_read_message_ts, last_read_at, status, snoozed_until)
VALUES (?, ?, 0, ?, ?, ?)
ON CONFLICT(account_id, conversation_id) DO UPDATE SET
status = excluded.status,
snoozed_until = excluded.snoozed_until,
last_read_at = excluded.last_read_at
`).run(this.accountId, conversationId, now, status, status === 'snoozed' ? snoozedUntil : null);
}
/** Returns snoozed conversations whose snooze has expired, so they can reopen. */
expireSnoozes(now = Date.now()): string[] {
const rows = this.db.prepare(`
SELECT conversation_id FROM conversation_read_state
WHERE account_id = ? AND status = 'snoozed' AND snoozed_until IS NOT NULL AND snoozed_until <= ?
`).all(this.accountId, now) as Array<{ conversation_id: string }>;
if (rows.length === 0) return [];
this.db.prepare(`
UPDATE conversation_read_state SET status = 'open', snoozed_until = NULL, last_read_at = ?
WHERE account_id = ? AND status = 'snoozed' AND snoozed_until IS NOT NULL AND snoozed_until <= ?
`).run(now, this.accountId, now);
return rows.map((row) => row.conversation_id);
}
}
/** Durable idempotency claims for AI replies to individual inbound events. */
export interface ConversationAutomationClaimRecord {
conversationId: string;
@@ -7,6 +7,7 @@ import { BrowserClient } from '../browser/client.js';
import {
PostsRepository, MessagesRepository, SuccessfulMessagesRepository,
DeliveryOutboxRepository, ConversationStateRepository,
ConversationReadStateRepository,
ConversationAutomationClaimRepository,
ConversationOutcomesRepository, RedditUsersRepository,
ActivityLogRepository,
@@ -158,6 +159,7 @@ async function buildRuntime(
const successfulMessagesRepo = new SuccessfulMessagesRepository(db, session.accountId);
const deliveryOutboxRepo = new DeliveryOutboxRepository(db, session.accountId);
const conversationStateRepo = new ConversationStateRepository(db, session.accountId);
const conversationReadStateRepo = new ConversationReadStateRepository(db, session.accountId);
const conversationAutomationClaimRepo = new ConversationAutomationClaimRepository(db, session.accountId);
const conversationOutcomesRepo = new ConversationOutcomesRepository(db, session.accountId);
const redditUsersRepo = new RedditUsersRepository(db);
@@ -247,7 +249,8 @@ async function buildRuntime(
const resources: RedditAccountRuntimeResources = {
browserProfileDir, browserClient, accountAuth, matrixClient, chatCache, redditApi,
postsRepo, messagesRepo, successfulMessagesRepo, deliveryOutboxRepo,
conversationStateRepo, conversationOutcomesRepo, redditUsersRepo, accountContentRepo,
conversationStateRepo, conversationReadStateRepo, conversationOutcomesRepo,
redditUsersRepo, accountContentRepo,
activityLogRepo,
monitorService, processorService, responseChecker, conversationAutomation, chatService, mediaService,
mediaSyncService, mediaExchange, rateLimiter,
+2
View File
@@ -11,6 +11,7 @@ import type {
ConversationOutcomesRepository,
ActivityLogRepository,
ConversationStateRepository,
ConversationReadStateRepository,
DeliveryOutboxRepository,
MessagesRepository,
PostsRepository,
@@ -65,6 +66,7 @@ export interface RedditAccountRuntimeResources {
successfulMessagesRepo: SuccessfulMessagesRepository;
deliveryOutboxRepo: DeliveryOutboxRepository;
conversationStateRepo: ConversationStateRepository;
conversationReadStateRepo: ConversationReadStateRepository;
conversationOutcomesRepo: ConversationOutcomesRepository;
redditUsersRepo: RedditUsersRepository;
accountContentRepo: AccountContentRepository;
+23
View File
@@ -86,6 +86,29 @@ export const API_OPERATIONS: readonly ApiOperation[] = [
operation('getConversationMediaOptions', 'GET', '/api/conversations/{id}/shareable-media', 'List every enabled exchange-library image not yet shared in this conversation', ['Conversations']),
operation('shareConversationMedia', 'POST', '/api/conversations/{id}/share-media', 'Send a selected exchange-library image', ['Conversations']),
operation('startConversation', 'POST', '/api/conversations/start', 'Start a new conversation', ['Conversations']),
operation('markConversationRead', 'POST', '/api/conversations/{id}/read', 'Advance the operator read watermark', ['Conversations'], {
requestSchema: z.strictObject({ throughTimestamp: z.number().int().nonnegative().optional() }),
responseSchema: z.strictObject({ success: z.literal(true) }),
}),
operation('markConversationUnread', 'POST', '/api/conversations/{id}/unread', 'Return a conversation to the unread queue', ['Conversations'], {
requestSchema: z.strictObject({ beforeTimestamp: z.number().int().nonnegative().optional() }),
responseSchema: z.strictObject({ success: z.literal(true) }),
}),
operation('setConversationStatus', 'POST', '/api/conversations/{id}/status', 'Snooze, resolve or reopen a conversation', ['Conversations'], {
requestSchema: z.strictObject({
status: z.enum(['open', 'snoozed', 'done']),
snoozeUntil: z.number().int().positive().nullable().optional(),
}),
responseSchema: z.strictObject({ success: z.literal(true) }),
}),
operation('bulkMarkConversationsRead', 'POST', '/api/conversations/bulk-read', 'Mark many conversations read in one request', ['Conversations'], {
requestSchema: z.strictObject({ conversationIds: z.array(z.string().min(1)).min(1).max(500) }),
responseSchema: z.strictObject({
success: z.literal(true),
updated: z.number().int().nonnegative(),
failed: z.array(z.string()),
}),
}),
operation('getMedia', 'GET', '/api/media', 'List received private media', ['Media']),
operation('getMediaSync', 'GET', '/api/media/sync', 'Get media sync state', ['Media']),
operation('triggerMediaSync', 'POST', '/api/media/sync', 'Trigger media sync', ['Media']),
+2
View File
@@ -5,6 +5,7 @@ import type {
MessagesRepository,
SuccessfulMessagesRepository,
ConversationStateRepository,
ConversationReadStateRepository,
} from '../database/repositories.js';
import type { ChatService } from '../services/chat.js';
import type { MediaService } from '../services/media.js';
@@ -55,6 +56,7 @@ export interface WebContext {
sessionImporter?: RedditSessionImporter;
accountAuth?: AccountAuthContext;
conversationState?: ConversationStateRepository;
conversationReadState?: ConversationReadStateRepository;
configStore?: ConfigStore;
redditAccounts?: RedditAccountsService;
accountRuntimes?: RedditAccountRuntimeManager;
+187 -7
View File
@@ -10,11 +10,73 @@ import {
remainingAutomationTurns,
} from '../../services/conversation-state.js';
import type { MediaLibraryAsset } from '../../services/media-library-types.js';
import type {
ConversationReadStateRecord,
ConversationReadStateRepository,
} from '../../database/repositories.js';
const ShareConversationMediaRequestSchema = z.strictObject({ assetId: z.string().uuid() });
const MarkReadRequestSchema = z.strictObject({
throughTimestamp: z.number().int().nonnegative().optional(),
});
const MarkUnreadRequestSchema = z.strictObject({
beforeTimestamp: z.number().int().nonnegative().optional(),
});
const SetStatusRequestSchema = z.strictObject({
status: z.enum(['open', 'snoozed', 'done']),
snoozeUntil: z.number().int().positive().nullable().optional(),
});
const BulkMarkReadRequestSchema = z.strictObject({
conversationIds: z.array(z.string().min(1)).min(1).max(500),
});
type OwnedActiveChat = ActiveChat & { owner?: { accountId: string; redditUsername: string } };
/** An owned conversation with operator triage state projected onto it. */
type TriagedActiveChat = OwnedActiveChat & {
hasUnreadInbound: boolean;
needsReply: boolean;
triageStatus: 'open' | 'snoozed' | 'done';
snoozedUntil: number | null;
lastReadMessageTs: number;
};
/**
* Projects operator read state onto a conversation summary.
*
* `hasUnreadInbound` is deliberately derived from the summary's last-message
* fields rather than the full message list — the list endpoint does not always
* have message history loaded, and a conversation whose newest message is ours
* is by definition one we have seen.
*/
function annotateTriage(
conversation: OwnedActiveChat,
readState: ConversationReadStateRecord | undefined,
): TriagedActiveChat {
const lastReadMessageTs = readState?.lastReadMessageTs ?? 0;
const status = readState?.status ?? 'open';
return {
...conversation,
hasUnreadInbound: !conversation.isFromUs && conversation.lastMessageTime > lastReadMessageTs,
needsReply: !conversation.isFromUs && status === 'open',
triageStatus: status,
snoozedUntil: readState?.snoozedUntil ?? null,
lastReadMessageTs,
};
}
/**
* Reopens conversations whose snooze has elapsed and returns the current read
* state for the account, keyed by conversation id.
*/
function currentReadState(
repo: ConversationReadStateRepository | undefined,
): Map<string, ConversationReadStateRecord> {
if (!repo) return new Map();
repo.expireSnoozes();
return new Map(repo.list().map((record) => [record.conversationId, record]));
}
export function createConversationsRouter(ctx: WebContext): Router {
const router = Router();
@@ -110,21 +172,32 @@ export function createConversationsRouter(ctx: WebContext): Router {
}
router.get('/conversations', async (req, res) => {
if (!ctx.services.chat) {
const chatService = ctx.services.chat;
if (!chatService) {
res.status(503).json({ error: 'Chat service not initialized' });
return;
}
try {
const runtimes = ctx.services.accountRuntimes?.list().filter((runtime) => runtime.state === 'running') ?? [];
const conversations: OwnedActiveChat[] = runtimes.length > 0
const conversations: TriagedActiveChat[] = runtimes.length > 0
? (await Promise.all(runtimes.map(async (snapshot) => {
const runtime = ctx.services.accountRuntimes!.get(snapshot.accountId)!;
return (await runtime.resources.chatService.getCachedConversations()).map((conversation) => withSellerMarker({
...conversation,
owner: { accountId: snapshot.accountId, redditUsername: snapshot.redditUsername },
}, runtime.resources.conversationStateRepo));
const readState = currentReadState(runtime.resources.conversationReadStateRepo);
return (await runtime.resources.chatService.getCachedConversations()).map((conversation) => annotateTriage(
withSellerMarker({
...conversation,
owner: { accountId: snapshot.accountId, redditUsername: snapshot.redditUsername },
}, runtime.resources.conversationStateRepo),
conversation.roomId ? readState.get(conversation.roomId) : undefined,
));
}))).flat()
: (await ctx.services.chat.getCachedConversations()).map((conversation) => withSellerMarker(conversation));
: await (async () => {
const readState = currentReadState(ctx.services.conversationReadState);
return (await chatService.getCachedConversations()).map((conversation) => annotateTriage(
withSellerMarker(conversation),
conversation.roomId ? readState.get(conversation.roomId) : undefined,
));
})();
if (req.query.includeImageMetadata !== 'true') {
res.json(conversations.map((conversation) => ({ ...conversation, owner: conversation.owner ?? currentOwner() })));
return;
@@ -269,8 +342,20 @@ export function createConversationsRouter(ctx: WebContext): Router {
return { ...message, isAi: true };
});
const readStateRepo = resolved.runtime?.resources.conversationReadStateRepo ?? ctx.services.conversationReadState;
readStateRepo?.expireSnoozes();
const triage = annotateTriage(
conversation,
conversation.roomId ? readStateRepo?.get(conversation.roomId) : undefined,
);
res.json({
...conversation,
hasUnreadInbound: triage.hasUnreadInbound,
needsReply: triage.needsReply,
triageStatus: triage.triageStatus,
snoozedUntil: triage.snoozedUntil,
lastReadMessageTs: triage.lastReadMessageTs,
sellerDetected: intelligence.state.sellerDetected,
...(annotatedMessages ? { messages: annotatedMessages } : {}),
owner: resolved.runtime
@@ -452,6 +537,101 @@ export function createConversationsRouter(ctx: WebContext): Router {
}
});
/**
* Resolves the read-state repository that owns a conversation, plus the room
* id to key it by. Conversations without a Matrix room cannot carry read
* state — there is no stable identifier to hang it off.
*/
async function resolveReadState(id: string): Promise<
| { repo: ConversationReadStateRepository; roomId: string; conversation: ActiveChat }
| { error: { status: number; message: string } }
> {
const resolved = await findConversation(decodeURIComponent(id));
if (!resolved) return { error: { status: 404, message: 'Conversation not found' } };
const repo = resolved.runtime?.resources.conversationReadStateRepo ?? ctx.services.conversationReadState;
if (!repo) return { error: { status: 503, message: 'Conversation read state is not initialized' } };
if (!resolved.conversation.roomId) {
return { error: { status: 409, message: 'This conversation has no Matrix room.' } };
}
return { repo, roomId: resolved.conversation.roomId, conversation: resolved.conversation };
}
router.post('/conversations/:id/read', async (req, res) => {
const parsed = MarkReadRequestSchema.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(400).json({ error: 'throughTimestamp must be a non-negative integer' });
return;
}
const resolved = await resolveReadState(req.params.id);
if ('error' in resolved) {
res.status(resolved.error.status).json({ error: resolved.error.message });
return;
}
const through = parsed.data.throughTimestamp ?? resolved.conversation.lastMessageTime;
resolved.repo.markRead(resolved.roomId, through);
res.json({ success: true });
});
router.post('/conversations/:id/unread', async (req, res) => {
const parsed = MarkUnreadRequestSchema.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(400).json({ error: 'beforeTimestamp must be a non-negative integer' });
return;
}
const resolved = await resolveReadState(req.params.id);
if ('error' in resolved) {
res.status(resolved.error.status).json({ error: resolved.error.message });
return;
}
const before = parsed.data.beforeTimestamp ?? resolved.conversation.lastMessageTime;
resolved.repo.markUnread(resolved.roomId, before);
res.json({ success: true });
});
router.post('/conversations/:id/status', async (req, res) => {
const parsed = SetStatusRequestSchema.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(400).json({ error: 'status must be one of open, snoozed, done' });
return;
}
if (parsed.data.status === 'snoozed' && !parsed.data.snoozeUntil) {
res.status(400).json({ error: 'snoozeUntil is required when snoozing a conversation' });
return;
}
const resolved = await resolveReadState(req.params.id);
if ('error' in resolved) {
res.status(resolved.error.status).json({ error: resolved.error.message });
return;
}
resolved.repo.setStatus(resolved.roomId, parsed.data.status, parsed.data.snoozeUntil ?? null);
res.json({ success: true });
});
/**
* Marks many conversations read in one request so draining a filtered backlog
* is a single action. Individual failures are reported rather than aborting
* the whole batch — a stopped account should not block the rest.
*/
router.post('/conversations/bulk-read', async (req, res) => {
const parsed = BulkMarkReadRequestSchema.safeParse(req.body ?? {});
if (!parsed.success) {
res.status(400).json({ error: 'conversationIds must be a non-empty array of at most 500 ids' });
return;
}
const failed: string[] = [];
let updated = 0;
for (const id of parsed.data.conversationIds) {
const resolved = await resolveReadState(id);
if ('error' in resolved) {
failed.push(id);
continue;
}
resolved.repo.markRead(resolved.roomId, resolved.conversation.lastMessageTime);
updated += 1;
}
res.json({ success: true, updated, failed });
});
router.get('/conversations/:id/shareable-media', async (req, res) => {
try {
const resolved = await findConversation(decodeURIComponent(req.params.id));
+5
View File
@@ -9,6 +9,7 @@ import type {
MessagesRepository,
SuccessfulMessagesRepository,
ConversationStateRepository,
ConversationReadStateRepository,
ActivityLogRepository,
} from '../database/repositories.js';
import type { ChatService } from '../services/chat.js';
@@ -247,6 +248,10 @@ export class WebServer {
this.services.conversationState = conversationState;
}
setConversationReadStateRepository(conversationReadState: ConversationReadStateRepository): void {
this.services.conversationReadState = conversationReadState;
}
setCandidateQueueRepository(candidateQueue: CandidateQueueRepository): void {
this.services.candidateQueue = candidateQueue;
}
+110
View File
@@ -0,0 +1,110 @@
import Database from 'better-sqlite3';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { runMigrations } from '../src/database/client.js';
import { ConversationReadStateRepository } from '../src/database/repositories.js';
describe('ConversationReadStateRepository', () => {
let db: Database.Database;
let repo: ConversationReadStateRepository;
let other: ConversationReadStateRepository;
beforeEach(() => {
db = new Database(':memory:');
runMigrations(db);
repo = new ConversationReadStateRepository(db, 'account-one');
other = new ConversationReadStateRepository(db, 'account-two');
});
afterEach(() => db.close());
it('returns nothing for a conversation that has never been read', () => {
expect(repo.get('room-1')).toBeUndefined();
expect(repo.list()).toEqual([]);
});
it('records a read watermark and defaults to an open status', () => {
repo.markRead('room-1', 500, 1_000);
expect(repo.get('room-1')).toEqual({
conversationId: 'room-1',
lastReadMessageTs: 500,
lastReadAt: 1_000,
status: 'open',
snoozedUntil: null,
});
});
it('never moves the watermark backwards, so a stale write cannot resurrect an unread badge', () => {
repo.markRead('room-1', 900, 1_000);
repo.markRead('room-1', 400, 2_000);
const record = repo.get('room-1');
expect(record?.lastReadMessageTs).toBe(900);
// The read *time* still advances even when the watermark does not.
expect(record?.lastReadAt).toBe(2_000);
});
it('rewinds the watermark just before the named message when marking unread', () => {
repo.markRead('room-1', 900, 1_000);
repo.markUnread('room-1', 900, 2_000);
expect(repo.get('room-1')?.lastReadMessageTs).toBe(899);
});
it('reopens and clears any snooze when marking unread', () => {
repo.setStatus('room-1', 'snoozed', 5_000, 1_000);
repo.markUnread('room-1', 100, 2_000);
expect(repo.get('room-1')).toMatchObject({ status: 'open', snoozedUntil: null });
});
it('clamps a negative watermark to zero', () => {
repo.markUnread('room-1', 0, 1_000);
expect(repo.get('room-1')?.lastReadMessageTs).toBe(0);
});
it('stores a snooze deadline and drops it when the status changes', () => {
repo.setStatus('room-1', 'snoozed', 5_000, 1_000);
expect(repo.get('room-1')).toMatchObject({ status: 'snoozed', snoozedUntil: 5_000 });
repo.setStatus('room-1', 'done', null, 2_000);
expect(repo.get('room-1')).toMatchObject({ status: 'done', snoozedUntil: null });
});
it('ignores a snooze deadline for non-snoozed statuses', () => {
repo.setStatus('room-1', 'open', 5_000, 1_000);
expect(repo.get('room-1')?.snoozedUntil).toBeNull();
});
it('preserves the read watermark when only the status changes', () => {
repo.markRead('room-1', 900, 1_000);
repo.setStatus('room-1', 'done', null, 2_000);
expect(repo.get('room-1')).toMatchObject({ lastReadMessageTs: 900, status: 'done' });
});
it('reopens only snoozes whose deadline has passed', () => {
repo.setStatus('room-past', 'snoozed', 1_000, 1);
repo.setStatus('room-future', 'snoozed', 9_000, 1);
expect(repo.expireSnoozes(5_000)).toEqual(['room-past']);
expect(repo.get('room-past')).toMatchObject({ status: 'open', snoozedUntil: null });
expect(repo.get('room-future')).toMatchObject({ status: 'snoozed', snoozedUntil: 9_000 });
});
it('does not reopen an indefinite snooze', () => {
repo.setStatus('room-1', 'snoozed', null, 1);
expect(repo.expireSnoozes(9_999)).toEqual([]);
});
it('keeps read state isolated per account', () => {
repo.markRead('room-1', 500, 1_000);
expect(other.get('room-1')).toBeUndefined();
expect(other.list()).toEqual([]);
expect(repo.list()).toHaveLength(1);
});
});
+232
View File
@@ -0,0 +1,232 @@
import { describe, expect, it } from 'vitest';
import type { ActiveChat } from '../packages/api-contracts/src/index.js';
import {
DEFAULT_CONVERSATION_FILTERS,
accountOptions,
filterCounts,
isConversationFilter,
isConversationSort,
isUnread,
isWaitingOnThem,
matchesFilter,
matchesSearch,
needsReply,
partitionConversations,
triageStatus,
type ConversationFilters,
} from '../apps/dashboard/src/features/conversations/conversation-triage.js';
function chat(overrides: Partial<ActiveChat> & { username: string }): ActiveChat {
return {
roomName: overrides.username,
roomId: `room-${overrides.username}`,
lastMessageTime: 1_000,
lastMessageSnippet: `snippet for ${overrides.username}`,
isFromUs: false,
unread: false,
...overrides,
};
}
function filters(overrides: Partial<ConversationFilters> = {}): ConversationFilters {
return { ...DEFAULT_CONVERSATION_FILTERS, ...overrides };
}
describe('conversation triage', () => {
describe('derived state', () => {
it('prefers application read state over the Matrix unread flag', () => {
expect(isUnread(chat({ username: 'a', unread: true, hasUnreadInbound: false }))).toBe(false);
expect(isUnread(chat({ username: 'a', unread: false, hasUnreadInbound: true }))).toBe(true);
});
it('falls back to the Matrix flag when the server sends no read state', () => {
expect(isUnread(chat({ username: 'a', unread: true }))).toBe(true);
expect(isUnread(chat({ username: 'a', unread: false }))).toBe(false);
});
it('treats an inbound newest message on an open conversation as needing a reply', () => {
expect(needsReply(chat({ username: 'a', isFromUs: false }))).toBe(true);
expect(needsReply(chat({ username: 'a', isFromUs: true }))).toBe(false);
});
it('stops a resolved or snoozed conversation from needing a reply', () => {
expect(needsReply(chat({ username: 'a', triageStatus: 'done' }))).toBe(false);
expect(needsReply(chat({ username: 'a', triageStatus: 'snoozed' }))).toBe(false);
});
it('separates waiting-on-them from needing a reply', () => {
const waiting = chat({ username: 'a', isFromUs: true });
expect(isWaitingOnThem(waiting)).toBe(true);
expect(needsReply(waiting)).toBe(false);
});
it('defaults a conversation with no stored state to open', () => {
expect(triageStatus(chat({ username: 'a' }))).toBe('open');
});
});
describe('filters', () => {
const open = chat({ username: 'open', hasUnreadInbound: true });
const replied = chat({ username: 'replied', isFromUs: true });
const snoozed = chat({ username: 'snoozed', triageStatus: 'snoozed' });
const done = chat({ username: 'done', triageStatus: 'done' });
const withImage = chat({ username: 'image', hasReceivedImages: true });
it('excludes snoozed and resolved conversations from the default view', () => {
expect(matchesFilter(open, 'all')).toBe(true);
expect(matchesFilter(replied, 'all')).toBe(true);
expect(matchesFilter(snoozed, 'all')).toBe(false);
expect(matchesFilter(done, 'all')).toBe(false);
});
it('keeps snoozed and resolved conversations reachable through their own buckets', () => {
expect(matchesFilter(snoozed, 'snoozed')).toBe(true);
expect(matchesFilter(done, 'done')).toBe(true);
});
it('scopes unread and image buckets to open conversations', () => {
expect(matchesFilter(open, 'unread')).toBe(true);
expect(matchesFilter(chat({ username: 'x', hasUnreadInbound: true, triageStatus: 'done' }), 'unread')).toBe(false);
expect(matchesFilter(withImage, 'received-images')).toBe(true);
expect(matchesFilter(chat({ username: 'y', hasReceivedImages: true, triageStatus: 'done' }), 'received-images')).toBe(false);
});
it('matches search against username, snippet and loaded message text', () => {
const conversation = chat({
username: 'alice',
lastMessageSnippet: 'about the weather',
messages: [{ sender: 'alice', text: 'I live in Bristol', timestamp: 1, isSelf: false }],
});
expect(matchesSearch(conversation, 'ALI')).toBe(true);
expect(matchesSearch(conversation, 'weather')).toBe(true);
expect(matchesSearch(conversation, 'bristol')).toBe(true);
expect(matchesSearch(conversation, 'nothing')).toBe(false);
expect(matchesSearch(conversation, ' ')).toBe(true);
});
});
describe('partitionConversations', () => {
it('sorts newest first by default', () => {
const result = partitionConversations([
chat({ username: 'old', lastMessageTime: 100 }),
chat({ username: 'new', lastMessageTime: 300 }),
chat({ username: 'mid', lastMessageTime: 200 }),
], filters());
expect(result.visible.map((item) => item.username)).toEqual(['new', 'mid', 'old']);
});
it('puts the longest-waiting unanswered conversation first when sorting by oldest unanswered', () => {
const result = partitionConversations([
chat({ username: 'recent-inbound', lastMessageTime: 300 }),
chat({ username: 'replied-old', lastMessageTime: 50, isFromUs: true }),
chat({ username: 'stale-inbound', lastMessageTime: 100 }),
], filters({ sort: 'oldest-unanswered' }));
// Unanswered first, oldest of those leading; everything else newest-first.
expect(result.visible.map((item) => item.username))
.toEqual(['stale-inbound', 'recent-inbound', 'replied-old']);
});
it('reports how many rows the seller filter removed', () => {
const result = partitionConversations([
chat({ username: 'alice' }),
chat({ username: 'seller-one', sellerDetected: true }),
chat({ username: 'seller-two', sellerDetected: true }),
], filters());
expect(result.visible.map((item) => item.username)).toEqual(['alice']);
expect(result.hiddenSellerCount).toBe(2);
});
it('includes sellers without counting them as hidden once revealed', () => {
const result = partitionConversations([
chat({ username: 'seller', sellerDetected: true }),
], filters({ showSellers: true }));
expect(result.visible).toHaveLength(1);
expect(result.hiddenSellerCount).toBe(0);
});
it('narrows to a single owning account', () => {
const result = partitionConversations([
chat({ username: 'a', owner: { accountId: 'one', redditUsername: 'bot-one' } }),
chat({ username: 'b', owner: { accountId: 'two', redditUsername: 'bot-two' } }),
], filters({ account: 'two' }));
expect(result.visible.map((item) => item.username)).toEqual(['b']);
});
it('does not mutate the input array order', () => {
const input = [
chat({ username: 'old', lastMessageTime: 100 }),
chat({ username: 'new', lastMessageTime: 300 }),
];
partitionConversations(input, filters());
expect(input.map((item) => item.username)).toEqual(['old', 'new']);
});
});
describe('filterCounts', () => {
it('counts each bucket independently', () => {
const counts = filterCounts([
chat({ username: 'unread-inbound', hasUnreadInbound: true }),
chat({ username: 'read-inbound', hasUnreadInbound: false }),
chat({ username: 'replied', isFromUs: true }),
chat({ username: 'snoozed', triageStatus: 'snoozed' }),
chat({ username: 'done', triageStatus: 'done' }),
], filters());
expect(counts.all).toBe(3);
expect(counts.unread).toBe(1);
expect(counts['needs-reply']).toBe(2);
expect(counts['awaiting-reply']).toBe(1);
expect(counts.snoozed).toBe(1);
expect(counts.done).toBe(1);
});
it('respects the search term so a pill never promises rows the list will not show', () => {
const counts = filterCounts([
chat({ username: 'alice', hasUnreadInbound: true }),
chat({ username: 'bob', hasUnreadInbound: true }),
], filters({ search: 'alice' }));
expect(counts.unread).toBe(1);
expect(counts.all).toBe(1);
});
it('excludes hidden sellers from the counts', () => {
const counts = filterCounts([
chat({ username: 'alice', hasUnreadInbound: true }),
chat({ username: 'seller', hasUnreadInbound: true, sellerDetected: true }),
], filters());
expect(counts.unread).toBe(1);
});
});
describe('accountOptions', () => {
it('lists distinct owners sorted by username', () => {
expect(accountOptions([
chat({ username: 'a', owner: { accountId: 'two', redditUsername: 'zoe' } }),
chat({ username: 'b', owner: { accountId: 'one', redditUsername: 'adam' } }),
chat({ username: 'c', owner: { accountId: 'one', redditUsername: 'adam' } }),
chat({ username: 'd' }),
])).toEqual([
{ accountId: 'one', redditUsername: 'adam' },
{ accountId: 'two', redditUsername: 'zoe' },
]);
});
});
describe('query-parameter guards', () => {
it('accepts only known filter and sort values', () => {
expect(isConversationFilter('needs-reply')).toBe(true);
expect(isConversationFilter('bogus')).toBe(false);
expect(isConversationFilter(null)).toBe(false);
expect(isConversationSort('oldest-unanswered')).toBe(true);
expect(isConversationSort('bogus')).toBe(false);
});
});
});
@@ -0,0 +1,468 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { MemoryRouter } from 'react-router-dom';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { ConversationsPage } from '../../apps/dashboard/src/features/conversations/conversations-page.js';
import { resetComposerDrafts } from '../../apps/dashboard/src/features/conversations/composer-drafts.js';
const NOW = 1_786_000_000_000;
interface ChatOptions {
username: string;
roomId?: string;
lastMessageTime?: number;
isFromUs?: boolean;
hasUnreadInbound?: boolean;
needsReply?: boolean;
triageStatus?: 'open' | 'snoozed' | 'done';
lastReadMessageTs?: number;
sellerDetected?: boolean;
}
function chatFixture(options: ChatOptions) {
const roomId = options.roomId ?? `room-${options.username}`;
const isFromUs = options.isFromUs ?? false;
const triageStatus = options.triageStatus ?? 'open';
return {
username: options.username,
roomName: options.username,
roomId,
url: `https://chat.reddit.com/room/${roomId}`,
lastMessageTime: options.lastMessageTime ?? NOW,
lastMessageSnippet: `Latest from ${options.username}`,
isFromUs,
unread: false,
hasUnreadInbound: options.hasUnreadInbound ?? (!isFromUs && triageStatus === 'open'),
needsReply: options.needsReply ?? (!isFromUs && triageStatus === 'open'),
triageStatus,
snoozedUntil: null,
lastReadMessageTs: options.lastReadMessageTs ?? 0,
...(options.sellerDetected === undefined ? {} : { sellerDetected: options.sellerDetected }),
};
}
function detailFixture(chat: ReturnType<typeof chatFixture>, messages?: unknown[]) {
return {
...chat,
messages: messages ?? [
{ eventId: '$one', sender: 'me', text: 'Hello there', timestamp: NOW - 200_000, isSelf: true },
{ eventId: '$two', sender: chat.username, text: 'Replying now', timestamp: NOW, isSelf: false },
],
triggeringPost: null,
automation: { turnsUsed: 0, remainingTurns: 3, status: 'active', handoffReason: null },
syncInProgress: null,
};
}
const alice = chatFixture({ username: 'alice' });
const server = setupServer(
http.get('/api/accounts', () => HttpResponse.json([])),
http.get('/api/conversations/:id/shareable-media', () => HttpResponse.json({
requestKind: null, assets: [], reason: 'Nothing to share.', warning: null,
})),
);
function renderConversations() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<ConversationsPage />
</QueryClientProvider>
</MemoryRouter>,
);
return queryClient;
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => {
cleanup();
server.resetHandlers();
resetComposerDrafts();
vi.useRealTimers();
});
afterAll(() => server.close());
describe('application-level read state', () => {
it('advances the read watermark after the conversation has been open a moment', async () => {
const readCalls: Array<{ id: string; body: unknown }> = [];
server.use(
http.get('/api/conversations', () => HttpResponse.json([alice])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(alice))),
http.post('/api/conversations/:id/read', async ({ params, request }) => {
readCalls.push({ id: String(params.id), body: await request.json() });
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await screen.findByRole('heading', { name: 'u/alice' });
await waitFor(() => expect(readCalls).toHaveLength(1));
expect(readCalls[0]).toEqual({ id: 'room-alice', body: { throughTimestamp: NOW } });
});
it('does not mark a conversation read that is already at the watermark', async () => {
const read = chatFixture({ username: 'alice', hasUnreadInbound: false, lastReadMessageTs: NOW });
let readCalls = 0;
server.use(
http.get('/api/conversations', () => HttpResponse.json([read])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(read))),
http.post('/api/conversations/:id/read', () => {
readCalls += 1;
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await screen.findByRole('heading', { name: 'u/alice' });
// Give the debounce more than enough time to fire if it were going to.
await new Promise((resolve) => setTimeout(resolve, 60));
expect(readCalls).toBe(0);
});
it('clears the unread chip once the watermark has advanced', async () => {
// Stateful so the assertion covers the whole round trip, not just the
// optimistic frame that the follow-up refetch would overwrite.
let watermark = 0;
server.use(
http.get('/api/conversations', () => HttpResponse.json([
chatFixture({ username: 'alice', hasUnreadInbound: watermark < NOW, lastReadMessageTs: watermark }),
])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(
chatFixture({ username: 'alice', hasUnreadInbound: watermark < NOW, lastReadMessageTs: watermark }),
))),
http.post('/api/conversations/:id/read', async ({ request }) => {
const body = await request.json() as { throughTimestamp?: number };
watermark = Math.max(watermark, body.throughTimestamp ?? NOW);
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
renderConversations();
const row = await screen.findByRole('button', { name: /u\/alice/ });
expect(row.getAttribute('aria-label')).toContain('Unread');
await user.click(row);
// The debounce plus the follow-up refetch takes longer than waitFor's default.
await waitFor(() => expect(
screen.getByRole('button', { name: /u\/alice/ }).getAttribute('aria-label'),
).not.toContain('Unread'), { timeout: 4_000 });
});
it('renders a new-messages divider at the read boundary and none for a first read', async () => {
const partiallyRead = chatFixture({ username: 'alice', lastReadMessageTs: NOW - 100_000 });
server.use(
http.get('/api/conversations', () => HttpResponse.json([partiallyRead])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(partiallyRead))),
http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
expect(await screen.findByText('New messages')).toBeInTheDocument();
});
it('keeps the divider in place after auto-mark-read advances the watermark', async () => {
// Regression: the divider used to vanish a second after opening, because it
// was derived from the live watermark that auto-mark-read had just moved.
let watermark = NOW - 100_000;
const current = () => chatFixture({
username: 'alice', hasUnreadInbound: watermark < NOW, lastReadMessageTs: watermark,
});
server.use(
http.get('/api/conversations', () => HttpResponse.json([current()])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(current()))),
http.post('/api/conversations/:id/read', async ({ request }) => {
const body = await request.json() as { throughTimestamp?: number };
watermark = Math.max(watermark, body.throughTimestamp ?? NOW);
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
expect(await screen.findByText('New messages')).toBeInTheDocument();
// Wait for the read to land and the panel to re-render from fresh data.
await waitFor(() => expect(
screen.getByRole('button', { name: /u\/alice/ }).getAttribute('aria-label'),
).not.toContain('Unread'), { timeout: 4_000 });
expect(screen.getByText('New messages')).toBeInTheDocument();
});
it('omits the divider when the conversation has never been read', async () => {
server.use(
http.get('/api/conversations', () => HttpResponse.json([alice])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(alice))),
http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await screen.findByText('Replying now');
expect(screen.queryByText('New messages')).not.toBeInTheDocument();
});
it('groups history under day dividers instead of stamping every message with a date', async () => {
const twoDaysAgo = NOW - (2 * 86_400_000);
server.use(
http.get('/api/conversations', () => HttpResponse.json([alice])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(alice, [
{ eventId: '$old', sender: 'me', text: 'Older message', timestamp: twoDaysAgo, isSelf: true },
{ eventId: '$new', sender: 'alice', text: 'Newer message', timestamp: Date.now(), isSelf: false },
]))),
http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await screen.findByText('Newer message');
expect(screen.getByText('Today')).toBeInTheDocument();
// Two distinct days produce two dividers.
expect(document.querySelectorAll('.conversation-day-divider')).toHaveLength(2);
});
it('returns a conversation to the queue from the detail header', async () => {
let watermark = NOW;
let unreadBody: unknown = null;
const current = () => chatFixture({
username: 'alice', hasUnreadInbound: watermark < NOW, lastReadMessageTs: watermark,
});
server.use(
http.get('/api/conversations', () => HttpResponse.json([current()])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(current()))),
http.post('/api/conversations/:id/unread', async ({ request }) => {
unreadBody = await request.json();
watermark = 0;
return HttpResponse.json({ success: true });
}),
// Re-opening the panel re-reads it; that must not fight the manual unread.
http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await user.click(await screen.findByRole('button', { name: 'Mark unread' }));
await waitFor(() => expect(unreadBody).toEqual({}));
await waitFor(() => expect(
screen.getByRole('button', { name: /u\/alice/ }).getAttribute('aria-label'),
).toContain('Unread'));
});
it('resolves a conversation and closes the detail panel', async () => {
let statusBody: unknown = null;
server.use(
http.get('/api/conversations', () => HttpResponse.json([alice])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(alice))),
http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })),
http.post('/api/conversations/:id/status', async ({ request }) => {
statusBody = await request.json();
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await user.click(await screen.findByRole('button', { name: 'Mark done' }));
await waitFor(() => expect(statusBody).toEqual({ status: 'done' }));
expect(await screen.findByText('Select a conversation')).toBeInTheDocument();
});
it('snoozes for a chosen duration', async () => {
let statusBody: { status: string; snoozeUntil: number } | null = null;
server.use(
http.get('/api/conversations', () => HttpResponse.json([alice])),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(alice))),
http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })),
http.post('/api/conversations/:id/status', async ({ request }) => {
statusBody = await request.json() as { status: string; snoozeUntil: number };
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await user.click(await screen.findByRole('button', { name: 'Snooze' }));
const menu = screen.getByRole('group', { name: 'Snooze options' });
await user.click(within(menu).getByRole('button', { name: 'Tomorrow' }));
await waitFor(() => expect(statusBody?.status).toBe('snoozed'));
expect(statusBody?.snoozeUntil).toBeGreaterThan(Date.now());
});
it('marks every selected conversation read in one request', async () => {
let bulkBody: { conversationIds: string[] } | null = null;
server.use(
http.get('/api/conversations', () => HttpResponse.json([
chatFixture({ username: 'alice' }),
chatFixture({ username: 'bob', lastMessageTime: NOW - 1_000 }),
])),
http.post('/api/conversations/bulk-read', async ({ request }) => {
bulkBody = await request.json() as { conversationIds: string[] };
return HttpResponse.json({ success: true, updated: 2, failed: [] });
}),
);
const user = userEvent.setup();
renderConversations();
await screen.findByRole('button', { name: /u\/alice/ });
await user.click(screen.getByRole('checkbox', { name: 'Select all visible conversations' }));
expect(screen.getByText('2 selected')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Mark read' }));
await waitFor(() => expect(bulkBody?.conversationIds).toEqual(['room-alice', 'room-bob']));
});
it('marks a single checked conversation read without touching the others', async () => {
let bulkBody: { conversationIds: string[] } | null = null;
server.use(
http.get('/api/conversations', () => HttpResponse.json([
chatFixture({ username: 'alice' }),
chatFixture({ username: 'bob', lastMessageTime: NOW - 1_000 }),
])),
http.post('/api/conversations/bulk-read', async ({ request }) => {
bulkBody = await request.json() as { conversationIds: string[] };
return HttpResponse.json({ success: true, updated: 1, failed: [] });
}),
);
const user = userEvent.setup();
renderConversations();
await screen.findByRole('button', { name: /u\/alice/ });
await user.click(screen.getByRole('checkbox', { name: 'Select u/bob' }));
await user.click(screen.getByRole('button', { name: 'Mark read' }));
await waitFor(() => expect(bulkBody?.conversationIds).toEqual(['room-bob']));
});
it('drives the list from the keyboard', async () => {
let readId: string | null = null;
let statusBody: unknown = null;
server.use(
http.get('/api/conversations', () => HttpResponse.json([
chatFixture({ username: 'alice' }),
chatFixture({ username: 'bob', lastMessageTime: NOW - 1_000 }),
])),
http.post('/api/conversations/:id/read', ({ params }) => {
readId = String(params.id);
return HttpResponse.json({ success: true });
}),
http.post('/api/conversations/:id/status', async ({ request }) => {
statusBody = await request.json();
return HttpResponse.json({ success: true });
}),
);
const user = userEvent.setup();
renderConversations();
await screen.findByRole('button', { name: /u\/alice/ });
// j moves to the first row, then to the second.
await user.keyboard('j');
expect(screen.getByRole('button', { name: /u\/alice/ })).toHaveFocus();
await user.keyboard('j');
expect(screen.getByRole('button', { name: /u\/bob/ })).toHaveFocus();
await user.keyboard('k');
expect(screen.getByRole('button', { name: /u\/alice/ })).toHaveFocus();
// u marks the focused row read; e resolves it.
await user.keyboard('u');
await waitFor(() => expect(readId).toBe('room-alice'));
await user.keyboard('e');
await waitFor(() => expect(statusBody).toEqual({ status: 'done' }));
});
it('focuses search with / and ignores shortcuts while typing', async () => {
server.use(http.get('/api/conversations', () => HttpResponse.json([alice])));
const user = userEvent.setup();
renderConversations();
await screen.findByRole('button', { name: /u\/alice/ });
await user.keyboard('/');
const search = screen.getByRole('searchbox', { name: 'Search conversations' });
expect(search).toHaveFocus();
// 'j' and 'e' are literal characters here, not shortcuts.
await user.keyboard('je');
expect(search).toHaveValue('je');
expect(search).toHaveFocus();
});
it('holds the row order while a conversation is open and offers an explicit refresh', async () => {
let listRequests = 0;
server.use(
http.get('/api/conversations', () => {
listRequests += 1;
return HttpResponse.json(listRequests === 1
? [chatFixture({ username: 'alice' })]
: [chatFixture({ username: 'carol', lastMessageTime: NOW + 5_000 }), chatFixture({ username: 'alice' })]);
}),
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture(alice))),
http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })),
);
const user = userEvent.setup();
const queryClient = renderConversations();
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
await screen.findByRole('heading', { name: 'u/alice' });
await queryClient.refetchQueries({ queryKey: ['conversations'] });
// carol arrived but must not push alice down mid-triage.
await waitFor(() => expect(screen.getByRole('button', { name: /1 new conversation/ })).toBeInTheDocument());
expect(screen.queryByRole('button', { name: /u\/carol/ })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /1 new conversation/ }));
expect(await screen.findByRole('button', { name: /u\/carol/ })).toBeInTheDocument();
});
it('explains rows hidden by the seller filter instead of silently dropping them', async () => {
server.use(
http.get('/api/conversations', () => HttpResponse.json([
chatFixture({ username: 'alice' }),
chatFixture({ username: 'seller', sellerDetected: true }),
])),
);
renderConversations();
await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByText('1 hidden by the seller filter.')).toBeInTheDocument();
});
it('flags an unanswered conversation that has gone stale', async () => {
server.use(
http.get('/api/conversations', () => HttpResponse.json([
chatFixture({ username: 'stale', lastMessageTime: Date.now() - (3 * 86_400_000) }),
])),
);
renderConversations();
const row = await screen.findByRole('button', { name: /u\/stale/ });
expect(row.getAttribute('aria-label')).toContain('Overdue');
expect(row.querySelector('time')).toHaveClass('is-critical');
});
});
+15 -5
View File
@@ -70,6 +70,12 @@ const server = setupServer(
requestKind: null, assets: [], warning: null,
reason: 'No enabled, unused library images are available to share.',
})),
// Opening a conversation advances the read watermark.
http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })),
http.post('/api/conversations/:id/unread', () => HttpResponse.json({ success: true })),
http.post('/api/conversations/:id/status', () => HttpResponse.json({ success: true })),
http.post('/api/conversations/bulk-read', () =>
HttpResponse.json({ success: true, updated: 1, failed: [] })),
);
function installConversationList() {
@@ -139,7 +145,7 @@ describe('ConversationsPage', () => {
expect(screen.getByRole('button', { name: /u\/bob/ })).toBeInTheDocument();
await user.clear(screen.getByRole('searchbox', { name: 'Search conversations' }));
await user.selectOptions(screen.getByRole('combobox', { name: 'Filter conversations' }), 'unread');
await user.click(screen.getByRole('button', { name: /^Unread/ }));
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
@@ -249,7 +255,7 @@ describe('ConversationsPage', () => {
const aliceItem = await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByText('Image received')).toBeInTheDocument();
await user.selectOptions(screen.getByRole('combobox', { name: 'Filter conversations' }), 'received-images');
await user.click(screen.getByRole('button', { name: /^Images/ }));
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
@@ -459,13 +465,17 @@ describe('ConversationsPage', () => {
expect(screen.queryByRole('button', { name: 'Start conversation' })).not.toBeInTheDocument();
});
it('shows the unread count and marks unread rows with state and time in the label', async () => {
it('counts each triage bucket on its filter pill and marks unread rows in the label', async () => {
installConversationList();
renderConversations();
const aliceButton = await screen.findByRole('button', { name: /u\/alice/ });
expect(screen.getByText('1 unread')).toBeInTheDocument();
expect(aliceButton.getAttribute('aria-label')).toMatch(/^u\/alice, Unread, .+ ago/);
// alice is inbound and unread; bob is a conversation we replied to last.
expect(screen.getByRole('button', { name: 'Unread, 1' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Needs reply, 1' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Waiting on them, 1' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'All, 2' })).toHaveAttribute('aria-pressed', 'true');
expect(aliceButton.getAttribute('aria-label')).toMatch(/^u\/alice, Unread, Needs reply, .+ago/);
expect(aliceButton.closest('li')).toHaveClass('is-unread');
expect(screen.getByRole('button', { name: /u\/bob/ }).closest('li')).not.toHaveClass('is-unread');
});
+9 -2
View File
@@ -32,14 +32,21 @@ describe('route-friendly filters', () => {
it('hydrates and writes conversation search and status filters', async () => {
server.use(http.get('/api/conversations', () => HttpResponse.json([])));
const user = userEvent.setup();
renderRoute('/conversations?q=alice&filter=received-images&showSellers=true', <ConversationRoute />);
renderRoute(
'/conversations?q=alice&filter=received-images&showSellers=true&sort=oldest-unanswered',
<ConversationRoute />,
);
const search = await screen.findByRole('searchbox', { name: 'Search conversations' });
expect(search).toHaveValue('alice');
expect(screen.getByRole('combobox', { name: 'Filter conversations' })).toHaveValue('received-images');
expect(screen.getByRole('button', { name: /^Images/ })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('combobox', { name: 'Sort' })).toHaveValue('oldest-unanswered');
expect(screen.getByRole('checkbox', { name: 'Show sellers' })).toBeChecked();
await user.clear(search);
await user.type(search, 'bob');
expect(screen.getByLabelText('Current URL')).toHaveTextContent('q=bob');
await user.click(screen.getByRole('button', { name: /^Needs reply/ }));
expect(screen.getByLabelText('Current URL')).toHaveTextContent('filter=needs-reply');
});
it('hydrates and writes media filters', async () => {
+8 -5
View File
@@ -107,12 +107,13 @@ describe('Database Client', () => {
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
.all() as { name: string }[];
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
expect(tables.length).toBe(20);
expect(tables.length).toBe(21);
expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([
'candidate_queue',
'runtime_state',
'media_library_assets',
'media_library_sends',
'conversation_read_state',
]));
});
@@ -156,13 +157,15 @@ describe('Database Client', () => {
) VALUES (?, 'old-post', 'Sent_Contact', 'subject', 'message', 11)
`).run('__legacy_unassigned__');
const latestVersion = db.pragma('user_version', { simple: true }) as number;
db.exec(`
DELETE FROM recipient_suppressions;
DROP TABLE conversation_automation_claims;
DROP TABLE ai_usage;
`);
db.pragma(`user_version = ${latestVersion - 7}`);
// Rewind to just before the suppression backfill migration. This must be an
// absolute version, not an offset from the latest — an offset silently
// re-targets a different migration every time a new one is appended.
db.pragma('user_version = 11');
runMigrations(db);
expect(db.prepare('SELECT recipient, reason FROM recipient_suppressions ORDER BY recipient COLLATE NOCASE').all())
@@ -174,7 +177,6 @@ describe('Database Client', () => {
it('preserves pending reply claims when adding auditable superseded status', () => {
runMigrations(db);
const latestVersion = db.pragma('user_version', { simple: true }) as number;
db.exec(`
DROP TABLE conversation_automation_claims;
CREATE TABLE conversation_automation_claims (
@@ -199,7 +201,8 @@ describe('Database Client', () => {
'durable reply', 5, 11, 1
);
`);
db.pragma(`user_version = ${latestVersion - 2}`);
// Absolute version: rewind to just before the superseded-status rebuild.
db.pragma('user_version = 16');
runMigrations(db);
+234 -3
View File
@@ -7,6 +7,19 @@ import { buildConversationState, conversationMessageFingerprint } from '../../sr
const servers: Array<ReturnType<express.Express['listen']>> = [];
/**
* Triage fields the route projects onto every conversation when no operator
* read state has been recorded yet: an inbound newest message is unread and
* needs a reply, and the conversation is still open.
*/
const UNREAD_INBOUND_TRIAGE = {
hasUnreadInbound: true,
needsReply: true,
triageStatus: 'open',
snoozedUntil: null,
lastReadMessageTs: 0,
} as const;
async function request(
app: express.Express,
path: string,
@@ -289,9 +302,11 @@ describe('conversations route', () => {
const response = await request(app, '/api/conversations?includeImageMetadata=true');
expect(legacyResponse.status).toBe(200);
expect(await legacyResponse.json()).toEqual([conversation]);
expect(await legacyResponse.json()).toEqual([{ ...conversation, ...UNREAD_INBOUND_TRIAGE }]);
expect(response.status).toBe(200);
expect(await response.json()).toEqual([{ ...conversation, hasReceivedImages: true }]);
expect(await response.json()).toEqual([
{ ...conversation, ...UNREAD_INBOUND_TRIAGE, hasReceivedImages: true },
]);
});
it('marks conversations with a durable seller detection', async () => {
@@ -313,7 +328,9 @@ describe('conversations route', () => {
const response = await request(app, '/api/conversations');
expect(response.status).toBe(200);
expect(await response.json()).toEqual([{ ...conversation, sellerDetected: true }]);
expect(await response.json()).toEqual([
{ ...conversation, ...UNREAD_INBOUND_TRIAGE, sellerDetected: true },
]);
});
it('serves conversation details without starting a delta sync', async () => {
@@ -458,4 +475,218 @@ describe('conversations route', () => {
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ success: true });
});
describe('operator read state', () => {
const conversation = {
username: 'alice', roomName: 'alice', roomId: 'room-alice',
lastMessageTime: 500, lastMessageSnippet: 'Hello', isFromUs: false, unread: false,
messages: [{ sender: 'alice', text: 'Hello', timestamp: 500, isSelf: false }],
};
function contextWithReadState(readState: Record<string, unknown>) {
return {
logger: { error: vi.fn(), warn: vi.fn() },
repos: { messages: { getByChatRoomId: vi.fn(), getLatestByRecipient: vi.fn() } },
services: {
chat: {
getCachedConversations: vi.fn().mockResolvedValue([conversation]),
getSyncStatus: vi.fn(() => ({ syncing: false, progress: null, syncingKind: null })),
loadConversationDetails: vi.fn(),
saveCacheExternal: vi.fn(),
},
conversationReadState: readState,
},
} as unknown as WebContext;
}
function readStateStub(record?: Record<string, unknown>) {
return {
get: vi.fn(() => record),
list: vi.fn(() => (record ? [record] : [])),
expireSnoozes: vi.fn(() => []),
markRead: vi.fn(),
markUnread: vi.fn(),
setStatus: vi.fn(),
};
}
it('reports an inbound message newer than the watermark as unread and needing a reply', async () => {
const readState = readStateStub({
conversationId: 'room-alice', lastReadMessageTs: 100, lastReadAt: 100,
status: 'open', snoozedUntil: null,
});
const app = express().use('/api', createConversationsRouter(contextWithReadState(readState)));
const body = await (await request(app, '/api/conversations')).json();
expect(body[0]).toMatchObject({
hasUnreadInbound: true, needsReply: true, triageStatus: 'open', lastReadMessageTs: 100,
});
expect(readState.expireSnoozes).toHaveBeenCalled();
});
it('clears unread once the watermark reaches the newest message', async () => {
const readState = readStateStub({
conversationId: 'room-alice', lastReadMessageTs: 500, lastReadAt: 600,
status: 'open', snoozedUntil: null,
});
const app = express().use('/api', createConversationsRouter(contextWithReadState(readState)));
const body = await (await request(app, '/api/conversations')).json();
expect(body[0]).toMatchObject({ hasUnreadInbound: false, needsReply: true });
});
it('stops treating a done conversation as needing a reply', async () => {
const readState = readStateStub({
conversationId: 'room-alice', lastReadMessageTs: 500, lastReadAt: 600,
status: 'done', snoozedUntil: null,
});
const app = express().use('/api', createConversationsRouter(contextWithReadState(readState)));
const body = await (await request(app, '/api/conversations')).json();
expect(body[0]).toMatchObject({ needsReply: false, triageStatus: 'done' });
});
it('marks read through the newest message when no timestamp is supplied', async () => {
const readState = readStateStub();
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(contextWithReadState(readState)));
const response = await request(app, '/api/conversations/room-alice/read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(response.status).toBe(200);
expect(readState.markRead).toHaveBeenCalledWith('room-alice', 500);
});
it('marks read only through an explicitly supplied timestamp', async () => {
const readState = readStateStub();
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(contextWithReadState(readState)));
await request(app, '/api/conversations/room-alice/read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ throughTimestamp: 200 }),
});
expect(readState.markRead).toHaveBeenCalledWith('room-alice', 200);
});
it('marks a conversation unread again', async () => {
const readState = readStateStub();
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(contextWithReadState(readState)));
const response = await request(app, '/api/conversations/room-alice/unread', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(response.status).toBe(200);
expect(readState.markUnread).toHaveBeenCalledWith('room-alice', 500);
});
it('rejects a snooze with no deadline', async () => {
const readState = readStateStub();
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(contextWithReadState(readState)));
const response = await request(app, '/api/conversations/room-alice/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'snoozed' }),
});
expect(response.status).toBe(400);
expect(readState.setStatus).not.toHaveBeenCalled();
});
it('rejects an unrecognised triage status', async () => {
const readState = readStateStub();
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(contextWithReadState(readState)));
const response = await request(app, '/api/conversations/room-alice/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'archived' }),
});
expect(response.status).toBe(400);
expect(readState.setStatus).not.toHaveBeenCalled();
});
it('stores a snooze deadline', async () => {
const readState = readStateStub();
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(contextWithReadState(readState)));
const response = await request(app, '/api/conversations/room-alice/status', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'snoozed', snoozeUntil: 9_000 }),
});
expect(response.status).toBe(200);
expect(readState.setStatus).toHaveBeenCalledWith('room-alice', 'snoozed', 9_000);
});
it('reports unresolvable ids in a bulk mark-read rather than failing the batch', async () => {
const readState = readStateStub();
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(contextWithReadState(readState)));
const response = await request(app, '/api/conversations/bulk-read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversationIds: ['room-alice', 'room-missing'] }),
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ success: true, updated: 1, failed: ['room-missing'] });
expect(readState.markRead).toHaveBeenCalledTimes(1);
});
it('rejects an empty bulk mark-read', async () => {
const app = express()
.use(express.json())
.use('/api', createConversationsRouter(contextWithReadState(readStateStub())));
const response = await request(app, '/api/conversations/bulk-read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ conversationIds: [] }),
});
expect(response.status).toBe(400);
});
it('exposes the read watermark on the conversation detail so the divider can be placed', async () => {
const readState = readStateStub({
conversationId: 'room-alice', lastReadMessageTs: 100, lastReadAt: 100,
status: 'open', snoozedUntil: null,
});
const app = express().use('/api', createConversationsRouter(contextWithReadState(readState)));
const body = await (await request(app, '/api/conversations/room-alice')).json();
expect(body).toMatchObject({
lastReadMessageTs: 100, hasUnreadInbound: true, needsReply: true, triageStatus: 'open',
});
});
});
});