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 (
    Inbox

    Conversations

    Review replies, draft carefully, and send only after confirmation.

    {lastSyncedLabel ? `Updated ${lastSyncedLabel} · auto every 30s` : 'Auto-refreshes every 30s'}
    {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.
    )}
    ); }