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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user