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