Conversations were hard to manage because "unread" came straight from
Matrix's notification_count, and nothing in the app ever sends a read
receipt — so opening a conversation in the dashboard could never clear it
and the unread set only ever grew. The list also collapsed three
orthogonal facts into one word, labelling "they spoke last and we have
not replied" as "Up to date".
Read state is now ours:
- conversation_read_state table (migration 19) plus
ConversationReadStateRepository, kept separate from conversation_states
so a read write can never be lost to mergeConversationState. The
watermark is monotonic, so a stale poll cannot resurrect an unread
badge.
- hasUnreadInbound and needsReply are derived server-side and returned on
every conversation. Matrix's `unread` stays on the contract but the
dashboard no longer reads it.
- POST /conversations/{id}/read|unread|status and
POST /conversations/bulk-read, with optimistic cache updates.
Inbox rework:
- List rows are a real grid (checkbox, open button, chip strip) instead of
a full-bleed absolute overlay button, which previously forced a
pointer-events escape hatch for anything interactive in a row. Rows use
aria-current rather than aria-pressed. Not a listbox: options must not
contain interactive descendants.
- Counted filter pills replace the select, independent state chips replace
the collapsed status word, plus a sort control (including oldest
unanswered), a per-account filter, age-coloured timestamps, and an
explicit count of rows hidden by the seller filter.
- Keyboard: j/k/arrows, Enter, u, e, / and Escape. Shortcuts yield to any
open dialog so Escape dismisses the modal, not the conversation behind
it. Multi-select with bulk mark-read and mark-done.
- Detail panel: auto-mark-read on open, a "New messages" divider pinned at
mount (deriving it from the live watermark made it vanish a second after
opening), triage actions in the header, the automation banner moved out
of the composer, day dividers with time-only stamps, and a flex column
so history scrolls in its own region instead of painting over the
composer.
- Row order freezes while a conversation is open, with a "N new — refresh"
pill, so a background poll cannot reorder rows mid-triage.
Two database tests rewound user_version by an offset from the latest
version, which silently re-targets a different migration whenever one is
appended. They now pin absolute versions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
553 lines
23 KiB
TypeScript
553 lines
23 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,
|
|
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;
|
|
}
|
|
|
|
/** Independent state chips. A conversation can be several of these at once. */
|
|
function conversationChips(conversation: ActiveChat, now: number): string[] {
|
|
const chips: string[] = [];
|
|
if (isUnread(conversation)) chips.push('Unread');
|
|
if (needsReply(conversation)) chips.push('Needs reply');
|
|
else if (conversation.isFromUs && triageStatus(conversation) === 'open') chips.push('Waiting on them');
|
|
const status = triageStatus(conversation);
|
|
if (status === 'snoozed') chips.push('Snoozed');
|
|
if (status === 'done') chips.push('Done');
|
|
if (needsReply(conversation) && conversationAge(conversation.lastMessageTime, now) !== 'fresh') {
|
|
chips.push('Overdue');
|
|
}
|
|
return chips;
|
|
}
|
|
|
|
function ConversationRow({
|
|
conversation, isSelected, isChecked, showOwner, now, onSelect, onToggleChecked, registerRef,
|
|
}: ConversationRowProps) {
|
|
const relativeTime = formatShortRelativeTime(conversation.lastMessageTime, now);
|
|
const unread = isUnread(conversation);
|
|
const overdue = needsReply(conversation) ? conversationAge(conversation.lastMessageTime, now) : 'fresh';
|
|
const chips = conversationChips(conversation, now);
|
|
const itemClasses = ['conversation-list-item'];
|
|
if (isSelected) itemClasses.push('is-selected');
|
|
if (unread) itemClasses.push('is-unread');
|
|
if (isChecked) itemClasses.push('is-checked');
|
|
|
|
const descriptors = [
|
|
redditUsernameLabel(conversation.username),
|
|
...chips,
|
|
relativeTime,
|
|
...(conversation.sellerDetected ? ['Seller'] : []),
|
|
...(conversation.hasReceivedImages ? ['Image received'] : []),
|
|
];
|
|
|
|
return (
|
|
<li className={itemClasses.join(' ')}>
|
|
<label className="conversation-list-check">
|
|
<input
|
|
aria-label={`Select ${redditUsernameLabel(conversation.username)}`}
|
|
checked={isChecked}
|
|
onChange={onToggleChecked}
|
|
type="checkbox"
|
|
/>
|
|
</label>
|
|
<button
|
|
aria-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.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>
|
|
</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>
|
|
);
|
|
}
|