import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ActiveChat, SellerScanResponse } from '../../../../../packages/api-contracts/src/index.js';
import { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.js';
import { ConversationDetail } from './conversation-detail.js';
import {
useBulkMarkConversationsReadMutation,
useConversationsQuery,
useMarkConversationReadMutation,
useMarkConversationUnreadMutation,
useSellerScanMutation,
useSetConversationStatusMutation,
} from './conversation-queries.js';
import { StartConversationForm } from './start-conversation-form.js';
import { ProcessedUserLookup } from './processed-user-lookup.js';
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
import './conversations.css';
import { ownedConversationId } from './conversation-identity.js';
import { conversationAge, formatExactTime, formatShortRelativeTime } from './relative-time.js';
import {
CONVERSATION_FILTERS,
CONVERSATION_SORTS,
DEFAULT_CONVERSATION_FILTERS,
accountOptions,
conversationQueue,
filterCounts,
isUnread,
manualFlowLabel,
needsReply,
partitionConversations,
type ConversationFilter,
type ConversationFilters,
type ConversationSort,
} from './conversation-triage.js';
export type { ConversationFilter, ConversationFilters, ConversationSort } from './conversation-triage.js';
const SMALL_VIEWPORT_QUERY = '(max-width: 51.9999rem)';
export interface ConversationsPageProps {
selectedConversationId?: string | null;
onSelectedConversationChange?(conversationId: string | null): void;
filters?: ConversationFilters;
onFiltersChange?(filters: ConversationFilters): void;
}
interface ConversationRowProps {
conversation: ActiveChat;
isSelected: boolean;
isChecked: boolean;
/** Only worth the row space when more than one account is in play. */
showOwner: boolean;
now: number;
onSelect(): void;
onToggleChecked(): void;
registerRef(element: HTMLButtonElement | null): void;
}
/**
* A chip's visible label, plus the longer form used for screen readers and the
* hover tooltip when the label alone does not say why the chip is there.
*/
interface ConversationChip {
label: string;
hint?: string;
tooltip?: string;
}
/**
* Plain-English outcome of a forced seller sweep. The AI counts are shown
* because the sweep is deliberately frugal with them — most conversations are
* settled by keyword rules or skipped as unchanged, and the operator should be
* able to see that.
*/
function sellerScanSummary(result: SellerScanResponse): string {
const flagged = result.flagged.length;
const killed = result.flagged.filter((flag) => flag.killed).length;
const parts = [
`Scanned ${result.scanned} ${result.scanned === 1 ? 'conversation' : 'conversations'}`,
flagged === 0 ? 'no new sellers found' : `${flagged} flagged as sellers${killed > 0 ? ` (${killed} ended)` : ''}`,
result.aiScreened === 0
? 'no AI needed'
: `${result.aiScreened} needed AI over ${result.aiRequests} ${result.aiRequests === 1 ? 'request' : 'requests'}`,
];
if (result.unchanged > 0) parts.push(`${result.unchanged} unchanged since the last scan`);
if (result.budgetSkipped > 0) parts.push(`${result.budgetSkipped} left for the next scan`);
if (result.degraded) parts.push('some conversations were skipped because screening failed');
return `${parts.join(' · ')}.`;
}
/** One workflow chip plus independent presentation/ownership badges. */
function conversationChips(conversation: ActiveChat, now: number): ConversationChip[] {
const chips: ConversationChip[] = [];
switch (conversationQueue(conversation)) {
case 'needs-reply': chips.push({ label: 'Needs attention' }); break;
case 'awaiting-reply': chips.push({ label: 'Waiting' }); break;
case 'sellers': chips.push({ label: 'Seller' }); break;
case 'snoozed': chips.push({ label: 'Snoozed' }); break;
case 'done': chips.push({ label: 'Done' }); break;
case 'account-deleted': chips.push({ label: 'Account deleted' }); break;
case 'archived': chips.push({ label: 'Archived' }); break;
}
if (isUnread(conversation)) chips.push({ label: 'Unread' });
const manualLabel = manualFlowLabel(conversation);
if (manualLabel) {
chips.push({
label: 'Manual',
hint: manualLabel,
tooltip: conversation.automationHandoffReason
? `${manualLabel} — ${conversation.automationHandoffReason}`
: manualLabel,
});
}
if (conversationQueue(conversation) === 'needs-reply'
&& conversationAge(conversation.lastMessageTime, now) !== 'fresh') {
chips.push({ label: 'Overdue' });
}
return chips;
}
function ConversationRow({
conversation, isSelected, isChecked, showOwner, now, onSelect, onToggleChecked, registerRef,
}: ConversationRowProps) {
const relativeTime = formatShortRelativeTime(conversation.lastMessageTime, now);
const unread = isUnread(conversation);
const overdue = needsReply(conversation) ? conversationAge(conversation.lastMessageTime, now) : 'fresh';
const chips = conversationChips(conversation, now);
const itemClasses = ['conversation-list-item'];
if (isSelected) itemClasses.push('is-selected');
if (unread) itemClasses.push('is-unread');
if (isChecked) itemClasses.push('is-checked');
if (conversation.readOnly) itemClasses.push('is-read-only');
const descriptors = [
redditUsernameLabel(conversation.username),
...chips.map((chip) => chip.hint ?? chip.label),
relativeTime,
...(conversation.hasReceivedImages ? ['Image received'] : []),
...(conversation.readOnly ? ['Removed account, read-only'] : []),
];
return (
{chips.map((chip) => (
{chip.label}
))}
{showOwner ? : null}
{conversation.readOnly ? (
Read-only
) : null}
{conversation.hasReceivedImages ? Image received : null}
);
}
export function ConversationsPage({
selectedConversationId, onSelectedConversationChange, filters, onFiltersChange,
}: ConversationsPageProps) {
const conversationsQuery = useConversationsQuery();
const [internalSelectedId, setInternalSelectedId] = useState(null);
const [localFilters, setLocalFilters] = useState(DEFAULT_CONVERSATION_FILTERS);
const [isStartingConversation, setIsStartingConversation] = useState(false);
const [now, setNow] = useState(() => Date.now());
const [checkedIds, setCheckedIds] = useState>(() => new Set());
const detailPanelRef = useRef(null);
const searchRef = useRef(null);
const rowRefs = useRef(new Map());
const activeConversationId = selectedConversationId !== undefined ? selectedConversationId : internalSelectedId;
const activeFilters = filters ?? localFilters;
const markRead = useMarkConversationReadMutation();
const markUnread = useMarkConversationUnreadMutation();
const setStatus = useSetConversationStatusMutation();
const bulkMarkRead = useBulkMarkConversationsReadMutation();
const sellerScan = useSellerScanMutation();
useEffect(() => {
const interval = window.setInterval(() => setNow(Date.now()), 60_000);
return () => window.clearInterval(interval);
}, []);
// On small viewports the detail panel sits below the list; bring it into
// view when a conversation is picked so the selection isn't invisible.
useEffect(() => {
if (!activeConversationId) return;
if (typeof window.matchMedia !== 'function') return;
if (!window.matchMedia(SMALL_VIEWPORT_QUERY).matches) return;
detailPanelRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
}, [activeConversationId]);
const updateFilters = useCallback((next: ConversationFilters) => {
if (filters === undefined) setLocalFilters(next);
onFiltersChange?.(next);
}, [filters, onFiltersChange]);
const allConversations = conversationsQuery.data ?? [];
const { visible: liveConversations } = 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(null);
const liveIds = useMemo(
() => liveConversations.map((conversation) => ownedConversationId(conversation)),
[liveConversations],
);
useEffect(() => {
if (!activeConversationId) {
setFrozenOrder(null);
return;
}
// Capture the order as it was when the conversation was opened. `liveIds` is
// intentionally read once here rather than tracked, so later polls do not
// re-freeze against a reordered list.
setFrozenOrder((current) => current ?? liveIds);
}, [activeConversationId, liveIds]);
const visibleConversations = useMemo(() => {
if (!frozenOrder) return liveConversations;
const rank = new Map(frozenOrder.map((id, index) => [id, index]));
return liveConversations
.filter((conversation) => rank.has(ownedConversationId(conversation)))
.sort((left, right) =>
(rank.get(ownedConversationId(left)) ?? 0) - (rank.get(ownedConversationId(right)) ?? 0));
}, [liveConversations, frozenOrder]);
const pendingCount = liveConversations.length - visibleConversations.length;
const visibleIds = useMemo(
() => visibleConversations.map((conversation) => ownedConversationId(conversation)),
[visibleConversations],
);
function selectConversation(id: string | null) {
if (selectedConversationId === undefined) setInternalSelectedId(id);
onSelectedConversationChange?.(id);
}
function releaseFrozenOrder() {
setFrozenOrder(activeConversationId ? liveIds : null);
}
function toggleChecked(id: string) {
setCheckedIds((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
const checkedVisibleIds = visibleIds.filter((id) => checkedIds.has(id));
const allVisibleChecked = visibleIds.length > 0 && checkedVisibleIds.length === visibleIds.length;
function toggleAllVisible() {
setCheckedIds(allVisibleChecked ? new Set() : new Set(visibleIds));
}
function moveFocus(offset: number) {
if (visibleIds.length === 0) return;
const focused = document.activeElement;
let index = visibleIds.findIndex((id) => rowRefs.current.get(id) === focused);
if (index < 0) index = visibleIds.findIndex((id) => id === activeConversationId);
if (index < 0) {
// Entering the list for the first time: land on the near end rather than
// stepping past it.
rowRefs.current.get(visibleIds[offset > 0 ? 0 : visibleIds.length - 1])?.focus();
return;
}
const nextIndex = Math.min(visibleIds.length - 1, Math.max(0, index + offset));
rowRefs.current.get(visibleIds[nextIndex])?.focus();
}
function focusedConversationId(): string | null {
const focused = document.activeElement;
const focusedId = visibleIds.find((id) => rowRefs.current.get(id) === focused);
return focusedId ?? activeConversationId ?? null;
}
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName);
}
/**
* Inbox keyboard shortcuts. Suppressed whenever focus is inside a text field
* so typing a reply or a search term never triggers an action.
*/
function handleShortcut(event: KeyboardEvent) {
if (event.metaKey || event.ctrlKey || event.altKey) return;
// A modal owns the keyboard while it is open — including Escape, which must
// dismiss the dialog rather than the conversation behind it.
if (event.defaultPrevented || document.querySelector('dialog[open]')) return;
const typing = isTypingTarget(event.target);
if (event.key === '/' && !typing) {
event.preventDefault();
searchRef.current?.focus();
return;
}
if (event.key === 'Escape') {
if (typing) (event.target as HTMLElement).blur();
else if (activeConversationId) selectConversation(null);
return;
}
if (typing) return;
switch (event.key) {
case 'j':
case 'ArrowDown':
event.preventDefault();
moveFocus(1);
break;
case 'k':
case 'ArrowUp':
event.preventDefault();
moveFocus(-1);
break;
case 'u': {
const id = focusedConversationId();
const conversation = allConversations.find((item) => ownedConversationId(item) === id);
if (!id || !conversation) return;
event.preventDefault();
if (isUnread(conversation)) markRead.mutate({ id });
else markUnread.mutate({ id });
break;
}
case 'e': {
const id = focusedConversationId();
if (!id) return;
event.preventDefault();
setStatus.mutate({ id, status: 'done' });
if (id === activeConversationId) selectConversation(null);
break;
}
default:
break;
}
}
// The handler closes over per-render state, so it is kept in a ref and the
// listener is registered once rather than re-bound on every render.
const shortcutRef = useRef(handleShortcut);
shortcutRef.current = handleShortcut;
useEffect(() => {
const listener = (event: KeyboardEvent) => shortcutRef.current(event);
window.addEventListener('keydown', listener);
return () => window.removeEventListener('keydown', listener);
}, []);
const lastSyncedLabel = conversationsQuery.dataUpdatedAt
? formatShortRelativeTime(conversationsQuery.dataUpdatedAt, now)
: null;
return (
{sellerScan.isError ? (
Seller scan failed: {sellerScan.error.message}
) : sellerScan.data ? (
{sellerScanSummary(sellerScan.data)}
) : null}
{isStartingConversation ? (
Start a conversation
selectConversation(id)} />
) : null}
updateFilters({ ...activeFilters, search: event.target.value })}
placeholder="Search conversations (/)"
ref={searchRef}
type="search"
value={activeFilters.search}
/>
{CONVERSATION_FILTERS.map((option) => (
))}
{accounts.length > 1 ? (
) : null}
{visibleConversations.length > 0 ? (
{checkedVisibleIds.length > 0 ? (
<>
>
) : null}
) : null}
{pendingCount > 0 ? (
) : null}
{conversationsQuery.isPending ? (
Loading conversations…
) : conversationsQuery.isError ? (
Could not load conversations
{conversationsQuery.error.message}
) : conversationsQuery.data.length === 0 ? (
No conversations yet.Start one when you are ready.
) : visibleConversations.length === 0 ? (
No matching conversations.
Try a different search or filter.
) : (
<>
{visibleConversations.map((conversation) => {
const id = ownedConversationId(conversation);
return (
selectConversation(id)}
onToggleChecked={() => toggleChecked(id)}
showOwner={accounts.length > 1}
registerRef={(element) => {
if (element) rowRefs.current.set(id, element);
else rowRefs.current.delete(id);
}}
/>
);
})}
j/k move · Enter opens · u toggles unread · e marks done · / searches
>
)}
{activeConversationId ? (
selectConversation(null)} />
) : (
Select a conversation
Message history and reply tools will appear here.
)}
);
}