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