Make conversation queues mutually exclusive

This commit is contained in:
Jamie Brunton committed 2026-08-14 09:25:45 +01:00
1 parent 3bb1dda6bb
commit 2447563c60
20 files changed
+386 -220

No files matched your search

@@ -15,7 +15,9 @@ export function ConversationRoute() {
const sortValue = searchParams.get('sort');
const filters: ConversationFilters = {
search: searchParams.get('q') ?? '',
showSellers: searchParams.get('showSellers') === 'true',
unreadOnly: searchParams.get('unread') === 'true',
manualOnly: searchParams.get('manual') === 'true',
imagesOnly: searchParams.get('images') === 'true',
filter: isConversationFilter(filterValue) ? filterValue : DEFAULT_CONVERSATION_FILTERS.filter,
sort: isConversationSort(sortValue) ? sortValue : DEFAULT_CONVERSATION_FILTERS.sort,
account: searchParams.get('account') ?? DEFAULT_CONVERSATION_FILTERS.account,
@@ -33,8 +35,10 @@ export function ConversationRoute() {
function updateFilters(next: ConversationFilters) {
const params = new URLSearchParams(searchParams);
if (next.search) params.set('q', next.search); else params.delete('q');
if (next.filter !== 'all') params.set('filter', next.filter); else params.delete('filter');
if (next.showSellers) params.set('showSellers', 'true'); else params.delete('showSellers');
if (next.filter !== DEFAULT_CONVERSATION_FILTERS.filter) params.set('filter', next.filter); else params.delete('filter');
if (next.unreadOnly) params.set('unread', 'true'); else params.delete('unread');
if (next.manualOnly) params.set('manual', 'true'); else params.delete('manual');
if (next.imagesOnly) params.set('images', 'true'); else params.delete('images');
if (next.sort !== DEFAULT_CONVERSATION_FILTERS.sort) params.set('sort', next.sort); else params.delete('sort');
if (next.account !== 'all') params.set('account', next.account); else params.delete('account');
setSearchParams(params, { replace: true });
@@ -1,21 +1,16 @@
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
/**
* Inbox buckets. These are the operator's actual work queues, derived from
* application read state rather than Matrix's unread count.
*
* `all` deliberately excludes resolved and snoozed conversations — the default
* view is "what is still on my plate", and non-actionable conversations have
* dedicated pills so nothing becomes unreachable.
* Mutually exclusive inbox workflow queues. Read state, manual ownership, and
* images are secondary filters so they cannot make a row appear to occupy two
* competing workflow states.
*/
export type ConversationFilter =
| 'all'
| 'needs-reply'
| 'unread'
| 'awaiting-reply'
| 'manual'
| 'received-images'
| 'sellers'
| 'account-deleted'
| 'archived'
| 'snoozed'
| 'done';
@@ -24,7 +19,9 @@ export type ConversationSort = 'recent' | 'oldest-unanswered';
export interface ConversationFilters {
search: string;
filter: ConversationFilter;
showSellers: boolean;
unreadOnly: boolean;
manualOnly: boolean;
imagesOnly: boolean;
sort: ConversationSort;
/** Reddit account id to narrow to, or `all`. */
account: string;
@@ -32,22 +29,22 @@ export interface ConversationFilters {
export const DEFAULT_CONVERSATION_FILTERS: ConversationFilters = {
search: '',
filter: 'all',
showSellers: false,
filter: 'needs-reply',
unreadOnly: false,
manualOnly: false,
imagesOnly: false,
sort: 'recent',
account: 'all',
};
export const CONVERSATION_FILTERS: ReadonlyArray<{ value: ConversationFilter; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'needs-reply', label: 'Needs reply' },
{ value: 'unread', label: 'Unread' },
{ value: 'awaiting-reply', label: 'Waiting on them' },
{ value: 'manual', label: 'Manual' },
{ value: 'received-images', label: 'Images' },
{ value: 'account-deleted', label: 'Account deleted' },
{ value: 'needs-reply', label: 'Needs attention' },
{ value: 'awaiting-reply', label: 'Waiting' },
{ value: 'sellers', label: 'Sellers' },
{ value: 'snoozed', label: 'Snoozed' },
{ value: 'done', label: 'Done' },
{ value: 'account-deleted', label: 'Deleted' },
{ value: 'archived', label: 'Archived' },
];
export const CONVERSATION_SORTS: ReadonlyArray<{ value: ConversationSort; label: string }> = [
@@ -87,9 +84,7 @@ export function needsReply(conversation: ActiveChat): boolean {
/** We have replied and are waiting on them. */
export function isWaitingOnThem(conversation: ActiveChat): boolean {
return conversation.recipientAccountStatus !== 'deleted'
&& conversation.isFromUs
&& triageStatus(conversation) === 'open';
return conversationQueue(conversation) === 'awaiting-reply';
}
/**
@@ -103,7 +98,9 @@ export function isWaitingOnThem(conversation: ActiveChat): boolean {
*/
export function isManualFlow(conversation: ActiveChat): boolean {
if (conversation.automationPolicyMode === 'manual') return true;
return conversation.automationStatus !== undefined && conversation.automationStatus !== 'active';
return conversation.automationStatus !== undefined
&& conversation.automationStatus !== 'active'
&& conversation.automationStatus !== 'killed';
}
/** Short label for why a conversation is being handled by hand. */
@@ -113,8 +110,6 @@ export function manualFlowLabel(conversation: ActiveChat): string | null {
return 'Manual by policy';
}
switch (conversation.automationStatus) {
case 'killed':
return 'Killed — seller';
case 'user-overridden':
return 'Manual — you replied';
case 'image-received':
@@ -128,29 +123,23 @@ export function manualFlowLabel(conversation: ActiveChat): string | null {
}
}
export function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
if (conversation.recipientAccountStatus === 'deleted') return filter === 'account-deleted';
/**
* One authoritative workflow queue per conversation. Data-ownership and
* terminal safety outcomes take precedence over message direction so archived,
* deleted, and seller rows can never leak into actionable queues.
*/
export function conversationQueue(conversation: ActiveChat): ConversationFilter {
if (conversation.readOnly) return 'archived';
if (conversation.recipientAccountStatus === 'deleted') return 'account-deleted';
if (conversation.sellerDetected === true || conversation.automationStatus === 'killed') return 'sellers';
const status = triageStatus(conversation);
switch (filter) {
case 'needs-reply':
return needsReply(conversation);
case 'unread':
return status === 'open' && isUnread(conversation);
case 'awaiting-reply':
return isWaitingOnThem(conversation);
case 'manual':
return status === 'open' && isManualFlow(conversation);
case 'received-images':
return status === 'open' && conversation.hasReceivedImages === true;
case 'account-deleted':
return false;
case 'snoozed':
return status === 'snoozed';
case 'done':
return status === 'done';
default:
return status === 'open';
}
if (status === 'snoozed') return 'snoozed';
if (status === 'done') return 'done';
return needsReply(conversation) ? 'needs-reply' : 'awaiting-reply';
}
export function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
return conversationQueue(conversation) === filter;
}
export function matchesSearch(conversation: ActiveChat, search: string): boolean {
@@ -170,8 +159,12 @@ function matchesAccount(conversation: ActiveChat, account: string): boolean {
export interface ConversationListPartition {
/** Conversations passing every active filter, in the requested order. */
visible: ActiveChat[];
/** How many were removed purely by the seller toggle, so the UI can say so. */
hiddenSellerCount: number;
}
function matchesSecondaryFilters(conversation: ActiveChat, filters: ConversationFilters): boolean {
return (!filters.unreadOnly || isUnread(conversation))
&& (!filters.manualOnly || isManualFlow(conversation))
&& (!filters.imagesOnly || conversation.hasReceivedImages === true);
}
/**
@@ -195,18 +188,13 @@ export function partitionConversations(
conversations: readonly ActiveChat[],
filters: ConversationFilters,
): ConversationListPartition {
let hiddenSellerCount = 0;
const visible = conversations.filter((conversation) => {
if (!matchesSearch(conversation, filters.search)) return false;
if (!matchesFilter(conversation, filters.filter)) return false;
if (!matchesAccount(conversation, filters.account)) return false;
if (!filters.showSellers && conversation.sellerDetected === true) {
hiddenSellerCount += 1;
return false;
}
return true;
return matchesSecondaryFilters(conversation, filters);
});
return { visible: visible.sort(compareConversations(filters.sort)), hiddenSellerCount };
return { visible: visible.sort(compareConversations(filters.sort)) };
}
/**
@@ -220,7 +208,7 @@ export function filterCounts(
const candidates = conversations.filter((conversation) =>
matchesSearch(conversation, filters.search)
&& matchesAccount(conversation, filters.account)
&& (filters.showSellers || conversation.sellerDetected !== true));
&& matchesSecondaryFilters(conversation, filters));
const counts = {} as Record<ConversationFilter, number>;
for (const { value } of CONVERSATION_FILTERS) {
counts[value] = candidates.filter((conversation) => matchesFilter(conversation, value)).length;
@@ -21,12 +21,12 @@ import {
CONVERSATION_SORTS,
DEFAULT_CONVERSATION_FILTERS,
accountOptions,
conversationQueue,
filterCounts,
isUnread,
manualFlowLabel,
needsReply,
partitionConversations,
triageStatus,
type ConversationFilter,
type ConversationFilters,
type ConversationSort,
@@ -87,16 +87,19 @@ function sellerScanSummary(result: SellerScanResponse): string {
return `${parts.join(' · ')}.`;
}
/** Independent state chips. A conversation can be several of these at once. */
/** One workflow chip plus independent presentation/ownership badges. */
function conversationChips(conversation: ActiveChat, now: number): ConversationChip[] {
const chips: ConversationChip[] = [];
if (conversation.recipientAccountStatus === 'deleted') {
chips.push({ label: 'Account deleted' });
return chips;
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' });
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({
@@ -107,10 +110,8 @@ function conversationChips(conversation: ActiveChat, now: number): ConversationC
: 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') {
if (conversationQueue(conversation) === 'needs-reply'
&& conversationAge(conversation.lastMessageTime, now) !== 'fresh') {
chips.push({ label: 'Overdue' });
}
return chips;
@@ -133,7 +134,6 @@ function ConversationRow({
redditUsernameLabel(conversation.username),
...chips.map((chip) => chip.hint ?? chip.label),
relativeTime,
...(conversation.sellerDetected ? ['Seller'] : []),
...(conversation.hasReceivedImages ? ['Image received'] : []),
...(conversation.readOnly ? ['Removed account, read-only'] : []),
];
@@ -186,7 +186,6 @@ function ConversationRow({
Read-only
</span>
) : 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>
@@ -235,7 +234,7 @@ export function ConversationsPage({
const allConversations = conversationsQuery.data ?? [];
const { visible: liveConversations, hiddenSellerCount } = useMemo(
const { visible: liveConversations } = useMemo(
() => partitionConversations(allConversations, activeFilters),
[allConversations, activeFilters],
);
@@ -514,11 +513,27 @@ export function ConversationsPage({
) : null}
<label className="conversation-seller-toggle">
<input
checked={activeFilters.showSellers}
onChange={(event) => updateFilters({ ...activeFilters, showSellers: event.target.checked })}
checked={activeFilters.unreadOnly}
onChange={(event) => updateFilters({ ...activeFilters, unreadOnly: event.target.checked })}
type="checkbox"
/>
Show sellers
Unread only
</label>
<label className="conversation-seller-toggle">
<input
checked={activeFilters.manualOnly}
onChange={(event) => updateFilters({ ...activeFilters, manualOnly: event.target.checked })}
type="checkbox"
/>
Manual only
</label>
<label className="conversation-seller-toggle">
<input
checked={activeFilters.imagesOnly}
onChange={(event) => updateFilters({ ...activeFilters, imagesOnly: event.target.checked })}
type="checkbox"
/>
Has images
</label>
</div>
{visibleConversations.length > 0 ? (
@@ -577,9 +592,6 @@ export function ConversationsPage({
<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>
) : (
<>
@@ -605,9 +617,6 @@ export function ConversationsPage({
})}
</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>