feat: link media to conversations
This commit is contained in:
1 parent
e8f56b42e6
commit
acc610c2bf
4 files changed
+39
-5
No files matched your search
@@ -1,8 +1,12 @@
|
|||||||
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
|
import type { ActiveChat } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
|
||||||
|
export function createOwnedConversationId(accountId: string | undefined, conversationId: string): string {
|
||||||
|
return accountId
|
||||||
|
? `${encodeURIComponent(accountId)}~${encodeURIComponent(conversationId)}`
|
||||||
|
: conversationId;
|
||||||
|
}
|
||||||
|
|
||||||
export function ownedConversationId(conversation: ActiveChat): string {
|
export function ownedConversationId(conversation: ActiveChat): string {
|
||||||
const conversationId = conversation.roomId ?? conversation.username;
|
const conversationId = conversation.roomId ?? conversation.username;
|
||||||
return conversation.owner
|
return createOwnedConversationId(conversation.owner?.accountId, conversationId);
|
||||||
? `${encodeURIComponent(conversation.owner.accountId)}~${encodeURIComponent(conversationId)}`
|
|
||||||
: conversationId;
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
|
import type { MediaItem } from '../../../../../packages/api-contracts/src/index.js';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
|
||||||
|
import { createOwnedConversationId } from '../conversations/conversation-identity.js';
|
||||||
import { mediaConversation, mediaType } from './filters.js';
|
import { mediaConversation, mediaType } from './filters.js';
|
||||||
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
import { AccountOwnerBadge } from '../../components/account-owner-badge.js';
|
||||||
|
|
||||||
@@ -15,6 +17,11 @@ function formatMediaDate(timestamp: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: MediaCardProps) {
|
export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: MediaCardProps) {
|
||||||
|
const conversationId = item.chatRoomId ?? item.chatUsername;
|
||||||
|
const conversationPath = conversationId
|
||||||
|
? `/conversations/${encodeURIComponent(createOwnedConversationId(item.owner?.accountId, conversationId))}`
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="media-card">
|
<article className="media-card">
|
||||||
<div className="media-preview">
|
<div className="media-preview">
|
||||||
@@ -41,6 +48,11 @@ export function MediaCard({ item, isRevealed, onToggleReveal, onOpenPreview }: M
|
|||||||
<div><dt>Type</dt><dd>{mediaType(item) === 'gif' ? 'GIF' : 'Image'}</dd></div>
|
<div><dt>Type</dt><dd>{mediaType(item) === 'gif' ? 'GIF' : 'Image'}</dd></div>
|
||||||
<div><dt>Received</dt><dd>{formatMediaDate(item.timestamp)}</dd></div>
|
<div><dt>Received</dt><dd>{formatMediaDate(item.timestamp)}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
|
{conversationPath ? (
|
||||||
|
<Link className="button secondary media-conversation-link" to={conversationPath}>
|
||||||
|
Open conversation
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
className="button secondary media-reveal"
|
className="button secondary media-reveal"
|
||||||
onClick={onToggleReveal}
|
onClick={onToggleReveal}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
.media-filters { display: grid; grid-template-columns: 1fr; gap: .8rem; margin-bottom: 1rem; }
|
.media-filters { display: grid; grid-template-columns: 1fr; gap: .8rem; margin-bottom: 1rem; }
|
||||||
.media-filters label { display: grid; gap: .35rem; color: var(--muted); font-size: .78rem; font-weight: 750; }
|
.media-filters label { display: grid; gap: .35rem; color: var(--muted); font-size: .78rem; font-weight: 750; }
|
||||||
.media-filters select { width: 100%; min-height: 2.6rem; padding: .5rem .7rem; color: #e9f1ec; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .6rem; }
|
.media-filters select { width: 100%; min-height: 2.6rem; padding: .5rem .7rem; color: #e9f1ec; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .6rem; }
|
||||||
.media-filters select:focus-visible, .media-reveal:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
.media-filters select:focus-visible, .media-reveal:focus-visible, .media-conversation-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
.media-grid { display: grid; grid-template-columns: 1fr; gap: 1rem; }
|
.media-grid { display: grid; grid-template-columns: 1fr; gap: 1rem; }
|
||||||
.media-card { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
|
.media-card { min-width: 0; overflow: hidden; background: var(--surface); border: 1px solid var(--border); border-radius: .9rem; }
|
||||||
.media-preview { position: relative; display: grid; place-items: center; aspect-ratio: 4 / 3; overflow: hidden; background: #050907; }
|
.media-preview { position: relative; display: grid; place-items: center; aspect-ratio: 4 / 3; overflow: hidden; background: #050907; }
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
.media-card-body dl div { display: flex; justify-content: space-between; gap: .75rem; }
|
.media-card-body dl div { display: flex; justify-content: space-between; gap: .75rem; }
|
||||||
.media-card-body dt { color: var(--muted); }
|
.media-card-body dt { color: var(--muted); }
|
||||||
.media-card-body dd { margin: 0; text-align: right; }
|
.media-card-body dd { margin: 0; text-align: right; }
|
||||||
|
.media-conversation-link { display: flex; align-items: center; justify-content: center; width: 100%; text-decoration: none; }
|
||||||
.media-reveal { width: 100%; }
|
.media-reveal { width: 100%; }
|
||||||
.media-preview-dialog-backdrop { position: fixed; inset: 0; z-index: 70; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(0, 0, 0, .88); }
|
.media-preview-dialog-backdrop { position: fixed; inset: 0; z-index: 70; display: flex; align-items: center; justify-content: center; padding: 1rem; background: rgba(0, 0, 0, .88); }
|
||||||
.media-preview-dialog-content { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
|
.media-preview-dialog-content { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; }
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import userEvent from '@testing-library/user-event';
|
|||||||
import { HttpResponse, http } from 'msw';
|
import { HttpResponse, http } from 'msw';
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node';
|
||||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { MediaPage } from '../../apps/dashboard/src/features/media/media-page.js';
|
import { MediaPage } from '../../apps/dashboard/src/features/media/media-page.js';
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -15,6 +16,7 @@ const mediaItems = [
|
|||||||
id: 'one', chatUsername: 'alice', chatRoomId: 'room-a', sender: 'alice',
|
id: 'one', chatUsername: 'alice', chatRoomId: 'room-a', sender: 'alice',
|
||||||
timestamp: now - 60_000, originalUrl: 'https://media.example/one.jpg', fileName: 'one.jpg',
|
timestamp: now - 60_000, originalUrl: 'https://media.example/one.jpg', fileName: 'one.jpg',
|
||||||
relativePath: 'one.jpg', createdAt: now - 50_000, urlPath: '/media/one.jpg',
|
relativePath: 'one.jpg', createdAt: now - 50_000, urlPath: '/media/one.jpg',
|
||||||
|
owner: { accountId: '@t2_bot:reddit.com', redditUsername: 'bot-account' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'two', chatUsername: 'bob', chatRoomId: 'room-b', sender: 'charlie',
|
id: 'two', chatUsername: 'bob', chatRoomId: 'room-b', sender: 'charlie',
|
||||||
@@ -52,7 +54,9 @@ function renderMedia() {
|
|||||||
});
|
});
|
||||||
const view = render(
|
const view = render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<MediaPage />
|
<MemoryRouter>
|
||||||
|
<MediaPage />
|
||||||
|
</MemoryRouter>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
);
|
);
|
||||||
return { ...view, queryClient };
|
return { ...view, queryClient };
|
||||||
@@ -124,6 +128,19 @@ describe('media page', () => {
|
|||||||
expect(screen.queryByRole('dialog', { name: 'Media from alice' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog', { name: 'Media from alice' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('links media cards to their account-owned conversations', async () => {
|
||||||
|
installMediaApi();
|
||||||
|
renderMedia();
|
||||||
|
|
||||||
|
const conversationLinks = await screen.findAllByRole('link', { name: 'Open conversation' });
|
||||||
|
expect(conversationLinks).toHaveLength(3);
|
||||||
|
expect(conversationLinks[0]).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'/conversations/%2540t2_bot%253Areddit.com~room-a',
|
||||||
|
);
|
||||||
|
expect(conversationLinks[1]).toHaveAttribute('href', '/conversations/room-b');
|
||||||
|
});
|
||||||
|
|
||||||
it('filters by conversation, sender, media type, and age with an explicit no-match state', async () => {
|
it('filters by conversation, sender, media type, and age with an explicit no-match state', async () => {
|
||||||
installMediaApi();
|
installMediaApi();
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|||||||
Reference in new issue
Block a user