Conversations were hard to manage because "unread" came straight from
Matrix's notification_count, and nothing in the app ever sends a read
receipt — so opening a conversation in the dashboard could never clear it
and the unread set only ever grew. The list also collapsed three
orthogonal facts into one word, labelling "they spoke last and we have
not replied" as "Up to date".
Read state is now ours:
- conversation_read_state table (migration 19) plus
ConversationReadStateRepository, kept separate from conversation_states
so a read write can never be lost to mergeConversationState. The
watermark is monotonic, so a stale poll cannot resurrect an unread
badge.
- hasUnreadInbound and needsReply are derived server-side and returned on
every conversation. Matrix's `unread` stays on the contract but the
dashboard no longer reads it.
- POST /conversations/{id}/read|unread|status and
POST /conversations/bulk-read, with optimistic cache updates.
Inbox rework:
- List rows are a real grid (checkbox, open button, chip strip) instead of
a full-bleed absolute overlay button, which previously forced a
pointer-events escape hatch for anything interactive in a row. Rows use
aria-current rather than aria-pressed. Not a listbox: options must not
contain interactive descendants.
- Counted filter pills replace the select, independent state chips replace
the collapsed status word, plus a sort control (including oldest
unanswered), a per-account filter, age-coloured timestamps, and an
explicit count of rows hidden by the seller filter.
- Keyboard: j/k/arrows, Enter, u, e, / and Escape. Shortcuts yield to any
open dialog so Escape dismisses the modal, not the conversation behind
it. Multi-select with bulk mark-read and mark-done.
- Detail panel: auto-mark-read on open, a "New messages" divider pinned at
mount (deriving it from the live watermark made it vanish a second after
opening), triage actions in the header, the automation banner moved out
of the composer, day dividers with time-only stamps, and a flex column
so history scrolls in its own region instead of painting over the
composer.
- Row order freezes while a conversation is open, with a "N new — refresh"
pill, so a background poll cannot reorder rows mid-triage.
Two database tests rewound user_version by an offset from the latest
version, which silently re-targets a different migration whenever one is
appended. They now pin absolute versions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
233 lines
9.2 KiB
TypeScript
233 lines
9.2 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import type { ActiveChat } from '../packages/api-contracts/src/index.js';
|
|
import {
|
|
DEFAULT_CONVERSATION_FILTERS,
|
|
accountOptions,
|
|
filterCounts,
|
|
isConversationFilter,
|
|
isConversationSort,
|
|
isUnread,
|
|
isWaitingOnThem,
|
|
matchesFilter,
|
|
matchesSearch,
|
|
needsReply,
|
|
partitionConversations,
|
|
triageStatus,
|
|
type ConversationFilters,
|
|
} from '../apps/dashboard/src/features/conversations/conversation-triage.js';
|
|
|
|
function chat(overrides: Partial<ActiveChat> & { username: string }): ActiveChat {
|
|
return {
|
|
roomName: overrides.username,
|
|
roomId: `room-${overrides.username}`,
|
|
lastMessageTime: 1_000,
|
|
lastMessageSnippet: `snippet for ${overrides.username}`,
|
|
isFromUs: false,
|
|
unread: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function filters(overrides: Partial<ConversationFilters> = {}): ConversationFilters {
|
|
return { ...DEFAULT_CONVERSATION_FILTERS, ...overrides };
|
|
}
|
|
|
|
describe('conversation triage', () => {
|
|
describe('derived state', () => {
|
|
it('prefers application read state over the Matrix unread flag', () => {
|
|
expect(isUnread(chat({ username: 'a', unread: true, hasUnreadInbound: false }))).toBe(false);
|
|
expect(isUnread(chat({ username: 'a', unread: false, hasUnreadInbound: true }))).toBe(true);
|
|
});
|
|
|
|
it('falls back to the Matrix flag when the server sends no read state', () => {
|
|
expect(isUnread(chat({ username: 'a', unread: true }))).toBe(true);
|
|
expect(isUnread(chat({ username: 'a', unread: false }))).toBe(false);
|
|
});
|
|
|
|
it('treats an inbound newest message on an open conversation as needing a reply', () => {
|
|
expect(needsReply(chat({ username: 'a', isFromUs: false }))).toBe(true);
|
|
expect(needsReply(chat({ username: 'a', isFromUs: true }))).toBe(false);
|
|
});
|
|
|
|
it('stops a resolved or snoozed conversation from needing a reply', () => {
|
|
expect(needsReply(chat({ username: 'a', triageStatus: 'done' }))).toBe(false);
|
|
expect(needsReply(chat({ username: 'a', triageStatus: 'snoozed' }))).toBe(false);
|
|
});
|
|
|
|
it('separates waiting-on-them from needing a reply', () => {
|
|
const waiting = chat({ username: 'a', isFromUs: true });
|
|
expect(isWaitingOnThem(waiting)).toBe(true);
|
|
expect(needsReply(waiting)).toBe(false);
|
|
});
|
|
|
|
it('defaults a conversation with no stored state to open', () => {
|
|
expect(triageStatus(chat({ username: 'a' }))).toBe('open');
|
|
});
|
|
});
|
|
|
|
describe('filters', () => {
|
|
const open = chat({ username: 'open', hasUnreadInbound: true });
|
|
const replied = chat({ username: 'replied', isFromUs: true });
|
|
const snoozed = chat({ username: 'snoozed', triageStatus: 'snoozed' });
|
|
const done = chat({ username: 'done', triageStatus: 'done' });
|
|
const withImage = chat({ username: 'image', hasReceivedImages: true });
|
|
|
|
it('excludes snoozed and resolved conversations from the default view', () => {
|
|
expect(matchesFilter(open, 'all')).toBe(true);
|
|
expect(matchesFilter(replied, 'all')).toBe(true);
|
|
expect(matchesFilter(snoozed, 'all')).toBe(false);
|
|
expect(matchesFilter(done, 'all')).toBe(false);
|
|
});
|
|
|
|
it('keeps snoozed and resolved conversations reachable through their own buckets', () => {
|
|
expect(matchesFilter(snoozed, 'snoozed')).toBe(true);
|
|
expect(matchesFilter(done, 'done')).toBe(true);
|
|
});
|
|
|
|
it('scopes unread and image buckets to open conversations', () => {
|
|
expect(matchesFilter(open, 'unread')).toBe(true);
|
|
expect(matchesFilter(chat({ username: 'x', hasUnreadInbound: true, triageStatus: 'done' }), 'unread')).toBe(false);
|
|
expect(matchesFilter(withImage, 'received-images')).toBe(true);
|
|
expect(matchesFilter(chat({ username: 'y', hasReceivedImages: true, triageStatus: 'done' }), 'received-images')).toBe(false);
|
|
});
|
|
|
|
it('matches search against username, snippet and loaded message text', () => {
|
|
const conversation = chat({
|
|
username: 'alice',
|
|
lastMessageSnippet: 'about the weather',
|
|
messages: [{ sender: 'alice', text: 'I live in Bristol', timestamp: 1, isSelf: false }],
|
|
});
|
|
expect(matchesSearch(conversation, 'ALI')).toBe(true);
|
|
expect(matchesSearch(conversation, 'weather')).toBe(true);
|
|
expect(matchesSearch(conversation, 'bristol')).toBe(true);
|
|
expect(matchesSearch(conversation, 'nothing')).toBe(false);
|
|
expect(matchesSearch(conversation, ' ')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('partitionConversations', () => {
|
|
it('sorts newest first by default', () => {
|
|
const result = partitionConversations([
|
|
chat({ username: 'old', lastMessageTime: 100 }),
|
|
chat({ username: 'new', lastMessageTime: 300 }),
|
|
chat({ username: 'mid', lastMessageTime: 200 }),
|
|
], filters());
|
|
|
|
expect(result.visible.map((item) => item.username)).toEqual(['new', 'mid', 'old']);
|
|
});
|
|
|
|
it('puts the longest-waiting unanswered conversation first when sorting by oldest unanswered', () => {
|
|
const result = partitionConversations([
|
|
chat({ username: 'recent-inbound', lastMessageTime: 300 }),
|
|
chat({ username: 'replied-old', lastMessageTime: 50, isFromUs: true }),
|
|
chat({ username: 'stale-inbound', lastMessageTime: 100 }),
|
|
], filters({ sort: 'oldest-unanswered' }));
|
|
|
|
// Unanswered first, oldest of those leading; everything else newest-first.
|
|
expect(result.visible.map((item) => item.username))
|
|
.toEqual(['stale-inbound', 'recent-inbound', 'replied-old']);
|
|
});
|
|
|
|
it('reports how many rows the seller filter removed', () => {
|
|
const result = partitionConversations([
|
|
chat({ username: 'alice' }),
|
|
chat({ username: 'seller-one', sellerDetected: true }),
|
|
chat({ username: 'seller-two', sellerDetected: true }),
|
|
], filters());
|
|
|
|
expect(result.visible.map((item) => item.username)).toEqual(['alice']);
|
|
expect(result.hiddenSellerCount).toBe(2);
|
|
});
|
|
|
|
it('includes sellers without counting them as hidden once revealed', () => {
|
|
const result = partitionConversations([
|
|
chat({ username: 'seller', sellerDetected: true }),
|
|
], filters({ showSellers: true }));
|
|
|
|
expect(result.visible).toHaveLength(1);
|
|
expect(result.hiddenSellerCount).toBe(0);
|
|
});
|
|
|
|
it('narrows to a single owning account', () => {
|
|
const result = partitionConversations([
|
|
chat({ username: 'a', owner: { accountId: 'one', redditUsername: 'bot-one' } }),
|
|
chat({ username: 'b', owner: { accountId: 'two', redditUsername: 'bot-two' } }),
|
|
], filters({ account: 'two' }));
|
|
|
|
expect(result.visible.map((item) => item.username)).toEqual(['b']);
|
|
});
|
|
|
|
it('does not mutate the input array order', () => {
|
|
const input = [
|
|
chat({ username: 'old', lastMessageTime: 100 }),
|
|
chat({ username: 'new', lastMessageTime: 300 }),
|
|
];
|
|
partitionConversations(input, filters());
|
|
|
|
expect(input.map((item) => item.username)).toEqual(['old', 'new']);
|
|
});
|
|
});
|
|
|
|
describe('filterCounts', () => {
|
|
it('counts each bucket independently', () => {
|
|
const counts = filterCounts([
|
|
chat({ username: 'unread-inbound', hasUnreadInbound: true }),
|
|
chat({ username: 'read-inbound', hasUnreadInbound: false }),
|
|
chat({ username: 'replied', isFromUs: true }),
|
|
chat({ username: 'snoozed', triageStatus: 'snoozed' }),
|
|
chat({ username: 'done', triageStatus: 'done' }),
|
|
], filters());
|
|
|
|
expect(counts.all).toBe(3);
|
|
expect(counts.unread).toBe(1);
|
|
expect(counts['needs-reply']).toBe(2);
|
|
expect(counts['awaiting-reply']).toBe(1);
|
|
expect(counts.snoozed).toBe(1);
|
|
expect(counts.done).toBe(1);
|
|
});
|
|
|
|
it('respects the search term so a pill never promises rows the list will not show', () => {
|
|
const counts = filterCounts([
|
|
chat({ username: 'alice', hasUnreadInbound: true }),
|
|
chat({ username: 'bob', hasUnreadInbound: true }),
|
|
], filters({ search: 'alice' }));
|
|
|
|
expect(counts.unread).toBe(1);
|
|
expect(counts.all).toBe(1);
|
|
});
|
|
|
|
it('excludes hidden sellers from the counts', () => {
|
|
const counts = filterCounts([
|
|
chat({ username: 'alice', hasUnreadInbound: true }),
|
|
chat({ username: 'seller', hasUnreadInbound: true, sellerDetected: true }),
|
|
], filters());
|
|
|
|
expect(counts.unread).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('accountOptions', () => {
|
|
it('lists distinct owners sorted by username', () => {
|
|
expect(accountOptions([
|
|
chat({ username: 'a', owner: { accountId: 'two', redditUsername: 'zoe' } }),
|
|
chat({ username: 'b', owner: { accountId: 'one', redditUsername: 'adam' } }),
|
|
chat({ username: 'c', owner: { accountId: 'one', redditUsername: 'adam' } }),
|
|
chat({ username: 'd' }),
|
|
])).toEqual([
|
|
{ accountId: 'one', redditUsername: 'adam' },
|
|
{ accountId: 'two', redditUsername: 'zoe' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('query-parameter guards', () => {
|
|
it('accepts only known filter and sort values', () => {
|
|
expect(isConversationFilter('needs-reply')).toBe(true);
|
|
expect(isConversationFilter('bogus')).toBe(false);
|
|
expect(isConversationFilter(null)).toBe(false);
|
|
expect(isConversationSort('oldest-unanswered')).toBe(true);
|
|
expect(isConversationSort('bogus')).toBe(false);
|
|
});
|
|
});
|
|
});
|