feat: persist settings and enrich conversation context

This commit is contained in:
jjbrunton committed 2026-08-08 18:18:28 +01:00
1 parent a36723a325
commit 2c100d72b6
84 files changed
+2248 -644

No files matched your search

+47
View File
@@ -171,6 +171,7 @@ describe('dashboard foundation', () => {
expect(screen.getByText('Dry run')).toBeInTheDocument();
expect(await screen.findByRole('list', { name: 'Recent sent messages' })).toHaveTextContent('A recent opener');
expect(screen.getByRole('list', { name: 'Recent sent messages' })).toHaveTextContent('Responded');
expect(screen.getByRole('link', { name: 'u/alice' })).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
});
it('keeps stats visible when timers fail and retries only the failed panel', async () => {
@@ -200,6 +201,52 @@ describe('dashboard foundation', () => {
expect(timerRequests).toBe(2);
});
it('makes usernames in recent activity clickable', async () => {
installSuccessfulApi({
recentActivity: [{
timestamp: Date.now(),
type: 'response',
message: 'Reply from u/alice',
recipient: 'alice',
}],
});
renderDashboard();
const activity = await screen.findByRole('heading', { name: 'Recent activity' });
const panel = activity.closest('section');
expect(panel).not.toBeNull();
expect(within(panel!).getByRole('link', { name: 'u/alice' })).toHaveAttribute(
'href',
'https://www.reddit.com/u/alice',
);
});
it('opens saved source-post context from a sent activity entry', async () => {
installSuccessfulApi({
recentActivity: [{
timestamp: Date.now(),
type: 'sent',
message: 'Sent message to u/alice (r/example)',
recipient: 'alice',
imageUrl: 'https://i.redd.it/source.jpg',
postTitle: 'The source post title',
postUrl: 'https://www.reddit.com/r/example/comments/source/the_source_post_title/',
}],
});
const user = userEvent.setup();
renderDashboard();
await user.click(await screen.findByRole('button', { name: 'Sent message to u/alice (r/example)' }));
const dialog = screen.getByRole('dialog', { name: 'The source post title' });
expect(within(dialog).getByRole('img', { name: 'Source post: The source post title' })).toHaveAttribute(
'src', 'https://i.redd.it/source.jpg',
);
expect(within(dialog).getByRole('link', { name: 'Open Reddit post' })).toHaveAttribute(
'href', 'https://www.reddit.com/r/example/comments/source/the_source_post_title/',
);
});
it('styles the recent-messages retry control when its request fails', async () => {
installSuccessfulApi();
server.use(http.get('/api/messages', () => HttpResponse.json({ error: 'Messages unavailable' }, { status: 503 })));
+56
View File
@@ -15,6 +15,7 @@ interface ChatFixtureOptions {
unread?: boolean;
isFromUs?: boolean;
lastMessageSnippet?: string;
hasReceivedImages?: boolean;
}
function chatFixture(options: ChatFixtureOptions) {
@@ -27,6 +28,7 @@ function chatFixture(options: ChatFixtureOptions) {
lastMessageSnippet: options.lastMessageSnippet ?? `Latest from ${options.username}`,
isFromUs: options.isFromUs ?? false,
unread: options.unread ?? false,
...(options.hasReceivedImages === undefined ? {} : { hasReceivedImages: options.hasReceivedImages }),
};
}
@@ -45,6 +47,7 @@ function detailFixture(chat = alice) {
subreddit: 'casualconversation',
title: 'Looking for a chat',
url: 'https://reddit.com/r/casualconversation/comments/post-1',
imageUrl: 'https://i.redd.it/source-post.jpg',
sentAt: 1_785_999_800_000,
},
syncInProgress: null,
@@ -123,8 +126,59 @@ describe('ConversationsPage', () => {
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.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',
);
});
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',
);
});
it('turns a suggestion into an editable draft and confirms sending without losing failed input', async () => {
@@ -230,6 +284,8 @@ describe('ConversationsPage', () => {
);
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);
+3
View File
@@ -89,6 +89,9 @@ describe('media page', () => {
const aliceImage = await screen.findByRole('img', { name: 'Media from alice' });
const charlieImage = screen.getByRole('img', { name: 'Media from charlie' });
for (const profileLink of screen.getAllByRole('link', { name: 'alice' })) {
expect(profileLink).toHaveAttribute('href', 'https://www.reddit.com/u/alice');
}
expect(aliceImage).toHaveClass('is-blurred');
expect(charlieImage).toHaveClass('is-blurred');
expect(credentials.every((value) => value === 'same-origin')).toBe(true);
@@ -124,7 +124,9 @@ describe('OperationsPage', () => {
expect(await screen.findByRole('heading', { name: 'Operations' })).toBeInTheDocument();
const activityRegion = screen.getByRole('region', { name: 'Activity' });
expect(await within(activityRegion).findByText('Reply from u/alex')).toBeInTheDocument();
expect(await within(activityRegion).findByRole('link', { name: 'u/alex' })).toBeInTheDocument();
expect(within(activityRegion).getByText((_content, element) => element?.tagName === 'STRONG' && element.textContent === 'Reply from u/alex')).toBeInTheDocument();
expect(within(activityRegion).getByRole('link', { name: 'u/alex' })).toHaveAttribute('href', 'https://www.reddit.com/u/alex');
const jobsRegion = screen.getByRole('region', { name: 'Sync jobs' });
expect(await within(jobsRegion).findByText('4 of 10')).toBeInTheDocument();
for (const name of ['Sync latest chats', 'Full cache sync', 'Fetch all chats']) {
+10
View File
@@ -83,6 +83,16 @@ describe('dashboard realtime client', () => {
type: 'sync.progress',
payload: { syncId: 'sync-1', kind: 'media', completed: 2, total: 5 },
}),
).toEqual([
['operations', 'sync-status'],
]);
expect(
queryKeysForDashboardEvent({
id: 'completed',
occurredAt: '2026-08-08T12:00:01.000Z',
type: 'sync.completed',
payload: { syncId: 'sync-1', kind: 'conversations', processed: 5 },
}),
).toEqual([
['operations', 'sync-status'],
['conversations'],
@@ -0,0 +1,48 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { RedditUsernameLink } from '../../apps/dashboard/src/components/reddit-username-link.js';
import { RedditUserText } from '../../apps/dashboard/src/components/reddit-user-text.js';
describe('RedditUsernameLink', () => {
it('normalizes a username and opens its Reddit profile in a new tab', () => {
render(<RedditUsernameLink username="u/example user" />);
expect(screen.getByRole('link', { name: 'u/example user' })).toHaveAttribute(
'href',
'https://www.reddit.com/u/example%20user',
);
expect(screen.getByRole('link', { name: 'u/example user' })).toHaveAttribute('target', '_blank');
expect(screen.getByRole('link', { name: 'u/example user' })).toHaveAttribute('rel', 'noreferrer');
});
it('can preserve an existing unprefixed display label', () => {
render(<RedditUsernameLink showPrefix={false} username="alice" />);
expect(screen.getByRole('link', { name: 'alice' })).toHaveAttribute(
'href',
'https://www.reddit.com/u/alice',
);
});
it('never exposes or links an internal t2 identity', () => {
const view = render(<RedditUsernameLink username="@t2_private123:reddit.com" />);
expect(view.container).not.toHaveTextContent('t2_private123');
expect(view.container.querySelector('a')).toBeNull();
expect(view.container).toHaveTextContent('Unknown Reddit user');
});
it('links username mentions embedded in activity text and redacts raw ids', () => {
render(<RedditUserText>Reply from u/alice after syncing t2_private123</RedditUserText>);
expect(screen.getByRole('link', { name: 'u/alice' })).toHaveAttribute(
'href',
'https://www.reddit.com/u/alice',
);
expect(screen.queryByText(/t2_private123/)).not.toBeInTheDocument();
expect(screen.getByText(/after syncing Reddit user/)).toBeInTheDocument();
});
});
+2 -2
View File
@@ -32,10 +32,10 @@ describe('route-friendly filters', () => {
it('hydrates and writes conversation search and status filters', async () => {
server.use(http.get('/api/conversations', () => HttpResponse.json([])));
const user = userEvent.setup();
renderRoute('/conversations?q=alice&filter=unread', <ConversationRoute />);
renderRoute('/conversations?q=alice&filter=received-images', <ConversationRoute />);
const search = await screen.findByRole('searchbox', { name: 'Search conversations' });
expect(search).toHaveValue('alice');
expect(screen.getByRole('combobox', { name: 'Filter conversations' })).toHaveValue('unread');
expect(screen.getByRole('combobox', { name: 'Filter conversations' })).toHaveValue('received-images');
await user.clear(search);
await user.type(search, 'bob');
expect(screen.getByLabelText('Current URL')).toHaveTextContent('q=bob');
+24
View File
@@ -16,6 +16,7 @@ const config = {
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60 },
subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default' }],
tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'],
advanced: { openrouter: { chatModel: 'example/chat-model' } },
};
const server = setupServer();
@@ -228,6 +229,25 @@ describe('settings and access', () => {
expect(updateRequests).toBe(1);
});
it('validates advanced application JSON before saving', async () => {
installConfigApi();
let updateRequests = 0;
server.use(http.put('/api/config', () => {
updateRequests += 1;
return HttpResponse.json({ success: true, config, subreddits: config.subreddits });
}));
const user = userEvent.setup();
renderSettings('/settings?section=behaviour');
const editor = await screen.findByRole('textbox', { name: 'Advanced configuration JSON' });
await user.clear(editor);
await user.type(editor, 'invalid');
await user.click(screen.getByRole('button', { name: 'Save settings' }));
expect(screen.getByText('Advanced configuration is not valid JSON.')).toBeInTheDocument();
expect(updateRequests).toBe(0);
});
it('confirms unusually large hourly limits', async () => {
installConfigApi();
let updateRequests = 0;
@@ -287,6 +307,10 @@ describe('settings and access', () => {
expect(importRequests).toBe(1);
expect(await screen.findByText('Authenticated')).toBeInTheDocument();
expect(screen.getByText(/u\/example-account/)).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'u/example-account' })).toHaveAttribute(
'href',
'https://www.reddit.com/u/example-account',
);
await user.click(screen.getByRole('button', { name: 'Recheck session' }));
expect(statusRequests).toBeGreaterThanOrEqual(2);