Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
725b5fc4e6 |
No files matched your search
@@ -11,9 +11,16 @@ import {
|
|||||||
StartConversationResponseSchema,
|
StartConversationResponseSchema,
|
||||||
SuccessResponseSchema,
|
SuccessResponseSchema,
|
||||||
SuggestedReplyResponseSchema,
|
SuggestedReplyResponseSchema,
|
||||||
|
BulkMarkConversationsReadRequestSchema,
|
||||||
|
BulkMarkConversationsReadResponseSchema,
|
||||||
|
MarkConversationReadRequestSchema,
|
||||||
|
MarkConversationUnreadRequestSchema,
|
||||||
|
SetConversationStatusRequestSchema,
|
||||||
type ActiveChat,
|
type ActiveChat,
|
||||||
|
type BulkMarkConversationsReadResponse,
|
||||||
type ConversationDetailResponse,
|
type ConversationDetailResponse,
|
||||||
type ConversationMediaShareOptions,
|
type ConversationMediaShareOptions,
|
||||||
|
type ConversationTriageStatus,
|
||||||
type ProcessedPost,
|
type ProcessedPost,
|
||||||
type SuccessResponse,
|
type SuccessResponse,
|
||||||
} from '../../../../../packages/api-contracts/src/index.js';
|
} from '../../../../../packages/api-contracts/src/index.js';
|
||||||
@@ -42,6 +49,18 @@ export interface ConversationApi {
|
|||||||
sendReply(id: string, message: string): Promise<SuccessResponse>;
|
sendReply(id: string, message: string): Promise<SuccessResponse>;
|
||||||
shareMedia(id: string, assetId: string): Promise<SuccessResponse>;
|
shareMedia(id: string, assetId: string): Promise<SuccessResponse>;
|
||||||
startConversation(accountId: string, username: string, message: string): Promise<ActiveChat>;
|
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 {
|
export function createConversationApi(fetchImplementation?: typeof fetch): ConversationApi {
|
||||||
@@ -121,6 +140,40 @@ export function createConversationApi(fetchImplementation?: typeof fetch): Conve
|
|||||||
);
|
);
|
||||||
return response.chat;
|
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 { 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 { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
|
||||||
import { OriginPostCard } from '../../components/origin-post.js';
|
import { OriginPostCard } from '../../components/origin-post.js';
|
||||||
import {
|
import {
|
||||||
useConversationDetailQuery,
|
useConversationDetailQuery,
|
||||||
useConversationMediaShareOptionsQuery,
|
useConversationMediaShareOptionsQuery,
|
||||||
|
useMarkConversationReadMutation,
|
||||||
|
useMarkConversationUnreadMutation,
|
||||||
useSendReplyMutation,
|
useSendReplyMutation,
|
||||||
|
useSetConversationStatusMutation,
|
||||||
useShareConversationMediaMutation,
|
useShareConversationMediaMutation,
|
||||||
useSuggestMediaAskMutation,
|
useSuggestMediaAskMutation,
|
||||||
useSuggestReplyMutation,
|
useSuggestReplyMutation,
|
||||||
@@ -13,14 +20,19 @@ import {
|
|||||||
import { SendConfirmationDialog } from './send-confirmation-dialog.js';
|
import { SendConfirmationDialog } from './send-confirmation-dialog.js';
|
||||||
import { MediaShareConfirmationDialog } from './media-share-confirmation-dialog.js';
|
import { MediaShareConfirmationDialog } from './media-share-confirmation-dialog.js';
|
||||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.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 { 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. */
|
/** Reddit chat rejects messages longer than roughly this many characters. */
|
||||||
export const REDDIT_CHAT_MAX_MESSAGE_LENGTH = 10_000;
|
export const REDDIT_CHAT_MAX_MESSAGE_LENGTH = 10_000;
|
||||||
const CHARACTER_WARNING_MARGIN = 500;
|
const CHARACTER_WARNING_MARGIN = 500;
|
||||||
const SUCCESS_NOTICE_DISMISS_MS = 5_000;
|
const SUCCESS_NOTICE_DISMISS_MS = 5_000;
|
||||||
const AT_BOTTOM_THRESHOLD_PX = 48;
|
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 {
|
interface ConversationDetailProps {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
@@ -40,12 +52,35 @@ function useAutoDismiss(isVisible: boolean, dismiss: () => void) {
|
|||||||
}, [isVisible, dismiss]);
|
}, [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 [revealedMedia, setRevealedMedia] = useState<Set<string>>(() => new Set());
|
||||||
const listRef = useRef<HTMLOListElement>(null);
|
const listRef = useRef<HTMLOListElement>(null);
|
||||||
|
const dividerRef = useRef<HTMLLIElement>(null);
|
||||||
const isAtBottomRef = useRef(true);
|
const isAtBottomRef = useRef(true);
|
||||||
const [isAtBottom, setIsAtBottom] = useState(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 scrollToBottom = useCallback(() => {
|
||||||
const list = listRef.current;
|
const list = listRef.current;
|
||||||
@@ -55,11 +90,24 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp
|
|||||||
setIsAtBottom(true);
|
setIsAtBottom(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Stick to the latest message on mount and when new messages arrive,
|
// Land on the first unread message rather than the bottom, so the operator
|
||||||
// unless the operator has scrolled up to read history.
|
// 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(() => {
|
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();
|
if (isAtBottomRef.current) scrollToBottom();
|
||||||
}, [messageCount, scrollToBottom]);
|
}, [messageCount, scrollToBottom, unreadIndex]);
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
const list = listRef.current;
|
const list = listRef.current;
|
||||||
@@ -69,58 +117,76 @@ function MessageHistory({ conversation }: { conversation: ConversationDetailResp
|
|||||||
setIsAtBottom(atBottom);
|
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-empty-detail">No messages have been loaded for this conversation.</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="conversation-messages-shell">
|
<div className="conversation-messages-shell">
|
||||||
<ol aria-label="Message history" className="conversation-messages" onScroll={handleScroll} ref={listRef}>
|
<ol aria-label="Message history" className="conversation-messages" onScroll={handleScroll} ref={listRef}>
|
||||||
{conversation.messages.map((message) => (
|
{messages.map((message, index) => {
|
||||||
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={message.eventId ?? `${message.timestamp}-${message.sender}-${message.text}`}>
|
const previous = index > 0 ? messages[index - 1] : null;
|
||||||
<div>
|
const startsNewDay = previous === null || !isSameDay(previous.timestamp, message.timestamp);
|
||||||
<strong>{message.isSelf
|
const key = message.eventId ?? `${message.timestamp}-${message.sender}-${message.text}`;
|
||||||
? `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);
|
|
||||||
|
|
||||||
return (
|
return [
|
||||||
<div className="conversation-message-image" key={mediaKey}>
|
startsNewDay ? (
|
||||||
<img
|
<li aria-hidden="true" className="conversation-day-divider" key={`day-${key}`}>
|
||||||
alt={`Image ${sender}`}
|
<span>{formatDayLabel(message.timestamp, now)}</span>
|
||||||
className={isRevealed ? undefined : 'is-blurred'}
|
</li>
|
||||||
loading="lazy"
|
) : null,
|
||||||
src={mediaPath(media.localPath)}
|
index === unreadIndex ? (
|
||||||
/>
|
<li className="conversation-unread-divider" key={`unread-${key}`} ref={dividerRef}>
|
||||||
<button
|
<span>New messages</span>
|
||||||
aria-pressed={isRevealed}
|
</li>
|
||||||
className="conversation-image-reveal"
|
) : null,
|
||||||
onClick={() => setRevealedMedia((current) => {
|
<li className={message.isSelf ? 'is-self' : 'is-recipient'} key={key}>
|
||||||
const next = new Set(current);
|
<div>
|
||||||
if (next.has(mediaKey)) next.delete(mediaKey);
|
<strong>{message.isSelf
|
||||||
else next.add(mediaKey);
|
? `You${conversation.owner ? ` (u/${conversation.owner.redditUsername})` : ''}`
|
||||||
return next;
|
: <RedditUsernameLink username={conversation.username} />}</strong>
|
||||||
})}
|
{message.isAi ? <span aria-label="Sent by AI" className="conversation-ai-badge">AI</span> : null}
|
||||||
type="button"
|
<time dateTime={new Date(message.timestamp).toISOString()} title={formatExactTime(message.timestamp)}>
|
||||||
>
|
{formatTimeOfDay(message.timestamp)}
|
||||||
{isRevealed ? 'Blur conversation image' : 'Reveal conversation image'}
|
</time>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<p>{message.text}</p>
|
||||||
</li>
|
{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>
|
</ol>
|
||||||
{!isAtBottom ? (
|
{!isAtBottom ? (
|
||||||
<button className="conversation-jump-latest" onClick={scrollToBottom} type="button">
|
<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'}
|
{mediaAskMutation.isPending ? 'Generating media ask…' : 'Suggest media ask'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
<label className="sr-only" htmlFor="conversation-reply">Reply to {redditUsernameLabel(conversation.username)}</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="conversation-reply"
|
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) {
|
export function ConversationDetail({ conversationId, onClose }: ConversationDetailProps) {
|
||||||
const detailQuery = useConversationDetailQuery(conversationId);
|
const detailQuery = useConversationDetailQuery(conversationId);
|
||||||
|
const [now] = useState(() => Date.now());
|
||||||
|
useAutoMarkRead(conversationId, detailQuery.data);
|
||||||
|
|
||||||
if (detailQuery.isPending) {
|
if (detailQuery.isPending) {
|
||||||
return <div aria-label="Loading conversation" className="conversation-detail-state" role="status">Loading conversation…</div>;
|
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 conversation = detailQuery.data;
|
||||||
|
const status = triageStatus(conversation);
|
||||||
return (
|
return (
|
||||||
<article className="conversation-detail">
|
<article className="conversation-detail">
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div className="conversation-detail-identity">
|
||||||
<span className="conversation-eyebrow">Conversation</span>
|
<span className="conversation-eyebrow">Conversation</span>
|
||||||
<h2><RedditUsernameLink username={conversation.username} /></h2>
|
<h2><RedditUsernameLink username={conversation.username} /></h2>
|
||||||
<AccountOwnerBadge owner={conversation.owner} />
|
<span className="conversation-detail-badges">
|
||||||
{conversation.sellerDetected ? <span className="conversation-seller-marker">Seller</span> : null}
|
<AccountOwnerBadge owner={conversation.owner} />
|
||||||
{conversation.unread ? <span className="conversation-badge">Unread</span> : null}
|
{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>
|
</div>
|
||||||
<button aria-label="Close conversation" onClick={onClose} type="button">Close</button>
|
|
||||||
</header>
|
</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 ? (
|
{conversation.syncInProgress ? (
|
||||||
<div className="conversation-sync-notice" role="status">
|
<div className="conversation-sync-notice" role="status">
|
||||||
A {conversation.syncInProgress.kind ?? 'chat'} sync is in progress. Messages may be stale.
|
A {conversation.syncInProgress.kind ?? 'chat'} sync is in progress. Messages may be stale.
|
||||||
@@ -420,7 +610,7 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
|
|||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<MessageHistory conversation={conversation} />
|
<MessageHistory conversation={conversation} now={now} />
|
||||||
<ConversationComposer conversation={conversation} conversationId={conversationId} />
|
<ConversationComposer conversation={conversation} conversationId={conversationId} />
|
||||||
</article>
|
</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 { conversationApi } from './conversation-api.js';
|
||||||
|
import { ownedConversationId } from './conversation-identity.js';
|
||||||
|
|
||||||
export const conversationQueryKeys = {
|
export const conversationQueryKeys = {
|
||||||
all: ['conversations'] as const,
|
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() {
|
export function useStartConversationMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
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() {
|
export function ConversationRoute() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { conversationId } = useParams<{ conversationId: string }>();
|
const { conversationId } = useParams<{ conversationId: string }>();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const filterValue = searchParams.get('filter');
|
const filterValue = searchParams.get('filter');
|
||||||
|
const sortValue = searchParams.get('sort');
|
||||||
const filters: ConversationFilters = {
|
const filters: ConversationFilters = {
|
||||||
search: searchParams.get('q') ?? '',
|
search: searchParams.get('q') ?? '',
|
||||||
showSellers: searchParams.get('showSellers') === 'true',
|
showSellers: searchParams.get('showSellers') === 'true',
|
||||||
filter:
|
filter: isConversationFilter(filterValue) ? filterValue : DEFAULT_CONVERSATION_FILTERS.filter,
|
||||||
filterValue === 'unread'
|
sort: isConversationSort(sortValue) ? sortValue : DEFAULT_CONVERSATION_FILTERS.sort,
|
||||||
|| filterValue === 'awaiting-reply'
|
account: searchParams.get('account') ?? DEFAULT_CONVERSATION_FILTERS.account,
|
||||||
|| filterValue === 'received-images'
|
|
||||||
? filterValue
|
|
||||||
: 'all',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function selectConversation(nextConversationId: string | null) {
|
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.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.filter !== 'all') params.set('filter', next.filter); else params.delete('filter');
|
||||||
if (next.showSellers) params.set('showSellers', 'true'); else params.delete('showSellers');
|
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 });
|
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 type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
|
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
|
||||||
import { ConversationDetail } from './conversation-detail.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 { StartConversationForm } from './start-conversation-form.js';
|
||||||
import { ProcessedUserLookup } from './processed-user-lookup.js';
|
import { ProcessedUserLookup } from './processed-user-lookup.js';
|
||||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||||
import './conversations.css';
|
import './conversations.css';
|
||||||
import { ownedConversationId } from './conversation-identity.js';
|
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)';
|
const SMALL_VIEWPORT_QUERY = '(max-width: 51.9999rem)';
|
||||||
|
|
||||||
export interface ConversationFilters {
|
|
||||||
search: string;
|
|
||||||
filter: ConversationFilter;
|
|
||||||
showSellers: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ConversationsPageProps {
|
export interface ConversationsPageProps {
|
||||||
selectedConversationId?: string | null;
|
selectedConversationId?: string | null;
|
||||||
onSelectedConversationChange?(conversationId: string | null): void;
|
onSelectedConversationChange?(conversationId: string | null): void;
|
||||||
@@ -27,59 +41,118 @@ export interface ConversationsPageProps {
|
|||||||
onFiltersChange?(filters: ConversationFilters): void;
|
onFiltersChange?(filters: ConversationFilters): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
|
interface ConversationRowProps {
|
||||||
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 }: {
|
|
||||||
conversation: ActiveChat;
|
conversation: ActiveChat;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
isChecked: boolean;
|
||||||
|
/** Only worth the row space when more than one account is in play. */
|
||||||
|
showOwner: boolean;
|
||||||
now: number;
|
now: number;
|
||||||
onSelect(): void;
|
onSelect(): void;
|
||||||
}) {
|
onToggleChecked(): void;
|
||||||
const state = conversation.unread ? 'Unread' : conversation.isFromUs ? 'Awaiting reply' : 'Up to date';
|
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 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'];
|
const itemClasses = ['conversation-list-item'];
|
||||||
if (isSelected) itemClasses.push('is-selected');
|
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 (
|
return (
|
||||||
<li className={itemClasses.join(' ')}>
|
<li className={itemClasses.join(' ')}>
|
||||||
|
<label className="conversation-list-check">
|
||||||
|
<input
|
||||||
|
aria-label={`Select ${redditUsernameLabel(conversation.username)}`}
|
||||||
|
checked={isChecked}
|
||||||
|
onChange={onToggleChecked}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<button
|
<button
|
||||||
aria-label={`${redditUsernameLabel(conversation.username)}, ${state}, ${relativeTime}${conversation.sellerDetected ? ', Seller' : ''}${conversation.hasReceivedImages ? ', Image received' : ''}`}
|
aria-current={isSelected || undefined}
|
||||||
aria-pressed={isSelected}
|
aria-label={descriptors.join(', ')}
|
||||||
className="conversation-list-target"
|
className="conversation-list-open"
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
|
ref={registerRef}
|
||||||
type="button"
|
type="button"
|
||||||
/>
|
>
|
||||||
<span className="conversation-list-heading">
|
<span className="conversation-list-heading">
|
||||||
<strong><RedditUsernameLink username={conversation.username} /></strong>
|
<strong>{redditUsernameLabel(conversation.username)}</strong>
|
||||||
<span className="conversation-list-meta">
|
<time
|
||||||
<small>{state}</small>
|
className={overdue === 'fresh' ? undefined : `is-${overdue}`}
|
||||||
<time dateTime={new Date(conversation.lastMessageTime).toISOString()} title={formatExactTime(conversation.lastMessageTime)}>
|
dateTime={new Date(conversation.lastMessageTime).toISOString()}
|
||||||
|
title={formatExactTime(conversation.lastMessageTime)}
|
||||||
|
>
|
||||||
{relativeTime}
|
{relativeTime}
|
||||||
</time>
|
</time>
|
||||||
</span>
|
</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>
|
</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>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConversationsPage({ selectedConversationId, onSelectedConversationChange, filters, onFiltersChange }: ConversationsPageProps) {
|
export function ConversationsPage({
|
||||||
|
selectedConversationId, onSelectedConversationChange, filters, onFiltersChange,
|
||||||
|
}: ConversationsPageProps) {
|
||||||
const conversationsQuery = useConversationsQuery();
|
const conversationsQuery = useConversationsQuery();
|
||||||
const [internalSelectedId, setInternalSelectedId] = useState<string | null>(null);
|
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 [isStartingConversation, setIsStartingConversation] = useState(false);
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const [checkedIds, setCheckedIds] = useState<Set<string>>(() => new Set());
|
||||||
const detailPanelRef = useRef<HTMLElement>(null);
|
const detailPanelRef = useRef<HTMLElement>(null);
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
|
const rowRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||||
const activeConversationId = selectedConversationId !== undefined ? selectedConversationId : internalSelectedId;
|
const activeConversationId = selectedConversationId !== undefined ? selectedConversationId : internalSelectedId;
|
||||||
const activeFilters = filters ?? localFilters;
|
const activeFilters = filters ?? localFilters;
|
||||||
|
const markRead = useMarkConversationReadMutation();
|
||||||
|
const markUnread = useMarkConversationUnreadMutation();
|
||||||
|
const setStatus = useSetConversationStatusMutation();
|
||||||
|
const bulkMarkRead = useBulkMarkConversationsReadMutation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = window.setInterval(() => setNow(Date.now()), 60_000);
|
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' });
|
detailPanelRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
|
||||||
}, [activeConversationId]);
|
}, [activeConversationId]);
|
||||||
|
|
||||||
function updateFilters(next: ConversationFilters) {
|
const updateFilters = useCallback((next: ConversationFilters) => {
|
||||||
if (filters === undefined) setLocalFilters(next);
|
if (filters === undefined) setLocalFilters(next);
|
||||||
onFiltersChange?.(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 visibleConversations = useMemo(() => {
|
||||||
const normalizedSearch = activeFilters.search.trim().toLowerCase();
|
if (!frozenOrder) return liveConversations;
|
||||||
return (conversationsQuery.data ?? [])
|
const rank = new Map(frozenOrder.map((id, index) => [id, index]));
|
||||||
.filter((conversation) => {
|
return liveConversations
|
||||||
const matchesSearch = normalizedSearch.length === 0
|
.filter((conversation) => rank.has(ownedConversationId(conversation)))
|
||||||
|| conversation.username.toLowerCase().includes(normalizedSearch)
|
.sort((left, right) =>
|
||||||
|| conversation.lastMessageSnippet.toLowerCase().includes(normalizedSearch);
|
(rank.get(ownedConversationId(left)) ?? 0) - (rank.get(ownedConversationId(right)) ?? 0));
|
||||||
return matchesSearch
|
}, [liveConversations, frozenOrder]);
|
||||||
&& matchesFilter(conversation, activeFilters.filter)
|
|
||||||
&& (activeFilters.showSellers || conversation.sellerDetected !== true);
|
|
||||||
})
|
|
||||||
.sort((left, right) => right.lastMessageTime - left.lastMessageTime);
|
|
||||||
}, [activeFilters, conversationsQuery.data]);
|
|
||||||
|
|
||||||
const unreadCount = useMemo(
|
const pendingCount = liveConversations.length - visibleConversations.length;
|
||||||
() => (conversationsQuery.data ?? []).filter((conversation) => conversation.unread).length,
|
|
||||||
[conversationsQuery.data],
|
const visibleIds = useMemo(
|
||||||
|
() => visibleConversations.map((conversation) => ownedConversationId(conversation)),
|
||||||
|
[visibleConversations],
|
||||||
);
|
);
|
||||||
|
|
||||||
function selectConversation(id: string | null) {
|
function selectConversation(id: string | null) {
|
||||||
@@ -124,6 +223,121 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
|||||||
onSelectedConversationChange?.(id);
|
onSelectedConversationChange?.(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function releaseFrozenOrder() {
|
||||||
|
setFrozenOrder(activeConversationId ? liveIds : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleChecked(id: string) {
|
||||||
|
setCheckedIds((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkedVisibleIds = visibleIds.filter((id) => checkedIds.has(id));
|
||||||
|
const allVisibleChecked = visibleIds.length > 0 && checkedVisibleIds.length === visibleIds.length;
|
||||||
|
|
||||||
|
function toggleAllVisible() {
|
||||||
|
setCheckedIds(allVisibleChecked ? new Set() : new Set(visibleIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveFocus(offset: number) {
|
||||||
|
if (visibleIds.length === 0) return;
|
||||||
|
const focused = document.activeElement;
|
||||||
|
let index = visibleIds.findIndex((id) => rowRefs.current.get(id) === focused);
|
||||||
|
if (index < 0) index = visibleIds.findIndex((id) => id === activeConversationId);
|
||||||
|
if (index < 0) {
|
||||||
|
// Entering the list for the first time: land on the near end rather than
|
||||||
|
// stepping past it.
|
||||||
|
rowRefs.current.get(visibleIds[offset > 0 ? 0 : visibleIds.length - 1])?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextIndex = Math.min(visibleIds.length - 1, Math.max(0, index + offset));
|
||||||
|
rowRefs.current.get(visibleIds[nextIndex])?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusedConversationId(): string | null {
|
||||||
|
const focused = document.activeElement;
|
||||||
|
const focusedId = visibleIds.find((id) => rowRefs.current.get(id) === focused);
|
||||||
|
return focusedId ?? activeConversationId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTypingTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false;
|
||||||
|
return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbox keyboard shortcuts. Suppressed whenever focus is inside a text field
|
||||||
|
* so typing a reply or a search term never triggers an action.
|
||||||
|
*/
|
||||||
|
function handleShortcut(event: KeyboardEvent) {
|
||||||
|
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
||||||
|
// A modal owns the keyboard while it is open — including Escape, which must
|
||||||
|
// dismiss the dialog rather than the conversation behind it.
|
||||||
|
if (event.defaultPrevented || document.querySelector('dialog[open]')) return;
|
||||||
|
const typing = isTypingTarget(event.target);
|
||||||
|
if (event.key === '/' && !typing) {
|
||||||
|
event.preventDefault();
|
||||||
|
searchRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
if (typing) (event.target as HTMLElement).blur();
|
||||||
|
else if (activeConversationId) selectConversation(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typing) return;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'j':
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault();
|
||||||
|
moveFocus(1);
|
||||||
|
break;
|
||||||
|
case 'k':
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault();
|
||||||
|
moveFocus(-1);
|
||||||
|
break;
|
||||||
|
case 'u': {
|
||||||
|
const id = focusedConversationId();
|
||||||
|
const conversation = allConversations.find((item) => ownedConversationId(item) === id);
|
||||||
|
if (!id || !conversation) return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (isUnread(conversation)) markRead.mutate({ id });
|
||||||
|
else markUnread.mutate({ id });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'e': {
|
||||||
|
const id = focusedConversationId();
|
||||||
|
if (!id) return;
|
||||||
|
event.preventDefault();
|
||||||
|
setStatus.mutate({ id, status: 'done' });
|
||||||
|
if (id === activeConversationId) selectConversation(null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The handler closes over per-render state, so it is kept in a ref and the
|
||||||
|
// listener is registered once rather than re-bound on every render.
|
||||||
|
const shortcutRef = useRef(handleShortcut);
|
||||||
|
shortcutRef.current = handleShortcut;
|
||||||
|
useEffect(() => {
|
||||||
|
const listener = (event: KeyboardEvent) => shortcutRef.current(event);
|
||||||
|
window.addEventListener('keydown', listener);
|
||||||
|
return () => window.removeEventListener('keydown', listener);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const lastSyncedLabel = conversationsQuery.dataUpdatedAt
|
||||||
|
? formatShortRelativeTime(conversationsQuery.dataUpdatedAt, now)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="conversations-page">
|
<div className="conversations-page">
|
||||||
<header className="conversations-header">
|
<header className="conversations-header">
|
||||||
@@ -142,7 +356,9 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
|||||||
>
|
>
|
||||||
{conversationsQuery.isFetching ? 'Refreshing…' : 'Refresh'}
|
{conversationsQuery.isFetching ? 'Refreshing…' : 'Refresh'}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
<button className="conversation-primary" onClick={() => setIsStartingConversation((current) => !current)} type="button">
|
<button className="conversation-primary" onClick={() => setIsStartingConversation((current) => !current)} type="button">
|
||||||
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
|
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
|
||||||
@@ -165,30 +381,107 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
|||||||
<input
|
<input
|
||||||
id="conversation-search"
|
id="conversation-search"
|
||||||
onChange={(event) => updateFilters({ ...activeFilters, search: event.target.value })}
|
onChange={(event) => updateFilters({ ...activeFilters, search: event.target.value })}
|
||||||
placeholder="Search conversations"
|
placeholder="Search conversations (/)"
|
||||||
|
ref={searchRef}
|
||||||
type="search"
|
type="search"
|
||||||
value={activeFilters.search}
|
value={activeFilters.search}
|
||||||
/>
|
/>
|
||||||
<label className="sr-only" htmlFor="conversation-filter">Filter conversations</label>
|
<div aria-label="Filter conversations" className="conversation-filter-pills" role="group">
|
||||||
<select
|
{CONVERSATION_FILTERS.map((option) => (
|
||||||
id="conversation-filter"
|
<button
|
||||||
onChange={(event) => updateFilters({ ...activeFilters, filter: event.target.value as ConversationFilter })}
|
aria-label={`${option.label}, ${counts[option.value]}`}
|
||||||
value={activeFilters.filter}
|
aria-pressed={activeFilters.filter === option.value}
|
||||||
>
|
className="conversation-filter-pill"
|
||||||
<option value="all">All</option>
|
key={option.value}
|
||||||
<option value="unread">Unread</option>
|
onClick={() => updateFilters({ ...activeFilters, filter: option.value })}
|
||||||
<option value="awaiting-reply">Awaiting reply</option>
|
type="button"
|
||||||
<option value="received-images">Received images</option>
|
>
|
||||||
</select>
|
{option.label}
|
||||||
<span className="conversation-unread-count">{unreadCount} unread</span>
|
<span className="conversation-filter-count">{counts[option.value]}</span>
|
||||||
<label className="conversation-seller-toggle">
|
</button>
|
||||||
<input
|
))}
|
||||||
checked={activeFilters.showSellers}
|
</div>
|
||||||
onChange={(event) => updateFilters({ ...activeFilters, showSellers: event.target.checked })}
|
<div className="conversation-toolbar-row">
|
||||||
type="checkbox"
|
<label className="conversation-sort">
|
||||||
/>
|
<span>Sort</span>
|
||||||
Show sellers
|
<select
|
||||||
</label>
|
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>
|
</div>
|
||||||
{conversationsQuery.isPending ? (
|
{conversationsQuery.isPending ? (
|
||||||
<div aria-label="Loading conversations" className="conversation-list-state" role="status">Loading conversations…</div>
|
<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 ? (
|
) : conversationsQuery.data.length === 0 ? (
|
||||||
<div className="conversation-list-state"><strong>No conversations yet.</strong><span>Start one when you are ready.</span></div>
|
<div className="conversation-list-state"><strong>No conversations yet.</strong><span>Start one when you are ready.</span></div>
|
||||||
) : visibleConversations.length === 0 ? (
|
) : 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) => (
|
<ul className="conversation-list">
|
||||||
<ConversationListItem
|
{visibleConversations.map((conversation) => {
|
||||||
conversation={conversation}
|
const id = ownedConversationId(conversation);
|
||||||
isSelected={ownedConversationId(conversation) === activeConversationId}
|
return (
|
||||||
key={ownedConversationId(conversation)}
|
<ConversationRow
|
||||||
now={now}
|
conversation={conversation}
|
||||||
onSelect={() => selectConversation(ownedConversationId(conversation))}
|
isChecked={checkedIds.has(id)}
|
||||||
/>
|
isSelected={id === activeConversationId}
|
||||||
))}
|
key={id}
|
||||||
</ul>
|
now={now}
|
||||||
|
onSelect={() => selectConversation(id)}
|
||||||
|
onToggleChecked={() => toggleChecked(id)}
|
||||||
|
showOwner={accounts.length > 1}
|
||||||
|
registerRef={(element) => {
|
||||||
|
if (element) rowRefs.current.set(id, element);
|
||||||
|
else rowRefs.current.delete(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<p className="conversation-list-footer">
|
||||||
|
{hiddenSellerCount > 0
|
||||||
|
? `${hiddenSellerCount} hidden by the seller filter.`
|
||||||
|
: null}
|
||||||
|
<span className="conversation-shortcut-hint">
|
||||||
|
j/k move · Enter opens · u toggles unread · e marks done · / searches
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -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-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-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-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-toolbar { display: grid; gap: .55rem; 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-toolbar-row { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; }
|
||||||
.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-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-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 { 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:hover { background: var(--surface-raised); }
|
||||||
.conversation-list-item.is-selected { background: var(--accent-dark); border-color: #28593d; }
|
.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-checked { border-color: var(--accent); }
|
||||||
.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-unread .conversation-list-heading strong { font-weight: 900; }
|
.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-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-target:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
.conversation-list-check { display: flex; align-items: start; padding-top: .3rem; }
|
||||||
.conversation-list-item > span { position: relative; z-index: 1; pointer-events: none; }
|
.conversation-list-check input { accent-color: var(--accent); }
|
||||||
.conversation-list-item .reddit-username-link { pointer-events: auto; }
|
.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-item > span:last-child { overflow: hidden; color: var(--muted); font-size: .82rem; text-overflow: ellipsis; white-space: nowrap; }
|
.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 { display: flex; align-items: center; justify-content: space-between; gap: .5rem; }
|
||||||
.conversation-list-heading small { color: var(--accent); }
|
.conversation-list-heading time { color: var(--muted); font-size: .7rem; white-space: nowrap; }
|
||||||
.conversation-list-meta { display: flex; align-items: center; gap: .45rem; }
|
.conversation-list-heading time.is-stale { color: var(--warning-text); font-weight: 700; }
|
||||||
.conversation-list-meta time { color: var(--muted); font-size: .7rem; white-space: nowrap; }
|
.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-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-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, .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-list-state strong, .conversation-detail-state strong { color: #e9f1ec; }
|
||||||
.conversation-detail { min-height: 32rem; }
|
/* Column layout: history takes the slack, the composer stays reachable. */
|
||||||
.conversation-detail > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; padding: 1rem; border-bottom: 1px solid var(--border); }
|
.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 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 { 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; }
|
.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; }
|
.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-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 { 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-self { justify-self: end; background: var(--accent-dark); }
|
||||||
.conversation-messages li.is-recipient { justify-self: start; background: var(--surface-raised); }
|
.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 { 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-image-reveal:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
.conversation-empty-detail { padding: 2rem; color: var(--muted); text-align: center; }
|
.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, .conversation-actions { display: flex; align-items: center; justify-content: space-between; gap: .75rem; }
|
||||||
.conversation-section-heading h3 { margin: 0; }
|
.conversation-section-heading h3 { margin: 0; }
|
||||||
.conversation-actions > span { color: var(--muted); font-size: .75rem; }
|
.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) {
|
@media (min-width: 52rem) {
|
||||||
.conversations-page { padding: 3rem 2rem 5rem; }
|
.conversations-page { padding: 3rem 2rem 5rem; }
|
||||||
.start-conversation-form { grid-template-columns: minmax(12rem, .5fr) minmax(18rem, 1fr) auto; align-items: end; }
|
.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 {
|
.conversation-automation-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -18,3 +18,40 @@ export function formatShortRelativeTime(timestamp: number, now: number): string
|
|||||||
export function formatExactTime(timestamp: number): string {
|
export function formatExactTime(timestamp: number): string {
|
||||||
return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(timestamp);
|
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
@@ -75,9 +75,13 @@ an administrator credential.
|
|||||||
| `POST` | `/api/conversations/{id}/suggest-reply` | Generate a reply suggestion | admin | yes |
|
| `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}/suggest-media-ask` | Generate a media request suggestion | admin | yes |
|
||||||
| `POST` | `/api/conversations/{id}/send-reply` | Send a conversation reply | 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/{id}/share-media` | Send a selected exchange-library image | admin | yes |
|
||||||
| `POST` | `/api/conversations/start` | Start a new conversation | 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` | List received private media | read | no |
|
||||||
| `GET` | `/api/media/sync` | Get media sync state | read | no |
|
| `GET` | `/api/media/sync` | Get media sync state | read | no |
|
||||||
| `POST` | `/api/media/sync` | Trigger media sync | admin | yes |
|
| `POST` | `/api/media/sync` | Trigger media sync | admin | yes |
|
||||||
|
|||||||
@@ -18,12 +18,20 @@ export const ChatMessageSchema = z.strictObject({
|
|||||||
isAi: z.boolean().optional(),
|
isAi: z.boolean().optional(),
|
||||||
media: z.array(ChatMediaSchema).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({
|
export const ActiveChatSchema = z.strictObject({
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
roomName: z.string(),
|
roomName: z.string(),
|
||||||
lastMessageTime: z.number(),
|
lastMessageTime: z.number(),
|
||||||
lastMessageSnippet: z.string(),
|
lastMessageSnippet: z.string(),
|
||||||
isFromUs: z.boolean(),
|
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(),
|
unread: z.boolean(),
|
||||||
url: z.string().optional(),
|
url: z.string().optional(),
|
||||||
roomId: z.string().optional(),
|
roomId: z.string().optional(),
|
||||||
@@ -32,6 +40,14 @@ export const ActiveChatSchema = z.strictObject({
|
|||||||
hasReceivedImages: z.boolean().optional(),
|
hasReceivedImages: z.boolean().optional(),
|
||||||
sellerDetected: z.boolean().optional(),
|
sellerDetected: z.boolean().optional(),
|
||||||
owner: RedditAccountOwnerSchema.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 ChatsResponseSchema = z.array(ActiveChatSchema);
|
||||||
export const ConversationsResponseSchema = ChatsResponseSchema;
|
export const ConversationsResponseSchema = ChatsResponseSchema;
|
||||||
@@ -102,6 +118,28 @@ export const ConversationMediaShareOptionsResponseSchema = z.strictObject({
|
|||||||
warning: z.string().nullable(),
|
warning: z.string().nullable(),
|
||||||
});
|
});
|
||||||
export const ShareConversationMediaRequestSchema = z.strictObject({ assetId: z.string().uuid() });
|
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({
|
export const StartConversationRequestSchema = z.strictObject({
|
||||||
accountId: z.string().min(1),
|
accountId: z.string().min(1),
|
||||||
username: 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 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 ChatSyncStatus = z.infer<typeof ChatSyncStatusSchema>;
|
||||||
export type ConversationDetailResponse = z.infer<typeof ConversationDetailResponseSchema>;
|
export type ConversationDetailResponse = z.infer<typeof ConversationDetailResponseSchema>;
|
||||||
export type ConversationMediaShareOptions = z.infer<typeof ConversationMediaShareOptionsResponseSchema>;
|
export type ConversationMediaShareOptions = z.infer<typeof ConversationMediaShareOptionsResponseSchema>;
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ActivityLogRepository,
|
ActivityLogRepository,
|
||||||
RedditUsersRepository,
|
RedditUsersRepository,
|
||||||
ConversationStateRepository,
|
ConversationStateRepository,
|
||||||
|
ConversationReadStateRepository,
|
||||||
ConversationOutcomesRepository,
|
ConversationOutcomesRepository,
|
||||||
DeliveryOutboxRepository,
|
DeliveryOutboxRepository,
|
||||||
RecipientHistoryRepository,
|
RecipientHistoryRepository,
|
||||||
@@ -70,6 +71,7 @@ export interface AppContainer {
|
|||||||
successfulMessagesRepo: SuccessfulMessagesRepository;
|
successfulMessagesRepo: SuccessfulMessagesRepository;
|
||||||
deliveryOutboxRepo: DeliveryOutboxRepository;
|
deliveryOutboxRepo: DeliveryOutboxRepository;
|
||||||
conversationStateRepo: ConversationStateRepository;
|
conversationStateRepo: ConversationStateRepository;
|
||||||
|
conversationReadStateRepo: ConversationReadStateRepository;
|
||||||
conversationOutcomesRepo: ConversationOutcomesRepository;
|
conversationOutcomesRepo: ConversationOutcomesRepository;
|
||||||
recipientDeduplicator: RecipientDeduplicator;
|
recipientDeduplicator: RecipientDeduplicator;
|
||||||
candidateQueueRepo: CandidateQueueRepository;
|
candidateQueueRepo: CandidateQueueRepository;
|
||||||
@@ -130,6 +132,7 @@ export async function bootstrap(
|
|||||||
const redditUsersRepo = new RedditUsersRepository(db);
|
const redditUsersRepo = new RedditUsersRepository(db);
|
||||||
const deliveryOutboxRepo = new DeliveryOutboxRepository(db, bootstrapAccountId);
|
const deliveryOutboxRepo = new DeliveryOutboxRepository(db, bootstrapAccountId);
|
||||||
const conversationStateRepo = new ConversationStateRepository(db, bootstrapAccountId);
|
const conversationStateRepo = new ConversationStateRepository(db, bootstrapAccountId);
|
||||||
|
const conversationReadStateRepo = new ConversationReadStateRepository(db, bootstrapAccountId);
|
||||||
const conversationOutcomesRepo = new ConversationOutcomesRepository(db, bootstrapAccountId);
|
const conversationOutcomesRepo = new ConversationOutcomesRepository(db, bootstrapAccountId);
|
||||||
const recipientDeduplicator = new RecipientDeduplicator(new RecipientHistoryRepository(db));
|
const recipientDeduplicator = new RecipientDeduplicator(new RecipientHistoryRepository(db));
|
||||||
const candidateQueueRepo = new CandidateQueueRepository(db);
|
const candidateQueueRepo = new CandidateQueueRepository(db);
|
||||||
@@ -447,6 +450,7 @@ export async function bootstrap(
|
|||||||
webServer.setRedditAccountsService(redditAccounts);
|
webServer.setRedditAccountsService(redditAccounts);
|
||||||
webServer.setAccountRuntimeManager(accountRuntimeManager);
|
webServer.setAccountRuntimeManager(accountRuntimeManager);
|
||||||
webServer.setConversationStateRepository(conversationStateRepo);
|
webServer.setConversationStateRepository(conversationStateRepo);
|
||||||
|
webServer.setConversationReadStateRepository(conversationReadStateRepo);
|
||||||
webServer.setCandidateQueueRepository(candidateQueueRepo);
|
webServer.setCandidateQueueRepository(candidateQueueRepo);
|
||||||
webServer.setConfigStore(new ConfigStore(db));
|
webServer.setConfigStore(new ConfigStore(db));
|
||||||
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
|
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
|
||||||
@@ -480,6 +484,7 @@ export async function bootstrap(
|
|||||||
successfulMessagesRepo,
|
successfulMessagesRepo,
|
||||||
deliveryOutboxRepo,
|
deliveryOutboxRepo,
|
||||||
conversationStateRepo,
|
conversationStateRepo,
|
||||||
|
conversationReadStateRepo,
|
||||||
conversationOutcomesRepo,
|
conversationOutcomesRepo,
|
||||||
recipientDeduplicator,
|
recipientDeduplicator,
|
||||||
candidateQueueRepo,
|
candidateQueueRepo,
|
||||||
|
|||||||
@@ -412,6 +412,19 @@ export function runMigrations(db: Database.Database): void {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_media_library_sends_conversation
|
CREATE INDEX IF NOT EXISTS idx_media_library_sends_conversation
|
||||||
ON media_library_sends(account_id, conversation_id, requested_at);
|
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;
|
let currentVersion = db.pragma('user_version', { simple: true }) as number;
|
||||||
|
|||||||
@@ -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. */
|
/** Durable idempotency claims for AI replies to individual inbound events. */
|
||||||
export interface ConversationAutomationClaimRecord {
|
export interface ConversationAutomationClaimRecord {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { BrowserClient } from '../browser/client.js';
|
|||||||
import {
|
import {
|
||||||
PostsRepository, MessagesRepository, SuccessfulMessagesRepository,
|
PostsRepository, MessagesRepository, SuccessfulMessagesRepository,
|
||||||
DeliveryOutboxRepository, ConversationStateRepository,
|
DeliveryOutboxRepository, ConversationStateRepository,
|
||||||
|
ConversationReadStateRepository,
|
||||||
ConversationAutomationClaimRepository,
|
ConversationAutomationClaimRepository,
|
||||||
ConversationOutcomesRepository, RedditUsersRepository,
|
ConversationOutcomesRepository, RedditUsersRepository,
|
||||||
ActivityLogRepository,
|
ActivityLogRepository,
|
||||||
@@ -158,6 +159,7 @@ async function buildRuntime(
|
|||||||
const successfulMessagesRepo = new SuccessfulMessagesRepository(db, session.accountId);
|
const successfulMessagesRepo = new SuccessfulMessagesRepository(db, session.accountId);
|
||||||
const deliveryOutboxRepo = new DeliveryOutboxRepository(db, session.accountId);
|
const deliveryOutboxRepo = new DeliveryOutboxRepository(db, session.accountId);
|
||||||
const conversationStateRepo = new ConversationStateRepository(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 conversationAutomationClaimRepo = new ConversationAutomationClaimRepository(db, session.accountId);
|
||||||
const conversationOutcomesRepo = new ConversationOutcomesRepository(db, session.accountId);
|
const conversationOutcomesRepo = new ConversationOutcomesRepository(db, session.accountId);
|
||||||
const redditUsersRepo = new RedditUsersRepository(db);
|
const redditUsersRepo = new RedditUsersRepository(db);
|
||||||
@@ -247,7 +249,8 @@ async function buildRuntime(
|
|||||||
const resources: RedditAccountRuntimeResources = {
|
const resources: RedditAccountRuntimeResources = {
|
||||||
browserProfileDir, browserClient, accountAuth, matrixClient, chatCache, redditApi,
|
browserProfileDir, browserClient, accountAuth, matrixClient, chatCache, redditApi,
|
||||||
postsRepo, messagesRepo, successfulMessagesRepo, deliveryOutboxRepo,
|
postsRepo, messagesRepo, successfulMessagesRepo, deliveryOutboxRepo,
|
||||||
conversationStateRepo, conversationOutcomesRepo, redditUsersRepo, accountContentRepo,
|
conversationStateRepo, conversationReadStateRepo, conversationOutcomesRepo,
|
||||||
|
redditUsersRepo, accountContentRepo,
|
||||||
activityLogRepo,
|
activityLogRepo,
|
||||||
monitorService, processorService, responseChecker, conversationAutomation, chatService, mediaService,
|
monitorService, processorService, responseChecker, conversationAutomation, chatService, mediaService,
|
||||||
mediaSyncService, mediaExchange, rateLimiter,
|
mediaSyncService, mediaExchange, rateLimiter,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
ConversationOutcomesRepository,
|
ConversationOutcomesRepository,
|
||||||
ActivityLogRepository,
|
ActivityLogRepository,
|
||||||
ConversationStateRepository,
|
ConversationStateRepository,
|
||||||
|
ConversationReadStateRepository,
|
||||||
DeliveryOutboxRepository,
|
DeliveryOutboxRepository,
|
||||||
MessagesRepository,
|
MessagesRepository,
|
||||||
PostsRepository,
|
PostsRepository,
|
||||||
@@ -65,6 +66,7 @@ export interface RedditAccountRuntimeResources {
|
|||||||
successfulMessagesRepo: SuccessfulMessagesRepository;
|
successfulMessagesRepo: SuccessfulMessagesRepository;
|
||||||
deliveryOutboxRepo: DeliveryOutboxRepository;
|
deliveryOutboxRepo: DeliveryOutboxRepository;
|
||||||
conversationStateRepo: ConversationStateRepository;
|
conversationStateRepo: ConversationStateRepository;
|
||||||
|
conversationReadStateRepo: ConversationReadStateRepository;
|
||||||
conversationOutcomesRepo: ConversationOutcomesRepository;
|
conversationOutcomesRepo: ConversationOutcomesRepository;
|
||||||
redditUsersRepo: RedditUsersRepository;
|
redditUsersRepo: RedditUsersRepository;
|
||||||
accountContentRepo: AccountContentRepository;
|
accountContentRepo: AccountContentRepository;
|
||||||
|
|||||||
@@ -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('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('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('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('getMedia', 'GET', '/api/media', 'List received private media', ['Media']),
|
||||||
operation('getMediaSync', 'GET', '/api/media/sync', 'Get media sync state', ['Media']),
|
operation('getMediaSync', 'GET', '/api/media/sync', 'Get media sync state', ['Media']),
|
||||||
operation('triggerMediaSync', 'POST', '/api/media/sync', 'Trigger media sync', ['Media']),
|
operation('triggerMediaSync', 'POST', '/api/media/sync', 'Trigger media sync', ['Media']),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
MessagesRepository,
|
MessagesRepository,
|
||||||
SuccessfulMessagesRepository,
|
SuccessfulMessagesRepository,
|
||||||
ConversationStateRepository,
|
ConversationStateRepository,
|
||||||
|
ConversationReadStateRepository,
|
||||||
} from '../database/repositories.js';
|
} from '../database/repositories.js';
|
||||||
import type { ChatService } from '../services/chat.js';
|
import type { ChatService } from '../services/chat.js';
|
||||||
import type { MediaService } from '../services/media.js';
|
import type { MediaService } from '../services/media.js';
|
||||||
@@ -55,6 +56,7 @@ export interface WebContext {
|
|||||||
sessionImporter?: RedditSessionImporter;
|
sessionImporter?: RedditSessionImporter;
|
||||||
accountAuth?: AccountAuthContext;
|
accountAuth?: AccountAuthContext;
|
||||||
conversationState?: ConversationStateRepository;
|
conversationState?: ConversationStateRepository;
|
||||||
|
conversationReadState?: ConversationReadStateRepository;
|
||||||
configStore?: ConfigStore;
|
configStore?: ConfigStore;
|
||||||
redditAccounts?: RedditAccountsService;
|
redditAccounts?: RedditAccountsService;
|
||||||
accountRuntimes?: RedditAccountRuntimeManager;
|
accountRuntimes?: RedditAccountRuntimeManager;
|
||||||
|
|||||||
@@ -10,11 +10,73 @@ import {
|
|||||||
remainingAutomationTurns,
|
remainingAutomationTurns,
|
||||||
} from '../../services/conversation-state.js';
|
} from '../../services/conversation-state.js';
|
||||||
import type { MediaLibraryAsset } from '../../services/media-library-types.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 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 } };
|
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 {
|
export function createConversationsRouter(ctx: WebContext): Router {
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -110,21 +172,32 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
}
|
}
|
||||||
|
|
||||||
router.get('/conversations', async (req, res) => {
|
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' });
|
res.status(503).json({ error: 'Chat service not initialized' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const runtimes = ctx.services.accountRuntimes?.list().filter((runtime) => runtime.state === 'running') ?? [];
|
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) => {
|
? (await Promise.all(runtimes.map(async (snapshot) => {
|
||||||
const runtime = ctx.services.accountRuntimes!.get(snapshot.accountId)!;
|
const runtime = ctx.services.accountRuntimes!.get(snapshot.accountId)!;
|
||||||
return (await runtime.resources.chatService.getCachedConversations()).map((conversation) => withSellerMarker({
|
const readState = currentReadState(runtime.resources.conversationReadStateRepo);
|
||||||
...conversation,
|
return (await runtime.resources.chatService.getCachedConversations()).map((conversation) => annotateTriage(
|
||||||
owner: { accountId: snapshot.accountId, redditUsername: snapshot.redditUsername },
|
withSellerMarker({
|
||||||
}, runtime.resources.conversationStateRepo));
|
...conversation,
|
||||||
|
owner: { accountId: snapshot.accountId, redditUsername: snapshot.redditUsername },
|
||||||
|
}, runtime.resources.conversationStateRepo),
|
||||||
|
conversation.roomId ? readState.get(conversation.roomId) : undefined,
|
||||||
|
));
|
||||||
}))).flat()
|
}))).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') {
|
if (req.query.includeImageMetadata !== 'true') {
|
||||||
res.json(conversations.map((conversation) => ({ ...conversation, owner: conversation.owner ?? currentOwner() })));
|
res.json(conversations.map((conversation) => ({ ...conversation, owner: conversation.owner ?? currentOwner() })));
|
||||||
return;
|
return;
|
||||||
@@ -269,8 +342,20 @@ export function createConversationsRouter(ctx: WebContext): Router {
|
|||||||
return { ...message, isAi: true };
|
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({
|
res.json({
|
||||||
...conversation,
|
...conversation,
|
||||||
|
hasUnreadInbound: triage.hasUnreadInbound,
|
||||||
|
needsReply: triage.needsReply,
|
||||||
|
triageStatus: triage.triageStatus,
|
||||||
|
snoozedUntil: triage.snoozedUntil,
|
||||||
|
lastReadMessageTs: triage.lastReadMessageTs,
|
||||||
sellerDetected: intelligence.state.sellerDetected,
|
sellerDetected: intelligence.state.sellerDetected,
|
||||||
...(annotatedMessages ? { messages: annotatedMessages } : {}),
|
...(annotatedMessages ? { messages: annotatedMessages } : {}),
|
||||||
owner: resolved.runtime
|
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) => {
|
router.get('/conversations/:id/shareable-media', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const resolved = await findConversation(decodeURIComponent(req.params.id));
|
const resolved = await findConversation(decodeURIComponent(req.params.id));
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
MessagesRepository,
|
MessagesRepository,
|
||||||
SuccessfulMessagesRepository,
|
SuccessfulMessagesRepository,
|
||||||
ConversationStateRepository,
|
ConversationStateRepository,
|
||||||
|
ConversationReadStateRepository,
|
||||||
ActivityLogRepository,
|
ActivityLogRepository,
|
||||||
} from '../database/repositories.js';
|
} from '../database/repositories.js';
|
||||||
import type { ChatService } from '../services/chat.js';
|
import type { ChatService } from '../services/chat.js';
|
||||||
@@ -247,6 +248,10 @@ export class WebServer {
|
|||||||
this.services.conversationState = conversationState;
|
this.services.conversationState = conversationState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setConversationReadStateRepository(conversationReadState: ConversationReadStateRepository): void {
|
||||||
|
this.services.conversationReadState = conversationReadState;
|
||||||
|
}
|
||||||
|
|
||||||
setCandidateQueueRepository(candidateQueue: CandidateQueueRepository): void {
|
setCandidateQueueRepository(candidateQueue: CandidateQueueRepository): void {
|
||||||
this.services.candidateQueue = candidateQueue;
|
this.services.candidateQueue = candidateQueue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -70,6 +70,12 @@ const server = setupServer(
|
|||||||
requestKind: null, assets: [], warning: null,
|
requestKind: null, assets: [], warning: null,
|
||||||
reason: 'No enabled, unused library images are available to share.',
|
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() {
|
function installConversationList() {
|
||||||
@@ -139,7 +145,7 @@ describe('ConversationsPage', () => {
|
|||||||
expect(screen.getByRole('button', { name: /u\/bob/ })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: /u\/bob/ })).toBeInTheDocument();
|
||||||
|
|
||||||
await user.clear(screen.getByRole('searchbox', { name: 'Search conversations' }));
|
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.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.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/ });
|
const aliceItem = await screen.findByRole('button', { name: /u\/alice/ });
|
||||||
expect(screen.getByText('Image received')).toBeInTheDocument();
|
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.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.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();
|
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();
|
installConversationList();
|
||||||
renderConversations();
|
renderConversations();
|
||||||
|
|
||||||
const aliceButton = await screen.findByRole('button', { name: /u\/alice/ });
|
const aliceButton = await screen.findByRole('button', { name: /u\/alice/ });
|
||||||
expect(screen.getByText('1 unread')).toBeInTheDocument();
|
// alice is inbound and unread; bob is a conversation we replied to last.
|
||||||
expect(aliceButton.getAttribute('aria-label')).toMatch(/^u\/alice, Unread, .+ ago/);
|
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(aliceButton.closest('li')).toHaveClass('is-unread');
|
||||||
expect(screen.getByRole('button', { name: /u\/bob/ }).closest('li')).not.toHaveClass('is-unread');
|
expect(screen.getByRole('button', { name: /u\/bob/ }).closest('li')).not.toHaveClass('is-unread');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,14 +32,21 @@ describe('route-friendly filters', () => {
|
|||||||
it('hydrates and writes conversation search and status filters', async () => {
|
it('hydrates and writes conversation search and status filters', async () => {
|
||||||
server.use(http.get('/api/conversations', () => HttpResponse.json([])));
|
server.use(http.get('/api/conversations', () => HttpResponse.json([])));
|
||||||
const user = userEvent.setup();
|
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' });
|
const search = await screen.findByRole('searchbox', { name: 'Search conversations' });
|
||||||
expect(search).toHaveValue('alice');
|
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();
|
expect(screen.getByRole('checkbox', { name: 'Show sellers' })).toBeChecked();
|
||||||
await user.clear(search);
|
await user.clear(search);
|
||||||
await user.type(search, 'bob');
|
await user.type(search, 'bob');
|
||||||
expect(screen.getByLabelText('Current URL')).toHaveTextContent('q=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 () => {
|
it('hydrates and writes media filters', async () => {
|
||||||
|
|||||||
@@ -107,12 +107,13 @@ describe('Database Client', () => {
|
|||||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||||
.all() as { name: string }[];
|
.all() as { name: string }[];
|
||||||
// Existing domain/config tables plus account, conversation, sync and media ownership stores.
|
// 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([
|
expect(tables.map((table) => table.name)).toEqual(expect.arrayContaining([
|
||||||
'candidate_queue',
|
'candidate_queue',
|
||||||
'runtime_state',
|
'runtime_state',
|
||||||
'media_library_assets',
|
'media_library_assets',
|
||||||
'media_library_sends',
|
'media_library_sends',
|
||||||
|
'conversation_read_state',
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,13 +157,15 @@ describe('Database Client', () => {
|
|||||||
) VALUES (?, 'old-post', 'Sent_Contact', 'subject', 'message', 11)
|
) VALUES (?, 'old-post', 'Sent_Contact', 'subject', 'message', 11)
|
||||||
`).run('__legacy_unassigned__');
|
`).run('__legacy_unassigned__');
|
||||||
|
|
||||||
const latestVersion = db.pragma('user_version', { simple: true }) as number;
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
DELETE FROM recipient_suppressions;
|
DELETE FROM recipient_suppressions;
|
||||||
DROP TABLE conversation_automation_claims;
|
DROP TABLE conversation_automation_claims;
|
||||||
DROP TABLE ai_usage;
|
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);
|
runMigrations(db);
|
||||||
|
|
||||||
expect(db.prepare('SELECT recipient, reason FROM recipient_suppressions ORDER BY recipient COLLATE NOCASE').all())
|
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', () => {
|
it('preserves pending reply claims when adding auditable superseded status', () => {
|
||||||
runMigrations(db);
|
runMigrations(db);
|
||||||
const latestVersion = db.pragma('user_version', { simple: true }) as number;
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
DROP TABLE conversation_automation_claims;
|
DROP TABLE conversation_automation_claims;
|
||||||
CREATE TABLE conversation_automation_claims (
|
CREATE TABLE conversation_automation_claims (
|
||||||
@@ -199,7 +201,8 @@ describe('Database Client', () => {
|
|||||||
'durable reply', 5, 11, 1
|
'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);
|
runMigrations(db);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,19 @@ import { buildConversationState, conversationMessageFingerprint } from '../../sr
|
|||||||
|
|
||||||
const servers: Array<ReturnType<express.Express['listen']>> = [];
|
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(
|
async function request(
|
||||||
app: express.Express,
|
app: express.Express,
|
||||||
path: string,
|
path: string,
|
||||||
@@ -289,9 +302,11 @@ describe('conversations route', () => {
|
|||||||
const response = await request(app, '/api/conversations?includeImageMetadata=true');
|
const response = await request(app, '/api/conversations?includeImageMetadata=true');
|
||||||
|
|
||||||
expect(legacyResponse.status).toBe(200);
|
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(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 () => {
|
it('marks conversations with a durable seller detection', async () => {
|
||||||
@@ -313,7 +328,9 @@ describe('conversations route', () => {
|
|||||||
const response = await request(app, '/api/conversations');
|
const response = await request(app, '/api/conversations');
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
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 () => {
|
it('serves conversation details without starting a delta sync', async () => {
|
||||||
@@ -458,4 +475,218 @@ describe('conversations route', () => {
|
|||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(await response.json()).toEqual({ success: true });
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user