Add an emoji picker to the dashboard reply composer and let generated replies use emoji. The picker offers a short hand-picked set rather than a full Unicode index, and inserts at the caret so the operator can keep typing without reaching for the mouse again. Insertion respects the Reddit length cap. The chat reply system prompt now permits at most one or two emoji per message, only where someone texting casually would use one. Scoped to ongoing replies — openers build from configurable prompts, so hardcoding guidance there would fight the operator's own settings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
700 lines
33 KiB
TypeScript
700 lines
33 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;
|
|
automationStatus?: 'active' | 'image-received' | 'human-required' | 'boundary' | 'user-overridden';
|
|
automationHandoffReason?: string;
|
|
}
|
|
|
|
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 }),
|
|
...(options.automationStatus === undefined ? {} : { automationStatus: options.automationStatus }),
|
|
...(options.automationHandoffReason === undefined
|
|
? {}
|
|
: { automationHandoffReason: options.automationHandoffReason }),
|
|
};
|
|
}
|
|
|
|
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();
|
|
await user.click(screen.getByRole('button', { name: /^Waiting/ }));
|
|
expect(screen.getByRole('button', { name: /u\/bob/ })).toBeInTheDocument();
|
|
|
|
await user.clear(screen.getByRole('searchbox', { name: 'Search conversations' }));
|
|
await user.click(screen.getByRole('button', { name: /^Needs attention/ }));
|
|
await user.click(screen.getByRole('checkbox', { name: 'Unread only' }));
|
|
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('keeps detected sellers in their dedicated queue', 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('button', { name: /^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('checkbox', { name: 'Has 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('marks and filters conversations that are being replied to by hand', async () => {
|
|
const manual = chatFixture({
|
|
username: 'alice',
|
|
roomId: 'room-alice',
|
|
isFromUs: true,
|
|
automationStatus: 'user-overridden',
|
|
automationHandoffReason: 'A manual reply was sent in this conversation.',
|
|
});
|
|
const automated = chatFixture({ username: 'bob', roomId: 'room-bob', automationStatus: 'active' });
|
|
server.use(http.get('/api/conversations', () => HttpResponse.json([manual, automated])));
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
|
|
await user.click(await screen.findByRole('button', { name: /^Waiting/ }));
|
|
await screen.findByRole('button', { name: /u\/alice/ });
|
|
expect(screen.getByText('Manual', { selector: '.conversation-chip' })).toHaveAttribute(
|
|
'title',
|
|
'Manual — you replied — A manual reply was sent in this conversation.',
|
|
);
|
|
|
|
await user.click(screen.getByRole('checkbox', { name: 'Manual only' }));
|
|
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
|
|
expect(screen.queryByRole('button', { name: /u\/bob/ })).not.toBeInTheDocument();
|
|
});
|
|
|
|
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('inserts a picked emoji at the caret without disturbing the rest of the draft', async () => {
|
|
installConversationList();
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
|
await screen.findByRole('heading', { name: 'u/alice' });
|
|
|
|
const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' }) as HTMLTextAreaElement;
|
|
await user.type(composer, 'hey there');
|
|
composer.setSelectionRange(3, 3);
|
|
|
|
expect(screen.queryByRole('group', { name: 'Emoji' })).not.toBeInTheDocument();
|
|
await user.click(screen.getByRole('button', { name: 'Add emoji' }));
|
|
await user.click(within(screen.getByRole('group', { name: 'Emoji' })).getByRole('button', { name: '😄' }));
|
|
|
|
expect(composer).toHaveValue('hey😄 there');
|
|
await waitFor(() => expect(composer).toHaveFocus());
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Hide emoji' }));
|
|
expect(screen.queryByRole('group', { name: 'Emoji' })).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('sends a reply containing emoji unchanged', async () => {
|
|
installConversationList();
|
|
let sentBody: unknown = null;
|
|
server.use(
|
|
http.post('/api/conversations/room-alice/send-reply', async ({ request }) => {
|
|
sentBody = await request.json();
|
|
return HttpResponse.json({ success: true });
|
|
}),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
await user.click(await screen.findByRole('button', { name: /u\/alice/ }));
|
|
await screen.findByRole('heading', { name: 'u/alice' });
|
|
|
|
const composer = await screen.findByRole('textbox', { name: 'Reply to u/alice' });
|
|
await user.type(composer, 'nice one 🔥');
|
|
|
|
await user.click(screen.getByRole('button', { name: 'Review and send reply' }));
|
|
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Confirm send' }));
|
|
|
|
await waitFor(() => expect(sentBody).toEqual({ message: 'nice one 🔥' }));
|
|
});
|
|
|
|
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: 'Accounts' })).toHaveAttribute('href', '/accounts');
|
|
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: 'Needs attention, 1' })).toHaveAttribute('aria-pressed', 'true');
|
|
expect(screen.getByRole('button', { name: 'Waiting, 1' })).toBeInTheDocument();
|
|
expect(screen.getByRole('checkbox', { name: 'Unread only' })).not.toBeChecked();
|
|
expect(aliceButton.getAttribute('aria-label')).toMatch(/^u\/alice, Needs attention, Unread, .+ago/);
|
|
expect(aliceButton.closest('li')).toHaveClass('is-unread');
|
|
await userEvent.setup().click(screen.getByRole('button', { name: 'Waiting, 1' }));
|
|
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');
|
|
});
|
|
it('runs a forced seller scan and reports what it cost', async () => {
|
|
let scanned = 0;
|
|
installConversationList();
|
|
server.use(
|
|
http.post('/api/conversations/seller-scan', () => {
|
|
scanned += 1;
|
|
return HttpResponse.json({
|
|
success: true, scanned: 12, alreadyFlagged: 2, unchanged: 6, deterministic: 1,
|
|
aiScreened: 3, aiRequests: 1, budgetSkipped: 0, emptySkipped: 0, degraded: false,
|
|
flagged: [{
|
|
conversationId: 'room-alice', username: 'alice', source: 'ai',
|
|
reason: 'AI screening classified this conversation as seller (confidence 0.90)',
|
|
confidence: 0.9, killed: true,
|
|
}],
|
|
});
|
|
}),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Scan for sellers' }));
|
|
|
|
const summary = await screen.findByRole('status');
|
|
expect(summary).toHaveTextContent('Scanned 12 conversations');
|
|
expect(summary).toHaveTextContent('1 flagged as sellers (1 ended)');
|
|
expect(summary).toHaveTextContent('3 needed AI over 1 request');
|
|
expect(summary).toHaveTextContent('6 unchanged since the last scan');
|
|
expect(scanned).toBe(1);
|
|
});
|
|
|
|
it('surfaces a failed seller scan without disturbing the list', async () => {
|
|
installConversationList();
|
|
server.use(
|
|
http.post('/api/conversations/seller-scan', () =>
|
|
HttpResponse.json({ error: 'Failed to scan conversations for sellers' }, { status: 500 })),
|
|
);
|
|
const user = userEvent.setup();
|
|
renderConversations();
|
|
|
|
await user.click(await screen.findByRole('button', { name: 'Scan for sellers' }));
|
|
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('Seller scan failed');
|
|
expect(screen.getByRole('button', { name: /u\/alice/ })).toBeInTheDocument();
|
|
});
|
|
});
|