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

@@ -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}