Files
RedditBot/apps/dashboard/src/features/conversations/conversation-triage.ts
T

241 lines
9.2 KiB
TypeScript

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).
*
* 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 !== '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<ConversationFilter, number> {
const candidates = conversations.filter((conversation) =>
matchesSearch(conversation, filters.search)
&& matchesAccount(conversation, filters.account)
&& matchesSecondaryFilters(conversation, filters));
const counts = {} as Record<ConversationFilter, number>;
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<string, string>();
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 },
];