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