Blur gallery images by default, sync progress UI, and add chat/monitor tests

- Blur NSFW media gallery images until hover/reveal
- Surface chat sync progress in the dashboard header
- Extend chat service, cache, and monitor with delta-media handling
- Add tests for chat cooldown, delta media, and monitor flows

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Opus 4.7 committed 2026-05-22 10:01:09 +01:00
1 parent 633bb0ec04
commit 2260388dc8
15 files changed
+675 -41

No files matched your search

+80
View File
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest';
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { ChatService } from '../src/services/chat/service.js';
import { ChatCache } from '../src/services/chat/cache.js';
import { MatrixClient, MatrixRateLimitError } from '../src/services/chat/matrix/client.js';
import { createMockLogger } from './mockLogger.js';
@@ -80,6 +84,82 @@ describe('ChatService invitation cooldown', () => {
expect(result).toBe(false);
});
it('persists the cooldown to disk and restores it after a fresh service load', async () => {
const logger = createMockLogger();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-cooldown-'));
const cacheFile = path.join(tmpDir, 'chat-cache.json');
try {
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
const cache1 = new ChatCache(logger, cacheFile);
const svc1 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache1);
svc1.setInvitationCooldown(
new MatrixRateLimitError('429', 120_000, 'M_LIMIT_EXCEEDED', null),
);
// setInvitationCooldown kicks off an async save; flush the queue.
await cache1.save();
const cache2 = new ChatCache(logger, cacheFile);
const svc2 = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache2);
await svc2.loadCache();
const restored = svc2.getInvitationCooldown();
expect(restored).not.toBeNull();
expect(restored!.until).toBeGreaterThan(Date.now() + 100_000);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it('preserves on-disk chats and lastFullSyncAt when a cooldown fires before loadCache', async () => {
const logger = createMockLogger();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-cooldown-preserve-'));
const cacheFile = path.join(tmpDir, 'chat-cache.json');
try {
// Pre-seed an existing cache with a completed full sync and known chats.
const existing = {
version: 1,
savedAt: Date.now() - 60_000,
chats: [
{
username: 'alice',
roomName: 'alice',
lastMessageTime: Date.now() - 30_000,
lastMessageSnippet: 'hi',
isFromUs: false,
unread: false,
roomId: '!room:example.test',
},
],
nextBatch: 's42_1',
lastFullSyncAt: Date.now() - 120_000,
lastDeltaSyncAt: Date.now() - 30_000,
cooldown: null,
};
await fs.writeFile(cacheFile, JSON.stringify(existing), 'utf-8');
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
const cache = new ChatCache(logger, cacheFile);
const svc = new ChatService(matrix, logger, undefined, undefined, undefined, undefined, cache);
// Cooldown fires before loadCache() — the in-memory chats are still [].
svc.setInvitationCooldown(
new MatrixRateLimitError('429', 60_000, 'M_LIMIT_EXCEEDED', null),
);
// Flush the async persist (read + write).
await cache.persistCooldown();
const raw = await fs.readFile(cacheFile, 'utf-8');
const after = JSON.parse(raw);
expect(after.chats).toHaveLength(1);
expect(after.chats[0].username).toBe('alice');
expect(after.lastFullSyncAt).toBe(existing.lastFullSyncAt);
expect(after.nextBatch).toBe('s42_1');
expect(after.cooldown).not.toBeNull();
expect(after.cooldown.reason).toMatch(/M_LIMIT_EXCEEDED/);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it('sendChatMessage records a cooldown when the homeserver rate-limits a reply', async () => {
const logger = createMockLogger();
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
+178
View File
@@ -0,0 +1,178 @@
import { describe, it, expect, vi } from 'vitest';
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { ChatService } from '../src/services/chat/service.js';
import { ChatCache } from '../src/services/chat/cache.js';
import { MatrixClient } from '../src/services/chat/matrix/client.js';
import { MediaService } from '../src/services/media.js';
import type { MediaDownloader } from '../src/services/media.js';
import { createMockLogger } from './mockLogger.js';
const creds = {
homeserver: 'https://matrix.example.test',
accessToken: 'tok',
userId: '@me:example.test',
capturedAt: Date.now(),
};
function buildDownloader(): MediaDownloader {
return {
fetch: vi.fn(async () => ({
buffer: Buffer.from('image-bytes'),
contentType: 'image/jpeg',
})),
};
}
describe('ChatService delta sync — media attachment', () => {
it('downloads media for image events arriving via /sync', async () => {
const logger = createMockLogger();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-delta-media-'));
try {
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
matrix.sync = vi.fn(async () => ({
next_batch: 's2',
rooms: {
join: {
'!room1:example.test': {
timeline: {
events: [
{
type: 'm.room.message',
sender: '@t2_abc:example.test',
origin_server_ts: 1000,
content: { msgtype: 'm.image', url: 'mxc://example.test/img1' },
},
{
type: 'm.room.message',
sender: '@t2_abc:example.test',
origin_server_ts: 1100,
content: { body: 'plain text follow-up' },
},
],
},
},
},
},
})) as typeof matrix.sync;
const downloader = buildDownloader();
const media = new MediaService(downloader, logger, tmpDir);
const cache = new ChatCache(logger, path.join(tmpDir, 'chat-cache.json'));
const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache);
await svc.getActiveChats();
expect(downloader.fetch).toHaveBeenCalledTimes(1);
const items = await media.getMediaItems();
expect(items).toHaveLength(1);
expect(items[0].originalUrl).toBe('mxc://example.test/img1');
expect(items[0].chatRoomId).toBe('!room1:example.test');
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it('appends new media messages to chats that already have loaded history', async () => {
const logger = createMockLogger();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-delta-media-merge-'));
const cacheFile = path.join(tmpDir, 'chat-cache.json');
try {
const now = Date.now();
const existing = {
version: 1,
savedAt: now - 60_000,
chats: [
{
username: 'alice',
roomName: 'alice',
lastMessageTime: now - 5_000,
lastMessageSnippet: 'hi',
isFromUs: false,
unread: false,
roomId: '!room1:example.test',
messages: [
{ sender: 't2_abc', text: 'hi', timestamp: now - 5_000, isSelf: false },
],
},
],
nextBatch: 's1',
lastFullSyncAt: now - 120_000,
lastDeltaSyncAt: now - 60_000,
cooldown: null,
};
const imageTs = now - 1_000;
await fs.writeFile(cacheFile, JSON.stringify(existing), 'utf-8');
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
matrix.sync = vi.fn(async () => ({
next_batch: 's2',
rooms: {
join: {
'!room1:example.test': {
timeline: {
events: [
{
type: 'm.room.message',
sender: '@t2_abc:example.test',
origin_server_ts: imageTs,
content: { msgtype: 'm.image', url: 'mxc://example.test/img1' },
},
],
},
},
},
},
})) as typeof matrix.sync;
const media = new MediaService(buildDownloader(), logger, tmpDir);
const cache = new ChatCache(logger, cacheFile);
const svc = new ChatService(matrix, logger, undefined, media, undefined, undefined, cache);
await svc.loadCache();
// refreshTopChats always runs a /sync (unlike getActiveChats, which can
// short-circuit on a fresh cache).
await svc.refreshTopChats();
const chats = await svc.getCachedConversations();
const alice = chats.find((c) => c.roomId === '!room1:example.test');
expect(alice).toBeDefined();
expect(alice!.messages).toBeDefined();
// Original message + the new image-bearing message.
expect(alice!.messages!.length).toBe(2);
const imageMsg = alice!.messages!.find((m) => m.timestamp === imageTs);
expect(imageMsg).toBeDefined();
expect(imageMsg!.media).toBeDefined();
expect(imageMsg!.media!.length).toBe(1);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it('is a no-op when no MediaService is configured', async () => {
const logger = createMockLogger();
const matrix = new MatrixClient(creds, logger, { minRequestIntervalMs: 0 });
matrix.sync = vi.fn(async () => ({
next_batch: 's2',
rooms: {
join: {
'!room1:example.test': {
timeline: {
events: [
{
type: 'm.room.message',
sender: '@t2_abc:example.test',
origin_server_ts: 1000,
content: { url: 'mxc://example.test/img1' },
},
],
},
},
},
},
})) as typeof matrix.sync;
const svc = new ChatService(matrix, logger);
await expect(svc.getActiveChats()).resolves.toBeDefined();
});
});
+64
View File
@@ -156,6 +156,70 @@ describe('MonitorService', () => {
expect(Array.isArray(result)).toBe(true);
});
it('prefers a recent post over an older one when prioritizeRecentPosts is enabled', async () => {
const now = Math.floor(Date.now() / 1000);
const oldPost: RedditPost = {
id: 't3_old', subreddit: 'sub1', author: 'u_old', title: 'Old',
url: 'http://example.com/old.jpg', permalink: '/r/sub1/old', thumbnail: '',
is_self: false, created_utc: now - 7200, over_18: false, spoiler: false,
};
const recentPost: RedditPost = {
id: 't3_new', subreddit: 'sub2', author: 'u_new', title: 'New',
url: 'http://example.com/new.jpg', permalink: '/r/sub2/new', thumbnail: '',
is_self: false, created_utc: now - 600, over_18: false, spoiler: false,
};
const redditApi = {
getNewPosts: vi.fn(async (sub: string) =>
sub === 'sub1' ? [oldPost] : [recentPost],
),
getImageUrl: vi.fn(),
getPostUrl: vi.fn(),
isSellerProfile: vi.fn(async () => false),
} as unknown as RedditApiClient;
const postsRepo = createMockPostsRepo();
const messagesRepo = createMockMessagesRepo();
const config = getTestConfig({
reddit: {
subreddits: [{ name: 'sub1', type: 'image' }, { name: 'sub2', type: 'image' }],
prioritizeRecentPosts: true,
recentWindowMinutes: 60,
},
});
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
const result = await monitor.getUnprocessedPosts();
expect(result.length).toBe(1);
expect(result[0].id).toBe('t3_new');
});
it('falls back to the newest older post when no recent posts exist', async () => {
const now = Math.floor(Date.now() / 1000);
const oldPost: RedditPost = {
id: 't3_old', subreddit: 'sub1', author: 'u_old', title: 'Old',
url: 'http://example.com/old.jpg', permalink: '/r/sub1/old', thumbnail: '',
is_self: false, created_utc: now - 7200, over_18: false, spoiler: false,
};
const redditApi = createMockRedditApi([oldPost]);
const postsRepo = createMockPostsRepo();
const messagesRepo = createMockMessagesRepo();
const config = getTestConfig({
reddit: {
subreddits: [{ name: 'sub1', type: 'image' }],
prioritizeRecentPosts: true,
recentWindowMinutes: 60,
},
});
monitor = new MonitorService(redditApi, postsRepo, messagesRepo, config, logger, 0);
const result = await monitor.getUnprocessedPosts();
expect(result.length).toBe(1);
expect(result[0].id).toBe('t3_old');
});
it('returns empty array when no subreddits have valid posts', async () => {
const redditApi = createMockRedditApi([]);
const postsRepo = createMockPostsRepo();
+3
View File
@@ -34,6 +34,9 @@ export function getTestConfig(overrides?: Partial<Config>): Config {
targetGenders: ['female'],
filterSellers: true,
sellerKeywords: ['onlyfans', 'fansly'],
sellerDomains: [],
prioritizeRecentPosts: false,
recentWindowMinutes: 60,
...overrides?.reddit,
},
openrouter: {