Make conversation queues mutually exclusive
This commit is contained in:
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>
|
||||
|
||||
Reference in new issue
Block a user