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>
578 lines
27 KiB
TypeScript
578 lines
27 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import '@testing-library/jest-dom/vitest';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { HttpResponse, http } from 'msw';
|
|
import { setupServer } from 'msw/node';
|
|
import { MemoryRouter } from 'react-router-dom';
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
import { ConversationsPage } from '../../apps/dashboard/src/features/conversations/conversations-page.js';
|
|
import { resetComposerDrafts } from '../../apps/dashboard/src/features/conversations/composer-drafts.js';
|
|
|
|
interface ChatFixtureOptions {
|
|
username: string;
|
|
roomId: string;
|
|
lastMessageTime?: number;
|
|
unread?: boolean;
|
|
isFromUs?: boolean;
|
|
lastMessageSnippet?: string;
|
|
hasReceivedImages?: boolean;
|
|
sellerDetected?: boolean;
|
|
}
|
|
|
|
function chatFixture(options: ChatFixtureOptions) {
|
|
return {
|
|
username: options.username,
|
|
roomName: options.username,
|
|
roomId: options.roomId,
|
|
url: `https://chat.reddit.com/room/${options.roomId}`,
|
|
lastMessageTime: options.lastMessageTime ?? 1_786_000_000_000,
|
|
lastMessageSnippet: options.lastMessageSnippet ?? `Latest from ${options.username}`,
|
|
isFromUs: options.isFromUs ?? false,
|
|
unread: options.unread ?? false,
|
|
...(options.hasReceivedImages === undefined ? {} : { hasReceivedImages: options.hasReceivedImages }),
|
|
...(options.sellerDetected === undefined ? {} : { sellerDetected: options.sellerDetected }),
|
|
};
|
|
}
|
|
|
|
const alice = chatFixture({ username: 'alice', roomId: 'room-alice', unread: true });
|
|
const bob = chatFixture({ username: 'bob', roomId: 'room-bob', isFromUs: true });
|
|
|
|
function detailFixture(chat = alice) {
|
|
return {
|
|
...chat,
|
|
messages: [
|
|
{ sender: 'me', text: 'Hello Alice', timestamp: 1_785_999_900_000, isSelf: true, isAi: true },
|
|
{ sender: 'alice', text: 'Good to hear from you', timestamp: 1_786_000_000_000, isSelf: false },
|
|
],
|
|
triggeringPost: {
|
|
id: 'post-1',
|
|
subreddit: 'casualconversation',
|
|
title: 'Looking for a chat',
|
|
url: 'https://reddit.com/r/casualconversation/comments/post-1',
|
|
imageUrl: 'https://i.redd.it/source-post.jpg',
|
|
postedAt: Date.now() - (2 * 60 * 60 * 1000),
|
|
sentAt: 1_785_999_800_000,
|
|
},
|
|
automation: { turnsUsed: 0, remainingTurns: 3, status: 'active', handoffReason: null },
|
|
syncInProgress: null,
|
|
};
|
|
}
|
|
|
|
const server = setupServer(
|
|
http.get('/api/accounts', () => HttpResponse.json([{
|
|
accountId: '@t2_bot:reddit.com', redditUsername: 'bot-account', matrixUserId: '@t2_bot:reddit.com',
|
|
isRunning: true, status: 'authenticated', createdAt: 1, updatedAt: 2, lastImportedAt: 2,
|
|
}])),
|
|
http.get('/api/conversations/:id/shareable-media', () => HttpResponse.json({
|
|
requestKind: null, assets: [], warning: null,
|
|
reason: 'No enabled, unused library images are available to share.',
|
|
})),
|
|
// Opening a conversation advances the read watermark.
|
|
http.post('/api/conversations/:id/read', () => HttpResponse.json({ success: true })),
|
|
http.post('/api/conversations/:id/unread', () => HttpResponse.json({ success: true })),
|
|
http.post('/api/conversations/:id/status', () => HttpResponse.json({ success: true })),
|
|
http.post('/api/conversations/bulk-read', () =>
|
|
HttpResponse.json({ success: true, updated: 1, failed: [] })),
|
|
);
|
|
|
|
function installConversationList() {
|
|
server.use(
|
|
http.get('/api/conversations', () => HttpResponse.json([alice, bob])),
|
|
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture())),
|
|
);
|
|
}
|
|
|
|
function renderConversations() {
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
});
|
|
render(
|
|
<MemoryRouter>
|
|
<QueryClientProvider client={queryClient}>
|
|
<ConversationsPage />
|
|
</QueryClientProvider>
|
|
</MemoryRouter>,
|
|
);
|
|
return queryClient;
|
|
}
|
|
|
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
|
|
afterEach(() => {
|
|
cleanup();
|
|
server.resetHandlers();
|
|
resetComposerDrafts();
|
|
});
|
|
afterAll(() => server.close());
|
|
|
|
describe('ConversationsPage', () => {
|
|
it('shows loading and a clear empty state', async () => {
|
|
server.use(http.get('/api/conversations', () => HttpResponse.json([])));
|
|
renderConversations();
|
|
|
|
expect(screen.getByRole('status', { name: 'Loading conversations' })).toBeInTheDocument();
|
|
expect(await screen.findByText('No conversations yet.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('recovers from a list error without replacing the page', async () => {
|
|
let requests = 0;
|
|
server.use(
|
|
http.get('/api/conversations', () => {
|
|
requests += 1;
|
|
if (requests === 1) return HttpResponse.json({ error: 'Chat service unavailable' }, { status: 503 });
|
|
return HttpResponse.json([alice]);
|
|
}),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
|
|
const alert = await screen.findByRole('alert');
|
|
expect(alert).toHaveTextContent('Chat service unavailable');
|
|
await user.click(within(alert).getByRole('button', { name: 'Retry conversations' }));
|
|
expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
|
|
});
|
|
|
|
it('searches and filters the master list, then opens conversation detail', async () => {
|
|
installConversationList();
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
|
|
expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
|
|
await user.type(screen.getByRole('searchbox', { name: 'Search conversations' }), 'bob');
|
|
expect(screen.queryByRole('button', { name: /u\/alice/ })).not.toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /u\/bob/ })).toBeInTheDocument();
|
|
|
|
await user.clear(screen.getByRole('searchbox', { name: 'Search conversations' }));
|
|
await user.click(screen.getByRole('button', { name: /^Unread/ }));
|
|
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: /u\/alice/ }));
|
|
expect(await screen.findByRole('heading', { name: 'u/alice' })).toBeInTheDocument();
|
|
expect(screen.getAllByRole('link', { name: 'u/alice' })).not.toHaveLength(0);
|
|
for (const profileLink of screen.getAllByRole('link', { name: 'u/alice' })) {
|
|
expect(profileLink).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
|
|
}
|
|
expect(screen.getByText('Good to hear from you')).toBeInTheDocument();
|
|
expect(screen.getByLabelText('Sent by AI')).toHaveTextContent('AI');
|
|
expect(screen.getByRole('link', { name: 'Looking for a chat' })).toHaveAttribute('href', expect.stringContaining('post-1'));
|
|
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveAttribute(
|
|
'src',
|
|
'https://i.redd.it/source-post.jpg',
|
|
);
|
|
expect(screen.getByText('2h ago')).toBeInTheDocument();
|
|
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveClass('is-blurred');
|
|
await user.click(screen.getByRole('button', { name: 'Reveal source image: Looking for a chat' }));
|
|
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).not.toHaveClass('is-blurred');
|
|
const previewTrigger = screen.getByRole('button', { name: 'View source image full screen: Looking for a chat' });
|
|
await user.click(previewTrigger);
|
|
const preview = screen.getByRole('dialog', { name: 'Source image: Looking for a chat' });
|
|
expect(within(preview).getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveAttribute(
|
|
'src',
|
|
'https://i.redd.it/source-post.jpg',
|
|
);
|
|
expect(within(preview).getByRole('button', { name: 'Close source image preview' })).toHaveFocus();
|
|
await user.keyboard('{Escape}');
|
|
expect(screen.queryByRole('dialog', { name: 'Source image: Looking for a chat' })).not.toBeInTheDocument();
|
|
expect(previewTrigger).toHaveFocus();
|
|
});
|
|
|
|
it('hides detected sellers by default and reveals marked conversations on request', async () => {
|
|
const seller = chatFixture({ username: 'seller', roomId: 'room-seller', sellerDetected: true });
|
|
server.use(http.get('/api/conversations', () => HttpResponse.json([alice, seller])));
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
|
|
expect(await screen.findByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: /u\/seller/ })).not.toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('checkbox', { name: 'Show sellers' }));
|
|
expect(screen.getByRole('button', { name: /u\/seller.*Seller/ })).toBeInTheDocument();
|
|
expect(screen.getByText('Seller')).toBeInTheDocument();
|
|
});
|
|
|
|
it('orders conversations by latest message activity', async () => {
|
|
const oldest = chatFixture({ username: 'oldest', roomId: 'room-oldest', lastMessageTime: 100 });
|
|
const newest = chatFixture({ username: 'newest', roomId: 'room-newest', lastMessageTime: 300 });
|
|
const middle = chatFixture({ username: 'middle', roomId: 'room-middle', lastMessageTime: 200 });
|
|
server.use(http.get('/api/conversations', () => HttpResponse.json([oldest, newest, middle])));
|
|
renderConversations();
|
|
|
|
await screen.findByRole('button', { name: /u\/newest/ });
|
|
expect(screen.getAllByRole('button', { name: /u\/(?:oldest|newest|middle)/ }).map((button) => button.getAttribute('aria-label')))
|
|
.toEqual([
|
|
expect.stringContaining('u/newest'),
|
|
expect.stringContaining('u/middle'),
|
|
expect.stringContaining('u/oldest'),
|
|
]);
|
|
});
|
|
|
|
it('shows short relative times for the latest message', async () => {
|
|
const now = Date.now();
|
|
const minutesAgo = chatFixture({ username: 'minutes', roomId: 'room-minutes', lastMessageTime: now - (5 * 60_000) });
|
|
const daysAgo = chatFixture({ username: 'days', roomId: 'room-days', lastMessageTime: now - (2 * 86_400_000) });
|
|
server.use(http.get('/api/conversations', () => HttpResponse.json([daysAgo, minutesAgo])));
|
|
renderConversations();
|
|
|
|
const recentTime = await screen.findByText('5m ago');
|
|
expect(recentTime.tagName).toBe('TIME');
|
|
expect(recentTime).toHaveAttribute('dateTime', new Date(minutesAgo.lastMessageTime).toISOString());
|
|
expect(recentTime).toHaveAttribute('title');
|
|
expect(screen.getByText('2d ago')).toBeInTheDocument();
|
|
});
|
|
|
|
it('marks and filters conversations with received images and renders the images in chat', async () => {
|
|
const imageChat = chatFixture({
|
|
username: 'alice',
|
|
roomId: 'room-alice',
|
|
hasReceivedImages: true,
|
|
lastMessageSnippet: '[Image]',
|
|
});
|
|
server.use(
|
|
http.get('/api/conversations', () => HttpResponse.json([imageChat, bob])),
|
|
http.get('/api/conversations/room-alice', () => HttpResponse.json({
|
|
...detailFixture(imageChat),
|
|
messages: [
|
|
{
|
|
sender: 'alice',
|
|
text: '[Image]',
|
|
timestamp: 1_786_000_000_000,
|
|
isSelf: false,
|
|
media: [{
|
|
originalUrl: 'mxc://reddit.com/image-one',
|
|
localPath: 'received image.jpg',
|
|
fileName: 'received image.jpg',
|
|
}],
|
|
},
|
|
],
|
|
})),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
|
|
const aliceItem = await screen.findByRole('button', { name: /u\/alice/ });
|
|
expect(screen.getByText('Image received')).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: /^Images/ }));
|
|
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: /u\/alice/ }));
|
|
expect(await screen.findByRole('img', { name: 'Image received from u/alice' })).toHaveAttribute(
|
|
'src',
|
|
'/media/received%20image.jpg',
|
|
);
|
|
expect(screen.getByRole('img', { name: 'Image received from u/alice' })).toHaveClass('is-blurred');
|
|
await user.click(screen.getByRole('button', { name: 'Reveal conversation image' }));
|
|
expect(screen.getByRole('img', { name: 'Image received from u/alice' })).not.toHaveClass('is-blurred');
|
|
});
|
|
|
|
it('turns a suggestion into an editable draft and confirms sending without losing failed input', async () => {
|
|
installConversationList();
|
|
let sendRequests = 0;
|
|
server.use(
|
|
http.post('/api/conversations/room-alice/suggest-reply', () =>
|
|
HttpResponse.json({ suggestedReply: 'That sounds lovely, what happened next?' }),
|
|
),
|
|
http.post('/api/conversations/room-alice/send-reply', async ({ request }) => {
|
|
sendRequests += 1;
|
|
const body = await request.json() as { message: string };
|
|
expect(body.message).toBe('My edited reply');
|
|
return HttpResponse.json({ error: 'Reddit is temporarily unavailable' }, { status: 503 });
|
|
}),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
|
await screen.findByRole('heading', { name: 'u/alice' });
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Suggest reply' }));
|
|
const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' });
|
|
expect(composer).toHaveValue('That sounds lovely, what happened next?');
|
|
await user.clear(composer);
|
|
await user.type(composer, 'My edited reply');
|
|
|
|
const sendButton = screen.getByRole('button', { name: 'Review and send reply' });
|
|
await user.click(sendButton);
|
|
expect(screen.getByRole('dialog', { name: 'Send this reply?' })).toHaveTextContent('My edited reply');
|
|
await user.keyboard('{Escape}');
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
|
expect(sendButton).toHaveFocus();
|
|
expect(sendRequests).toBe(0);
|
|
|
|
await user.click(sendButton);
|
|
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Confirm send' }));
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('Reddit is temporarily unavailable');
|
|
expect(composer).toHaveValue('My edited reply');
|
|
|
|
server.use(
|
|
http.post('/api/conversations/room-alice/send-reply', () => {
|
|
sendRequests += 1;
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
await user.click(sendButton);
|
|
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Confirm send' }));
|
|
expect(await screen.findByRole('status', { name: 'Reply sent' })).toHaveTextContent('Reply sent to u/alice');
|
|
expect(composer).toHaveValue('');
|
|
expect(sendRequests).toBe(2);
|
|
});
|
|
|
|
it('generates a media ask and looks up durable processed-user history', async () => {
|
|
installConversationList();
|
|
server.use(
|
|
http.post('/api/conversations/room-alice/suggest-media-ask', () => HttpResponse.json({ suggestedReply: 'Would you like to share a photo?' })),
|
|
http.get('/api/posts/by-author/alice', () => HttpResponse.json([{
|
|
id: 'post-1', subreddit: 'askuk', author: 'alice', title: 'A processed post',
|
|
imageUrl: null, imageAnalysis: null, generatedMessage: 'Hello',
|
|
processedAt: Date.now(), messageSent: true, error: null,
|
|
}])),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
await user.click(screen.getByText('Processed user lookup'));
|
|
await user.type(screen.getByLabelText('Lookup Reddit username'), 'u/alice');
|
|
await user.click(screen.getByRole('button', { name: 'Look up user' }));
|
|
expect(await screen.findByRole('list', { name: 'Processed posts for u/alice' })).toHaveTextContent('A processed post');
|
|
|
|
await user.click(screen.getByRole('button', { name: /u\/alice/ }));
|
|
await user.click(await screen.findByRole('button', { name: 'Suggest media ask' }));
|
|
expect(await screen.findByRole('textbox', { name: 'Reply to u/alice' })).toHaveValue('Would you like to share a photo?');
|
|
});
|
|
|
|
it('shares an eligible saved image through an explicit confirmation', async () => {
|
|
const asset = {
|
|
id: '11111111-1111-4111-8111-111111111111', tags: ['selfie'], safety: 'sfw',
|
|
classificationStatus: 'ready', description: 'A selfie', confidence: 0.95,
|
|
fileName: 'selfie.jpg', mimeType: 'image/jpeg', sizeBytes: 1024, width: 800, height: 1000,
|
|
enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1,
|
|
sendCount: 0, lastSentAt: null, urlPath: '/library-media/selfie.jpg',
|
|
};
|
|
let sharedAssetId: string | null = null;
|
|
server.use(
|
|
http.get('/api/conversations', () => HttpResponse.json([alice])),
|
|
http.get('/api/conversations/room-alice', () => HttpResponse.json({
|
|
...detailFixture(),
|
|
messages: [
|
|
{ sender: 'me', text: 'Hello Alice', timestamp: 1, isSelf: true },
|
|
{ eventId: '$request', sender: 'alice', text: 'Can you send a selfie?', timestamp: 2, isSelf: false },
|
|
],
|
|
})),
|
|
http.get('/api/conversations/room-alice/shareable-media', () => HttpResponse.json({
|
|
requestKind: 'selfie', assets: [asset], reason: null, warning: null,
|
|
})),
|
|
http.post('/api/conversations/room-alice/share-media', async ({ request }) => {
|
|
const body = await request.json() as { assetId: string };
|
|
sharedAssetId = body.assetId;
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
|
|
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
|
await user.click(await screen.findByText('Share a saved image'));
|
|
const image = await screen.findByRole('img', { name: 'Saved image tagged selfie' });
|
|
expect(image).toHaveClass('is-blurred');
|
|
await user.click(screen.getByRole('button', { name: 'Reveal saved images' }));
|
|
expect(image).not.toHaveClass('is-blurred');
|
|
await user.click(screen.getByRole('radio', { name: /Saved image tagged selfie/ }));
|
|
const reviewButton = screen.getByRole('button', { name: 'Review image share' });
|
|
await user.click(reviewButton);
|
|
const dialog = screen.getByRole('dialog', { name: 'Share this saved image?' });
|
|
expect(within(dialog).getByRole('img', { name: 'Saved image tagged selfie' })).toHaveAttribute('src', asset.urlPath);
|
|
await user.click(within(dialog).getByRole('button', { name: 'Confirm image share' }));
|
|
|
|
expect(await screen.findByText(/Saved image shared with u\/alice/)).toBeInTheDocument();
|
|
expect(sharedAssetId).toBe(asset.id);
|
|
expect(screen.getByText('Share a saved image')).toHaveFocus();
|
|
});
|
|
|
|
it('shows unused turns separately from a safety handoff', async () => {
|
|
server.use(
|
|
http.get('/api/conversations', () => HttpResponse.json([alice])),
|
|
http.get('/api/conversations/room-alice', () => HttpResponse.json({
|
|
...detailFixture(),
|
|
automation: {
|
|
turnsUsed: 0,
|
|
remainingTurns: 3,
|
|
status: 'human-required',
|
|
handoffReason: 'The generated media ask was rejected: media ask does not contain a recognizable image or video request.',
|
|
},
|
|
})),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
|
|
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
|
|
|
expect(await screen.findByText('3 automated turns remaining')).toBeInTheDocument();
|
|
expect(screen.getByText(/generated media ask was rejected/)).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Suggest media ask' })).toBeDisabled();
|
|
});
|
|
|
|
it('starts a conversation and preserves both fields after a recoverable failure', async () => {
|
|
installConversationList();
|
|
let startRequests = 0;
|
|
server.use(
|
|
http.post('/api/conversations/start', () => {
|
|
startRequests += 1;
|
|
return HttpResponse.json({ error: 'Invitation cooldown active' }, { status: 429 });
|
|
}),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'New conversation' }));
|
|
const username = screen.getByRole('textbox', { name: 'Reddit username' });
|
|
const message = screen.getByRole('textbox', { name: 'Opening message' });
|
|
await user.type(username, 'charlie');
|
|
await user.type(message, 'Hello Charlie');
|
|
await user.click(screen.getByRole('button', { name: 'Start conversation' }));
|
|
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('Invitation cooldown active');
|
|
expect(username).toHaveValue('charlie');
|
|
expect(message).toHaveValue('Hello Charlie');
|
|
|
|
server.use(
|
|
http.post('/api/conversations/start', () => {
|
|
startRequests += 1;
|
|
return HttpResponse.json({ success: true, chat: chatFixture({ username: 'charlie', roomId: 'room-charlie' }) });
|
|
}),
|
|
);
|
|
await user.click(screen.getByRole('button', { name: 'Start conversation' }));
|
|
expect(await screen.findByRole('status', { name: 'Conversation started' })).toHaveTextContent('Conversation started with u/charlie');
|
|
expect(within(screen.getByRole('status', { name: 'Conversation started' })).getByRole('link', { name: 'u/charlie' }))
|
|
.toHaveAttribute('href', 'https://www.reddit.com/u/charlie');
|
|
expect(username).toHaveValue('');
|
|
expect(message).toHaveValue('');
|
|
expect(startRequests).toBe(2);
|
|
});
|
|
|
|
it('explains how to enable the start-conversation form when no accounts are running', async () => {
|
|
server.use(
|
|
http.get('/api/conversations', () => HttpResponse.json([])),
|
|
http.get('/api/accounts', () => HttpResponse.json([])),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'New conversation' }));
|
|
expect(await screen.findByText(/No running accounts/)).toBeInTheDocument();
|
|
expect(screen.getByRole('link', { name: 'Settings' })).toHaveAttribute('href', '/settings');
|
|
expect(screen.queryByRole('button', { name: 'Start conversation' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('counts each triage bucket on its filter pill and marks unread rows in the label', async () => {
|
|
installConversationList();
|
|
renderConversations();
|
|
|
|
const aliceButton = await screen.findByRole('button', { name: /u\/alice/ });
|
|
// alice is inbound and unread; bob is a conversation we replied to last.
|
|
expect(screen.getByRole('button', { name: 'Unread, 1' })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Needs reply, 1' })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'Waiting on them, 1' })).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: 'All, 2' })).toHaveAttribute('aria-pressed', 'true');
|
|
expect(aliceButton.getAttribute('aria-label')).toMatch(/^u\/alice, Unread, Needs reply, .+ago/);
|
|
expect(aliceButton.closest('li')).toHaveClass('is-unread');
|
|
expect(screen.getByRole('button', { name: /u\/bob/ }).closest('li')).not.toHaveClass('is-unread');
|
|
});
|
|
|
|
it('offers a jump-to-latest control when the operator scrolls up in the history', async () => {
|
|
installConversationList();
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
|
|
|
const history = await screen.findByRole('list', { name: 'Message history' });
|
|
expect(screen.queryByRole('button', { name: 'Jump to latest' })).not.toBeInTheDocument();
|
|
|
|
Object.defineProperty(history, 'scrollHeight', { configurable: true, value: 1000 });
|
|
Object.defineProperty(history, 'clientHeight', { configurable: true, value: 300 });
|
|
history.scrollTop = 100;
|
|
fireEvent.scroll(history);
|
|
|
|
const jumpButton = await screen.findByRole('button', { name: 'Jump to latest' });
|
|
await user.click(jumpButton);
|
|
expect(history.scrollTop).toBe(1000);
|
|
expect(screen.queryByRole('button', { name: 'Jump to latest' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('preserves an unsent draft when switching away from and back to a conversation', async () => {
|
|
installConversationList();
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
|
|
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
|
const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' });
|
|
await user.type(composer, 'Draft in progress');
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Close conversation' }));
|
|
expect(screen.queryByRole('textbox', { name: 'Reply to u/alice' })).not.toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: /u\/alice/ }));
|
|
expect(await screen.findByRole('textbox', { name: 'Reply to u/alice' })).toHaveValue('Draft in progress');
|
|
});
|
|
|
|
it('lets the operator undo a suggestion that replaced a typed draft', async () => {
|
|
installConversationList();
|
|
server.use(
|
|
http.post('/api/conversations/room-alice/suggest-reply', () =>
|
|
HttpResponse.json({ suggestedReply: 'A generated suggestion' }),
|
|
),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
|
|
|
const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' });
|
|
await user.type(composer, 'My own words');
|
|
expect(screen.getByText('12 / 10000')).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Suggest reply' }));
|
|
await waitFor(() => expect(composer).toHaveValue('A generated suggestion'));
|
|
expect(screen.getByText('The suggestion replaced your draft.')).toBeInTheDocument();
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Undo' }));
|
|
expect(composer).toHaveValue('My own words');
|
|
expect(screen.queryByRole('button', { name: 'Undo' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('keeps the confirmation image blurred when saved images were re-blurred before review', async () => {
|
|
const asset = {
|
|
id: '22222222-2222-4222-8222-222222222222', tags: ['selfie'], safety: 'sfw',
|
|
classificationStatus: 'ready', description: 'A selfie', confidence: 0.95,
|
|
fileName: 'selfie.jpg', mimeType: 'image/jpeg', sizeBytes: 1024, width: 800, height: 1000,
|
|
enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1,
|
|
sendCount: 0, lastSentAt: null, urlPath: '/library-media/selfie.jpg',
|
|
};
|
|
server.use(
|
|
http.get('/api/conversations', () => HttpResponse.json([alice])),
|
|
http.get('/api/conversations/room-alice', () => HttpResponse.json(detailFixture())),
|
|
http.get('/api/conversations/room-alice/shareable-media', () => HttpResponse.json({
|
|
requestKind: 'selfie', assets: [asset], reason: null, warning: null,
|
|
})),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
|
|
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
|
await user.click(await screen.findByText('Share a saved image'));
|
|
|
|
expect(await screen.findByText('Reveal images to select one.')).toBeInTheDocument();
|
|
await user.click(screen.getByRole('button', { name: 'Reveal saved images' }));
|
|
expect(screen.queryByText('Reveal images to select one.')).not.toBeInTheDocument();
|
|
await user.click(screen.getByRole('radio', { name: /Saved image tagged selfie/ }));
|
|
await user.click(screen.getByRole('button', { name: 'Blur saved images' }));
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Review image share' }));
|
|
const dialog = screen.getByRole('dialog', { name: 'Share this saved image?' });
|
|
const dialogImage = within(dialog).getByRole('img', { name: 'Saved image tagged selfie' });
|
|
expect(dialogImage).toHaveClass('is-blurred');
|
|
await user.click(within(dialog).getByRole('button', { name: 'Reveal image' }));
|
|
expect(dialogImage).not.toHaveClass('is-blurred');
|
|
});
|
|
});
|