feat: add conversation image previews and sharing
This commit is contained in:
1 parent
488145340d
commit
b573b88b08
20 files changed
+634
-26
No files matched your search
@@ -59,10 +59,15 @@ function detailFixture(chat = alice) {
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
}])));
|
||||
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: [], reason: 'The latest inbound message does not ask for an image.',
|
||||
})),
|
||||
);
|
||||
|
||||
function installConversationList() {
|
||||
server.use(
|
||||
@@ -147,8 +152,20 @@ describe('ConversationsPage', () => {
|
||||
);
|
||||
expect(screen.getByText('2h ago')).toBeInTheDocument();
|
||||
expect(screen.getByRole('img', { name: 'Source post: Looking for a chat' })).toHaveClass('is-blurred');
|
||||
expect(screen.getByRole('button', { name: 'View source image full screen: Looking for a chat' })).toBeDisabled();
|
||||
await user.click(screen.getByRole('button', { name: 'Reveal original image' }));
|
||||
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 () => {
|
||||
@@ -313,6 +330,54 @@ describe('ConversationsPage', () => {
|
||||
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,
|
||||
})),
|
||||
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])),
|
||||
|
||||
@@ -81,6 +81,54 @@ describe('MediaLibraryExchangeService', () => {
|
||||
expect(chat.sendChatImage).toHaveBeenCalledOnce();
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('lists eligible choices and sends only the operator-selected matching asset', async () => {
|
||||
const db = createTestDb();
|
||||
const repository = new MediaLibraryRepository(db);
|
||||
await addAsset(repository, directory, 'selfie-one', ['selfie'], 'sfw', 1);
|
||||
await addAsset(repository, directory, 'selfie-two', ['selfie'], 'sfw', 2);
|
||||
await addAsset(repository, directory, 'topless', ['topless'], 'nsfw', 3);
|
||||
const chat = {
|
||||
sendChatImage: vi.fn().mockResolvedValue({ eventId: '$selected', mxcUri: 'mxc://selected' }),
|
||||
sendChatMessage: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const service = new MediaLibraryExchangeService(repository, chat, logger(), 'account', directory);
|
||||
|
||||
expect(service.listShareable('room', 'send a selfie').assets.map((asset) => asset.id)).toEqual([
|
||||
'selfie-two', 'selfie-one',
|
||||
]);
|
||||
expect(await service.handleSelected('room', '$request', 'send a selfie', 'topless')).toMatchObject({
|
||||
kind: 'human-required',
|
||||
});
|
||||
expect(await service.handleSelected('room', '$request', 'send a selfie', 'selfie-one')).toMatchObject({
|
||||
kind: 'sent', assetId: 'selfie-one', requestKind: 'selfie',
|
||||
});
|
||||
expect(chat.sendChatImage).toHaveBeenCalledWith(
|
||||
'room', expect.objectContaining({ fileName: 'selfie-one.jpg' }), expect.any(String), null,
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('keeps an image-only partial delivery human-required on later attempts', async () => {
|
||||
const db = createTestDb();
|
||||
const repository = new MediaLibraryRepository(db);
|
||||
await addAsset(repository, directory, 'selfie', ['selfie'], 'sfw', 1);
|
||||
const chat = {
|
||||
sendChatImage: vi.fn().mockResolvedValue({ eventId: '$image', mxcUri: 'mxc://image' }),
|
||||
sendChatMessage: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
const service = new MediaLibraryExchangeService(repository, chat, logger(), 'account', directory);
|
||||
|
||||
await expect(service.handleSelected('room', '$request', 'send a selfie', 'selfie')).resolves.toMatchObject({
|
||||
kind: 'human-required', remoteSent: true,
|
||||
});
|
||||
await expect(service.handleSelected('room', '$request', 'send a selfie', 'selfie')).resolves.toMatchObject({
|
||||
kind: 'human-required', remoteSent: true,
|
||||
});
|
||||
expect(chat.sendChatImage).toHaveBeenCalledOnce();
|
||||
expect(chat.sendChatMessage).toHaveBeenCalledOnce();
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
async function addAsset(
|
||||
|
||||
@@ -133,6 +133,72 @@ describe('conversations route', () => {
|
||||
expect(first.resources.activityLogRepo.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists and sends only request-matched library media through the owning runtime', async () => {
|
||||
const accountId = '@t2_one:reddit.com';
|
||||
const assetId = '11111111-1111-4111-8111-111111111111';
|
||||
const conversation = {
|
||||
username: 'alice', roomName: 'alice', roomId: 'room-one', url: '/room/room-one',
|
||||
lastMessageTime: 2, lastMessageSnippet: 'send a selfie', isFromUs: false, unread: true,
|
||||
messages: [
|
||||
{ sender: 'me', text: 'hello', timestamp: 1, isSelf: true },
|
||||
{ eventId: '$request', sender: 'alice', text: 'send a selfie', timestamp: 2, isSelf: false },
|
||||
],
|
||||
};
|
||||
const asset = {
|
||||
id: assetId, tags: ['selfie'], safety: 'sfw', classificationStatus: 'ready', description: 'selfie', confidence: 1,
|
||||
fileName: 'selfie.jpg', relativePath: 'nested/selfie.jpg', mimeType: 'image/jpeg', sizeBytes: 4,
|
||||
width: 10, height: 10, enabled: true, consentConfirmedAt: 1, createdAt: 1, updatedAt: 1,
|
||||
sendCount: 0, lastSentAt: null,
|
||||
};
|
||||
const mediaExchange = {
|
||||
listShareable: vi.fn().mockReturnValue({ requestKind: 'selfie', assets: [asset] }),
|
||||
handleSelected: vi.fn().mockResolvedValue({ kind: 'sent', assetId, requestKind: 'selfie' }),
|
||||
};
|
||||
const runtime = {
|
||||
snapshot: () => ({ state: 'running' }),
|
||||
resources: {
|
||||
chatService: {
|
||||
getCachedConversations: vi.fn().mockResolvedValue([conversation]),
|
||||
loadConversationDetails: vi.fn().mockResolvedValue(undefined),
|
||||
refreshTopChats: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
conversationStateRepo: { get: vi.fn(), save: vi.fn() },
|
||||
activityLogRepo: { insert: vi.fn() },
|
||||
mediaExchange,
|
||||
rateLimiter: { throttle: vi.fn((work: () => Promise<unknown>) => work()) },
|
||||
},
|
||||
};
|
||||
const context = {
|
||||
logger: { error: vi.fn() }, activity: { push: vi.fn() }, broadcaster: { push: vi.fn() },
|
||||
services: {
|
||||
chat: {},
|
||||
accountRuntimes: {
|
||||
list: () => [{ accountId, redditUsername: 'owner-one', state: 'running' }],
|
||||
get: (requested: string) => requested === accountId ? runtime : null,
|
||||
},
|
||||
},
|
||||
} as unknown as WebContext;
|
||||
const app = express().use(express.json()).use('/api', createConversationsRouter(context));
|
||||
const ownedId = `${encodeURIComponent(accountId)}~room-one`;
|
||||
|
||||
const optionsResponse = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/shareable-media`);
|
||||
expect(optionsResponse.status).toBe(200);
|
||||
expect(await optionsResponse.json()).toEqual({
|
||||
requestKind: 'selfie', reason: null,
|
||||
assets: [expect.objectContaining({ id: assetId, urlPath: '/library-media/nested/selfie.jpg' })],
|
||||
});
|
||||
|
||||
const sendResponse = await request(app, `/api/conversations/${encodeURIComponent(ownedId)}/share-media`, {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ assetId }),
|
||||
});
|
||||
expect(sendResponse.status).toBe(200);
|
||||
expect(await sendResponse.json()).toEqual({ success: true });
|
||||
expect(mediaExchange.handleSelected).toHaveBeenCalledWith('room-one', '$request', 'send a selfie', assetId);
|
||||
expect(runtime.resources.conversationStateRepo.save).toHaveBeenCalledWith('room-one', expect.objectContaining({
|
||||
automation: expect.objectContaining({ status: 'user-overridden' }),
|
||||
}));
|
||||
});
|
||||
|
||||
it('rejects an unowned conversation ID while account runtimes are active', async () => {
|
||||
const context = {
|
||||
logger: { error: vi.fn() },
|
||||
|
||||
Reference in new issue
Block a user