feat: application-level read state and inbox triage for conversations
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>
This commit is contained in:
1 parent
c8689af04d
commit
725b5fc4e6
26 files changed
+2625
-203
No files matched your search
@@ -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 { RedditUsernameLink, redditUsernameLabel } from '../../components/reddit-username-link.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 { ProcessedUserLookup } from './processed-user-lookup.js';
|
||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||
import './conversations.css';
|
||||
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)';
|
||||
|
||||
export interface ConversationFilters {
|
||||
search: string;
|
||||
filter: ConversationFilter;
|
||||
showSellers: boolean;
|
||||
}
|
||||
|
||||
export interface ConversationsPageProps {
|
||||
selectedConversationId?: string | null;
|
||||
onSelectedConversationChange?(conversationId: string | null): void;
|
||||
@@ -27,59 +41,118 @@ export interface ConversationsPageProps {
|
||||
onFiltersChange?(filters: ConversationFilters): void;
|
||||
}
|
||||
|
||||
function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
|
||||
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 }: {
|
||||
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;
|
||||
}) {
|
||||
const state = conversation.unread ? 'Unread' : conversation.isFromUs ? 'Awaiting reply' : 'Up to date';
|
||||
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 (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 (
|
||||
<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-label={`${redditUsernameLabel(conversation.username)}, ${state}, ${relativeTime}${conversation.sellerDetected ? ', Seller' : ''}${conversation.hasReceivedImages ? ', Image received' : ''}`}
|
||||
aria-pressed={isSelected}
|
||||
className="conversation-list-target"
|
||||
aria-current={isSelected || undefined}
|
||||
aria-label={descriptors.join(', ')}
|
||||
className="conversation-list-open"
|
||||
onClick={onSelect}
|
||||
ref={registerRef}
|
||||
type="button"
|
||||
/>
|
||||
<span className="conversation-list-heading">
|
||||
<strong><RedditUsernameLink username={conversation.username} /></strong>
|
||||
<span className="conversation-list-meta">
|
||||
<small>{state}</small>
|
||||
<time dateTime={new Date(conversation.lastMessageTime).toISOString()} title={formatExactTime(conversation.lastMessageTime)}>
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConversationsPage({ selectedConversationId, onSelectedConversationChange, filters, onFiltersChange }: ConversationsPageProps) {
|
||||
export function ConversationsPage({
|
||||
selectedConversationId, onSelectedConversationChange, filters, onFiltersChange,
|
||||
}: ConversationsPageProps) {
|
||||
const conversationsQuery = useConversationsQuery();
|
||||
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 [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);
|
||||
@@ -95,28 +168,54 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
||||
detailPanelRef.current?.scrollIntoView?.({ behavior: 'smooth', block: 'start' });
|
||||
}, [activeConversationId]);
|
||||
|
||||
function updateFilters(next: ConversationFilters) {
|
||||
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(() => {
|
||||
const normalizedSearch = activeFilters.search.trim().toLowerCase();
|
||||
return (conversationsQuery.data ?? [])
|
||||
.filter((conversation) => {
|
||||
const matchesSearch = normalizedSearch.length === 0
|
||||
|| conversation.username.toLowerCase().includes(normalizedSearch)
|
||||
|| conversation.lastMessageSnippet.toLowerCase().includes(normalizedSearch);
|
||||
return matchesSearch
|
||||
&& matchesFilter(conversation, activeFilters.filter)
|
||||
&& (activeFilters.showSellers || conversation.sellerDetected !== true);
|
||||
})
|
||||
.sort((left, right) => right.lastMessageTime - left.lastMessageTime);
|
||||
}, [activeFilters, conversationsQuery.data]);
|
||||
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 unreadCount = useMemo(
|
||||
() => (conversationsQuery.data ?? []).filter((conversation) => conversation.unread).length,
|
||||
[conversationsQuery.data],
|
||||
const pendingCount = liveConversations.length - visibleConversations.length;
|
||||
|
||||
const visibleIds = useMemo(
|
||||
() => visibleConversations.map((conversation) => ownedConversationId(conversation)),
|
||||
[visibleConversations],
|
||||
);
|
||||
|
||||
function selectConversation(id: string | null) {
|
||||
@@ -124,6 +223,121 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
||||
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">
|
||||
@@ -142,7 +356,9 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
||||
>
|
||||
{conversationsQuery.isFetching ? 'Refreshing…' : 'Refresh'}
|
||||
</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>
|
||||
<button className="conversation-primary" onClick={() => setIsStartingConversation((current) => !current)} type="button">
|
||||
{isStartingConversation ? 'Close new conversation' : 'New conversation'}
|
||||
@@ -165,30 +381,107 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
||||
<input
|
||||
id="conversation-search"
|
||||
onChange={(event) => updateFilters({ ...activeFilters, search: event.target.value })}
|
||||
placeholder="Search conversations"
|
||||
placeholder="Search conversations (/)"
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={activeFilters.search}
|
||||
/>
|
||||
<label className="sr-only" htmlFor="conversation-filter">Filter conversations</label>
|
||||
<select
|
||||
id="conversation-filter"
|
||||
onChange={(event) => updateFilters({ ...activeFilters, filter: event.target.value as ConversationFilter })}
|
||||
value={activeFilters.filter}
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="unread">Unread</option>
|
||||
<option value="awaiting-reply">Awaiting reply</option>
|
||||
<option value="received-images">Received images</option>
|
||||
</select>
|
||||
<span className="conversation-unread-count">{unreadCount} unread</span>
|
||||
<label className="conversation-seller-toggle">
|
||||
<input
|
||||
checked={activeFilters.showSellers}
|
||||
onChange={(event) => updateFilters({ ...activeFilters, showSellers: event.target.checked })}
|
||||
type="checkbox"
|
||||
/>
|
||||
Show sellers
|
||||
</label>
|
||||
<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>
|
||||
@@ -201,19 +494,45 @@ export function ConversationsPage({ selectedConversationId, onSelectedConversati
|
||||
) : 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></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) => (
|
||||
<ConversationListItem
|
||||
conversation={conversation}
|
||||
isSelected={ownedConversationId(conversation) === activeConversationId}
|
||||
key={ownedConversationId(conversation)}
|
||||
now={now}
|
||||
onSelect={() => selectConversation(ownedConversationId(conversation))}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<>
|
||||
<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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user