import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js'; /** * 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 = | 'needs-reply' | 'awaiting-reply' | 'sellers' | 'account-deleted' | 'archived' | 'snoozed' | 'done'; export type ConversationSort = 'recent' | 'oldest-unanswered'; export interface ConversationFilters { search: string; filter: ConversationFilter; unreadOnly: boolean; manualOnly: boolean; imagesOnly: boolean; sort: ConversationSort; /** Reddit account id to narrow to, or `all`. */ account: string; } export const DEFAULT_CONVERSATION_FILTERS: ConversationFilters = { search: '', filter: 'needs-reply', unreadOnly: false, manualOnly: false, imagesOnly: false, sort: 'recent', account: 'all', }; export const CONVERSATION_FILTERS: ReadonlyArray<{ value: ConversationFilter; label: string }> = [ { 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 }> = [ { value: 'recent', label: 'Newest activity' }, { value: 'oldest-unanswered', label: 'Oldest unanswered' }, ]; export function isConversationFilter(value: string | null): value is ConversationFilter { return CONVERSATION_FILTERS.some((option) => option.value === value); } export function isConversationSort(value: string | null): value is ConversationSort { return CONVERSATION_SORTS.some((option) => option.value === value); } export function triageStatus(conversation: ActiveChat): 'open' | 'snoozed' | 'done' { return conversation.triageStatus ?? 'open'; } /** * True when the newest message is inbound and newer than the read watermark. * * Falls back to Matrix's `unread` only when the server has not supplied read * state at all, so an older backend keeps behaving as before rather than * showing an inbox with nothing unread. */ export function isUnread(conversation: ActiveChat): boolean { if (conversation.hasUnreadInbound !== undefined) return conversation.hasUnreadInbound; return conversation.unread; } export function needsReply(conversation: ActiveChat): boolean { if (conversation.recipientAccountStatus === 'deleted') return false; if (conversation.needsReply !== undefined) return conversation.needsReply; return !conversation.isFromUs && triageStatus(conversation) === 'open'; } /** We have replied and are waiting on them. */ export function isWaitingOnThem(conversation: ActiveChat): boolean { return conversationQueue(conversation) === 'awaiting-reply'; } /** * True when the reply loop is no longer sending on its own, so every further * message has to be written by an operator: either the subreddit's policy was * manual from the start, or automation handed the conversation over (a manual * reply was sent, an image arrived, a boundary or turn limit was hit). Terminal * engagement successes and killed conversations are completed, not manual. * * Conversations with no recorded automation state are not manual — they have * simply never been through the reply loop. */ export function isManualFlow(conversation: ActiveChat): boolean { if (conversation.automationPolicyMode === 'manual') return true; return conversation.automationStatus !== undefined && conversation.automationStatus !== 'active' && conversation.automationStatus !== 'engaged' && conversation.automationStatus !== 'killed'; } /** Short label for why a conversation is being handled by hand. */ export function manualFlowLabel(conversation: ActiveChat): string | null { if (!isManualFlow(conversation)) return null; if (conversation.automationPolicyMode === 'manual' && conversation.automationStatus === 'active') { return 'Manual by policy'; } switch (conversation.automationStatus) { case 'user-overridden': return 'Manual — you replied'; case 'image-received': return 'Manual — image received'; case 'boundary': return 'Manual — boundary'; case 'human-required': return 'Manual — handed over'; default: return 'Manual'; } } /** * 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); 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 { const normalized = search.trim().toLowerCase(); if (normalized.length === 0) return true; if (conversation.username.toLowerCase().includes(normalized)) return true; if (conversation.lastMessageSnippet.toLowerCase().includes(normalized)) return true; // Message text is only present for conversations whose history has been // loaded, so this is a bonus rather than a guarantee. return (conversation.messages ?? []).some((message) => message.text.toLowerCase().includes(normalized)); } function matchesAccount(conversation: ActiveChat, account: string): boolean { return account === 'all' || conversation.owner?.accountId === account; } export interface ConversationListPartition { /** Conversations passing every active filter, in the requested order. */ visible: ActiveChat[]; } function matchesSecondaryFilters(conversation: ActiveChat, filters: ConversationFilters): boolean { return (!filters.unreadOnly || isUnread(conversation)) && (!filters.manualOnly || isManualFlow(conversation)) && (!filters.imagesOnly || conversation.hasReceivedImages === true); } /** * `oldest-unanswered` puts the longest-waiting conversation that needs a reply * first — the order you actually want when draining a queue — and keeps * everything else in newest-first order behind it. */ function compareConversations(sort: ConversationSort) { return (left: ActiveChat, right: ActiveChat): number => { if (sort === 'oldest-unanswered') { const leftNeeds = needsReply(left); const rightNeeds = needsReply(right); if (leftNeeds !== rightNeeds) return leftNeeds ? -1 : 1; if (leftNeeds && rightNeeds) return left.lastMessageTime - right.lastMessageTime; } return right.lastMessageTime - left.lastMessageTime; }; } export function partitionConversations( conversations: readonly ActiveChat[], filters: ConversationFilters, ): ConversationListPartition { 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; return matchesSecondaryFilters(conversation, filters); }); return { visible: visible.sort(compareConversations(filters.sort)) }; } /** * Per-bucket counts for the filter pills. Counts respect search, account and * the seller toggle so a pill never promises rows the list will not show. */ export function filterCounts( conversations: readonly ActiveChat[], filters: ConversationFilters, ): Record { const candidates = conversations.filter((conversation) => matchesSearch(conversation, filters.search) && matchesAccount(conversation, filters.account) && matchesSecondaryFilters(conversation, filters)); const counts = {} as Record; for (const { value } of CONVERSATION_FILTERS) { counts[value] = candidates.filter((conversation) => matchesFilter(conversation, value)).length; } return counts; } export interface ConversationAccountOption { accountId: string; redditUsername: string; } /** Distinct owning accounts present in the list, for the account switcher. */ export function accountOptions(conversations: readonly ActiveChat[]): ConversationAccountOption[] { const seen = new Map(); for (const conversation of conversations) { if (conversation.owner) seen.set(conversation.owner.accountId, conversation.owner.redditUsername); } return [...seen] .map(([accountId, redditUsername]) => ({ accountId, redditUsername })) .sort((left, right) => left.redditUsername.localeCompare(right.redditUsername)); } export const SNOOZE_OPTIONS: ReadonlyArray<{ label: string; ms: number }> = [ { label: '1 hour', ms: 60 * 60_000 }, { label: 'Tomorrow', ms: 24 * 60 * 60_000 }, { label: '3 days', ms: 3 * 24 * 60 * 60_000 }, { label: '1 week', ms: 7 * 24 * 60 * 60_000 }, ];