feat: recover missing conversations, fix media attachment, add manual inbox tab

Missing conversations: recipient Reddit accounts that are deleted or suspended
left conversations unaccounted for. A new RecipientAccountStatusService polls
Reddit for the users behind cached conversations and records their account
status durably, with progressive backfill and delta-media fixes in ChatService
so history and images are no longer dropped mid-sync.

Media: attachMedia now surfaces download failures as MediaAttachmentError so the
sync cursor can be retried instead of silently losing an image, recognizes image
bytes when Matrix returns a generic content type, and keeps separate gallery
occurrences when the same asset appears in more than one event.

Manual inbox tab: the conversations list now carries the reply-loop status, so
the inbox can separate conversations the operator is driving by hand from the
ones automation is still handling. A Manual filter pill and a per-row chip
explain why each conversation was handed over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 5 committed 2026-08-10 11:03:33 +01:00
1 parent 725b5fc4e6
commit ac12a3146a
35 files changed
+1164 -129

No files matched your search

@@ -573,6 +573,9 @@ export function ConversationDetail({ conversationId, onClose }: ConversationDeta
<span className="conversation-detail-badges">
<AccountOwnerBadge owner={conversation.owner} />
{conversation.sellerDetected ? <span className="conversation-seller-marker">Seller</span> : null}
{conversation.recipientAccountStatus === 'deleted' ? (
<span className="conversation-badge is-done">Account deleted</span>
) : null}
{isUnread(conversation) ? <span className="conversation-badge">Unread</span> : null}
{status === 'snoozed' ? <span className="conversation-badge is-snoozed">Snoozed</span> : null}
{status === 'done' ? <span className="conversation-badge is-done">Done</span> : null}
@@ -5,15 +5,17 @@ import type { ActiveChat } from '../../../../../packages/api-contracts/src/index
* 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 the two terminal buckets have their
* own pills so nothing becomes unreachable.
* view is "what is still on my plate", and non-actionable conversations have
* dedicated pills so nothing becomes unreachable.
*/
export type ConversationFilter =
| 'all'
| 'needs-reply'
| 'unread'
| 'awaiting-reply'
| 'manual'
| 'received-images'
| 'account-deleted'
| 'snoozed'
| 'done';
@@ -41,7 +43,9 @@ export const CONVERSATION_FILTERS: ReadonlyArray<{ value: ConversationFilter; la
{ 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: 'snoozed', label: 'Snoozed' },
{ value: 'done', label: 'Done' },
];
@@ -76,16 +80,54 @@ export function isUnread(conversation: ActiveChat): boolean {
}
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 conversation.isFromUs && triageStatus(conversation) === 'open';
return conversation.recipientAccountStatus !== 'deleted'
&& conversation.isFromUs
&& triageStatus(conversation) === 'open';
}
/**
* 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';
}
/** 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';
}
}
export function matchesFilter(conversation: ActiveChat, filter: ConversationFilter): boolean {
if (conversation.recipientAccountStatus === 'deleted') return filter === 'account-deleted';
const status = triageStatus(conversation);
switch (filter) {
case 'needs-reply':
@@ -94,8 +136,12 @@ export function matchesFilter(conversation: ActiveChat, filter: ConversationFilt
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':
@@ -22,6 +22,7 @@ import {
accountOptions,
filterCounts,
isUnread,
manualFlowLabel,
needsReply,
partitionConversations,
triageStatus,
@@ -53,17 +54,41 @@ interface ConversationRowProps {
registerRef(element: HTMLButtonElement | null): void;
}
/**
* A chip's visible label, plus the longer form used for screen readers and the
* hover tooltip when the label alone does not say why the chip is there.
*/
interface ConversationChip {
label: string;
hint?: string;
tooltip?: string;
}
/** Independent state chips. A conversation can be several of these at once. */
function conversationChips(conversation: ActiveChat, now: number): string[] {
const chips: string[] = [];
if (isUnread(conversation)) chips.push('Unread');
if (needsReply(conversation)) chips.push('Needs reply');
else if (conversation.isFromUs && triageStatus(conversation) === 'open') chips.push('Waiting on them');
function conversationChips(conversation: ActiveChat, now: number): ConversationChip[] {
const chips: ConversationChip[] = [];
if (conversation.recipientAccountStatus === 'deleted') {
chips.push({ label: 'Account deleted' });
return chips;
}
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({
label: 'Manual',
hint: manualLabel,
tooltip: conversation.automationHandoffReason
? `${manualLabel} — ${conversation.automationHandoffReason}`
: manualLabel,
});
}
const status = triageStatus(conversation);
if (status === 'snoozed') chips.push('Snoozed');
if (status === 'done') chips.push('Done');
if (status === 'snoozed') chips.push({ label: 'Snoozed' });
if (status === 'done') chips.push({ label: 'Done' });
if (needsReply(conversation) && conversationAge(conversation.lastMessageTime, now) !== 'fresh') {
chips.push('Overdue');
chips.push({ label: 'Overdue' });
}
return chips;
}
@@ -82,7 +107,7 @@ function ConversationRow({
const descriptors = [
redditUsernameLabel(conversation.username),
...chips,
...chips.map((chip) => chip.hint ?? chip.label),
relativeTime,
...(conversation.sellerDetected ? ['Seller'] : []),
...(conversation.hasReceivedImages ? ['Image received'] : []),
@@ -122,8 +147,12 @@ function ConversationRow({
</button>
<span className="conversation-list-chips">
{chips.map((chip) => (
<span className={`conversation-chip is-${chip.toLowerCase().replaceAll(' ', '-')}`} key={chip}>
{chip}
<span
className={`conversation-chip is-${chip.label.toLowerCase().replaceAll(' ', '-')}`}
key={chip.label}
title={chip.tooltip}
>
{chip.label}
</span>
))}
{showOwner ? <AccountOwnerBadge owner={conversation.owner} /> : null}
@@ -86,8 +86,9 @@
.conversation-chip { padding: .1rem .4rem; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-raised); color: var(--muted); font-size: .68rem; font-weight: 750; }
.conversation-chip.is-unread { color: var(--accent); border-color: #28593d; background: var(--accent-dark); }
.conversation-chip.is-needs-reply { color: #cfe8ff; border-color: #315d85; background: #18324d; }
.conversation-chip.is-manual { color: #e7d8ff; border-color: #5a4285; background: #2c1f47; }
.conversation-chip.is-overdue { color: var(--warning-text); border-color: var(--warning-border); background: var(--warning-bg); }
.conversation-chip.is-snoozed, .conversation-chip.is-done { opacity: .85; }
.conversation-chip.is-snoozed, .conversation-chip.is-done, .conversation-chip.is-account-deleted { opacity: .85; }
.conversation-list-footer { display: flex; flex-wrap: wrap; justify-content: space-between; gap: .5rem; padding: .5rem .75rem .75rem; margin: 0; color: var(--muted); font-size: .72rem; }
.conversation-shortcut-hint { margin-left: auto; opacity: .8; }