The "Share a saved image" panel was wired to the automated exchange engine, so an operator could only share an image right after an inbound image request, limited to assets the auto-exchanger would itself pick. Add a manual path that offers any enabled, unused library image at any point (no request detection, tier, seller, or boundary gating) and sends it with no reciprocal "your turn" text. Safety flags now surface as a non-blocking warning instead of disabling the panel. The automated auto-send path keeps all its guardrails; the shared image-delivery step is extracted into a helper used by both paths. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
568 lines
27 KiB
TypeScript
568 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.',
|
|
})),
|
|
);
|
|
|
|
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.selectOptions(screen.getByRole('combobox', { name: 'Filter conversations' }), '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.selectOptions(screen.getByRole('combobox', { name: 'Filter conversations' }), 'received-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('shows the unread count and marks unread rows with state and time in the label', async () => {
|
|
installConversationList();
|
|
renderConversations();
|
|
|
|
const aliceButton = await screen.findByRole('button', { name: /u\/alice/ });
|
|
expect(screen.getByText('1 unread')).toBeInTheDocument();
|
|
expect(aliceButton.getAttribute('aria-label')).toMatch(/^u\/alice, Unread, .+ 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');
|
|
});
|
|
});
|