import * as React from 'react'; import { createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { MemoryRouter } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import CatalogRow, { CatalogPostMedia } from '../catalog-row'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; const act = (React as { act?: (cb: () => void | Promise) => void | Promise }).act as (cb: () => void | Promise) => void | Promise; type TestComment = { author?: { address?: string; displayName?: string; }; cid: string; content?: string; commentModeration?: { archived?: boolean; }; link?: string; linkHeight?: number; linkWidth?: number; locked?: boolean; parentCid?: string; pinned?: boolean; postCid?: string; removed?: boolean; replyCount?: number; replies?: { pages?: Record< string, { comments?: TestComment[]; } >; }; spoiler?: boolean; communityAddress?: string; thumbnailUrl?: string; timestamp?: number; title?: string; }; const testState = vi.hoisted(() => ({ directories: [{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' }] as Array<{ address: string; features?: Record; title?: string; }>, gifFrameStatus: 'idle' as 'failed' | 'idle' | 'ready', gifFrameUrl: undefined as string | undefined, hiddenCids: new Set(), imageSize: 'Small' as 'Large' | 'Small', linkCount: 0, mediaInfoByLink: {} as Record, lastRepliesComment: undefined as TestComment | undefined, replies: [] as TestComment[], roleByAddress: {} as Record, showOPComment: true, showSnow: false, })); vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, }), })); vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ useReplies: ({ comment, sortType }: { comment?: TestComment; sortType?: string }) => { if (comment) { testState.lastRepliesComment = comment; } const preloadedReplies = comment?.replies?.pages?.[sortType || 'best']?.comments ?? Object.values(comment?.replies?.pages ?? {}).find((page) => page?.comments?.length)?.comments; const compatiblePreloadedReplies: TestComment[] = []; if (preloadedReplies?.length && comment?.communityAddress) { for (const reply of preloadedReplies) { if (!reply?.communityAddress || reply.communityAddress !== comment.communityAddress) { break; } compatiblePreloadedReplies.push(reply); } } return { replies: comment ? (compatiblePreloadedReplies.length ? compatiblePreloadedReplies : testState.replies) : [], }; }, })); vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/lib/localforage-lru/index.js', () => ({ default: { createInstance: () => ({ entries: vi.fn().mockResolvedValue([]), getItem: vi.fn(), removeItem: vi.fn(), setItem: vi.fn(), }), }, })); vi.mock('@floating-ui/react', () => ({ offset: () => ({}), size: () => ({}), useFloating: () => ({ floatingStyles: {}, refs: { floating: { current: null }, reference: { current: null }, setFloating: () => undefined, setReference: () => undefined, }, update: () => undefined, }), })); vi.mock('../../../lib/get-short-address', () => ({ default: () => 'mu', })); vi.mock('../../../lib/snow', () => ({ shouldShowSnow: () => testState.showSnow, })); vi.mock('../../../lib/utils/time-utils', () => ({ getFormattedTimeAgo: (timestamp?: number) => `ago:${timestamp}`, })); vi.mock('../../../lib/utils/post-menu-props', () => ({ selectPostMenuProps: (post?: TestComment) => ({ cid: post?.cid }), })); vi.mock('../../../hooks/use-directories', () => ({ findDirectoryByAddress: (directories: typeof testState.directories, address?: string) => { if (!address) { return undefined; } const normalizeBoardAddress = (value: string) => value.replace(/\.(bso|eth)$/, ''); return directories.find((entry) => entry.address === address) ?? directories.find((entry) => normalizeBoardAddress(entry.address) === normalizeBoardAddress(address)); }, normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''), useDirectories: () => testState.directories, })); vi.mock('../../../stores/use-catalog-style-store', () => ({ default: () => ({ imageSize: testState.imageSize, showOPComment: testState.showOPComment, }), })); vi.mock('../../../hooks/use-author-privileges', () => ({ default: ({ commentAuthorAddress }: { commentAuthorAddress?: string }) => testState.roleByAddress[commentAuthorAddress || ''] || { commentAuthorRole: undefined, isCommentAuthorMod: false, }, })); vi.mock('../../../hooks/use-comment-media-info', () => ({ useCommentMediaInfo: (link?: string) => (link ? testState.mediaInfoByLink[link] : undefined), })); vi.mock('../../../hooks/use-count-links-in-replies', () => ({ default: () => testState.linkCount, })); vi.mock('../../../hooks/use-fetch-gif-first-frame', () => ({ default: () => ({ frameUrl: testState.gifFrameUrl, status: testState.gifFrameStatus, }), })); vi.mock('../../../hooks/use-hide', () => ({ default: ({ cid }: { cid: string }) => ({ hidden: testState.hiddenCids.has(cid), }), })); vi.mock('../../post-desktop/post-menu-desktop', () => ({ default: ({ postMenu }: { postMenu: { cid?: string } }) => createElement('span', { 'data-testid': `post-menu-${postMenu.cid}` }, 'menu'), })); let container: HTMLDivElement; let root: Root; const flushEffects = async (count = 3) => { for (let i = 0; i < count; i += 1) { await act(async () => { await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); }); } }; const renderWithRouter = async (element: React.ReactNode, initialEntry = '/mu/catalog') => { await act(async () => { root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, element)); }); await flushEffects(); }; describe('CatalogRow', () => { beforeEach(() => { vi.clearAllMocks(); vi.useRealTimers(); testState.directories = [{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' }]; testState.gifFrameStatus = 'idle'; testState.gifFrameUrl = undefined; testState.hiddenCids = new Set(); testState.imageSize = 'Small'; testState.linkCount = 0; testState.mediaInfoByLink = {}; testState.lastRepliesComment = undefined; testState.replies = []; testState.roleByAddress = {}; testState.showOPComment = true; testState.showSnow = false; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { vi.useRealTimers(); act(() => root.unmount()); container.remove(); }); it('renders gif frames with matched filter borders and falls back to deleted media on load errors', async () => { testState.gifFrameStatus = 'ready'; testState.gifFrameUrl = 'https://cdn.example/frame.png'; await act(async () => { root.render( createElement(CatalogPostMedia, { cid: 'post-1', commentMediaInfo: { type: 'gif', url: 'https://example.com/source.gif' }, linkHeight: 200, linkWidth: 400, matchedFilterColor: 'red', }), ); }); const wrapper = container.firstElementChild as HTMLElement | null; const frameImage = container.querySelector('img[src="https://cdn.example/frame.png"]'); expect(wrapper?.style.border).toContain('red'); expect(frameImage).toBeTruthy(); expect(frameImage?.getAttribute('width')).toBe('150'); expect(frameImage?.getAttribute('height')).toBe('75'); await act(async () => { frameImage?.dispatchEvent(new Event('error', { bubbles: true })); }); expect(container.querySelector('img[src="assets/filedeleted-res.gif"]')).toBeTruthy(); }); it('renders the archived icon for archived threads', async () => { const post: TestComment = { author: { address: 'author-1', displayName: 'Alice' }, cid: 'post-archived', commentModeration: { archived: true, }, content: 'Archived thread', replyCount: 3, communityAddress: 'music-posting.eth', title: 'Old thread', }; testState.mediaInfoByLink['https://example.com/media.png'] = { type: 'image', url: 'https://example.com/media.png' }; await renderWithRouter(createElement(CatalogRow, { row: [post] })); expect(container.querySelector('[title="archived"]')).toBeTruthy(); }); it('renders audio players and video first-frame fallbacks for media without thumbnails', async () => { await act(async () => { root.render( createElement(CatalogPostMedia, { cid: 'audio-post', commentMediaInfo: { type: 'audio', url: 'https://example.com/file.mp3' }, }), ); }); expect(container.querySelector('audio[src="https://example.com/file.mp3"]')).toBeTruthy(); await act(async () => { root.render( createElement(CatalogPostMedia, { cid: 'video-post', commentMediaInfo: { type: 'video', url: 'https://example.com/file.mp4' }, }), ); }); expect(container.querySelector('video[src="https://example.com/file.mp4#t=0.001"]')).toBeTruthy(); }); it('does not force square dimensions on images when media metadata is missing', async () => { await act(async () => { root.render( createElement(CatalogPostMedia, { cid: 'image-post', commentMediaInfo: { type: 'image', url: 'https://example.com/file.png' }, }), ); }); const image = container.querySelector('img[src="https://example.com/file.png"]'); expect(image).toBeTruthy(); expect(image?.getAttribute('width')).toBeNull(); expect(image?.getAttribute('height')).toBeNull(); }); it('renders media posts with board links, counts, and hover previews in all view', async () => { testState.directories = [{ address: 'music-posting.eth', features: { requirePostLinkIsMedia: true }, title: '/mu/ - Music' }]; testState.linkCount = 2; testState.mediaInfoByLink['https://example.com/media.png'] = { type: 'image', url: 'https://example.com/media.png' }; testState.replies = [ { author: { address: 'author-2', displayName: 'Bob' }, cid: 'reply-1', timestamp: 200, }, ]; testState.roleByAddress = { 'author-1': { commentAuthorRole: 'Owner', isCommentAuthorMod: true }, 'author-2': { commentAuthorRole: 'Janitor', isCommentAuthorMod: true }, }; const post: TestComment = { author: { address: 'author-1', displayName: 'Alice' }, cid: 'post-1', content: 'Hello **world**', link: 'https://example.com/media.png', linkHeight: 120, linkWidth: 200, locked: true, pinned: true, replyCount: 5, communityAddress: 'music-posting.eth', timestamp: 100, title: 'Thread title', }; await renderWithRouter(createElement(CatalogRow, { row: [post] }), '/all/catalog'); vi.useFakeTimers(); const postLink = document.body.querySelector('a[href="/mu/thread/post-1"]'); expect(postLink).toBeTruthy(); expect(container.textContent).toContain('R: 5'); expect(container.textContent).toContain('/ I: 2'); expect(container.querySelector('[data-testid="post-menu-post-1"]')?.textContent).toBe('menu'); const previewTrigger = document.body.querySelector('a[href="/mu/thread/post-1"] > div'); await act(async () => { previewTrigger?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); vi.advanceTimersByTime(260); await Promise.resolve(); }); expect(document.body.textContent).toContain('Thread title by Alice ## Board Owner'); expect(document.body.textContent).toContain('to p/mu'); expect(document.body.textContent).toContain('last_reply_by Bob ## Board Janitor'); expect(document.body.textContent).toContain('ago:100'); expect(document.body.textContent).toContain('ago:200'); }); it('uses alias-aware board features when deciding whether reply links are media', async () => { testState.directories = [{ address: 'music-posting.bso', features: { requirePostLinkIsMedia: true }, title: '/mu/ - Music' }]; testState.linkCount = 3; testState.mediaInfoByLink['https://example.com/media.png'] = { type: 'image', url: 'https://example.com/media.png' }; const post: TestComment = { author: { address: 'author-1', displayName: 'Alice' }, cid: 'post-alias', content: 'Alias test', link: 'https://example.com/media.png', replyCount: 4, communityAddress: 'music-posting.eth', title: 'Alias title', }; await renderWithRouter(createElement(CatalogRow, { row: [post] }), '/mu/catalog'); expect(container.textContent).toContain('R: 4'); expect(container.textContent).toContain('/ I: 3'); expect(container.textContent).not.toContain('/ L: 3'); expect(document.body.querySelector('a[href="/mu/thread/post-alias"]')).toBeTruthy(); expect(container.querySelector('[title="(R)eplies / (I)mage Replies"]')).toBeTruthy(); }); it('normalizes legacy board addresses before fetching hover preview replies', async () => { testState.directories = [{ address: 'music-posting.eth', features: {}, title: '/mu/ - Music' }]; testState.mediaInfoByLink['https://example.com/legacy.png'] = { type: 'image', url: 'https://example.com/legacy.png' }; testState.replies = []; const post: TestComment = { author: { address: 'author-1', displayName: 'Alice' }, cid: 'post-legacy', content: 'Legacy address thread', link: 'https://example.com/legacy.png', replyCount: 1, replies: { pages: { new: { comments: [ { author: { address: 'author-2', displayName: 'Bob' }, cid: 'reply-legacy', communityAddress: 'music-posting.eth', timestamp: 200, }, ], }, }, }, communityAddress: 'music-posting.eth', timestamp: 100, title: 'Legacy title', }; await renderWithRouter(createElement(CatalogRow, { row: [post] }), '/all/catalog'); vi.useFakeTimers(); const previewTrigger = document.body.querySelector('a[href="/mu/thread/post-legacy"] > div'); await act(async () => { previewTrigger?.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); vi.advanceTimersByTime(260); await Promise.resolve(); }); expect(testState.lastRepliesComment?.communityAddress).toBe('music-posting.eth'); expect(testState.lastRepliesComment?.replies?.pages?.new?.comments?.[0]?.communityAddress).toBe('music-posting.eth'); expect(document.body.textContent).toContain('Legacy title by Alice'); expect(document.body.textContent).toContain('last_reply_by Bob'); }); it('renders hidden and text-only threads with canonical board thread links', async () => { testState.hiddenCids = new Set(['hidden-1']); testState.showOPComment = false; const posts: TestComment[] = [ { author: { address: 'hidden-author', displayName: 'Ghost' }, cid: 'hidden-1', content: 'hidden text', link: 'https://example.com/hidden.png', communityAddress: 'music-posting.eth', }, { author: { address: 'text-author', displayName: 'Anon' }, cid: 'text-1', content: 'Plain thread body', replyCount: 1, communityAddress: 'music-posting.eth', title: 'Text title', }, ]; await renderWithRouter(createElement(CatalogRow, { row: posts }), '/mu/catalog'); const links = Array.from(document.body.querySelectorAll('a')).map((link) => link.getAttribute('href')); expect(links).toContain('/mu/thread/hidden-1'); expect(links).toContain('/mu/thread/text-1'); expect(container.textContent).toContain('(hidden)'); expect(container.textContent).toContain('Text title: Plain thread body'); }); it('applies the estimated row height to the virtualization wrapper', async () => { const post: TestComment = { cid: 'estimated-post', content: 'Estimated row body', communityAddress: 'music-posting.eth', }; await renderWithRouter(createElement(CatalogRow, { estimatedHeight: 246, row: [post] }), '/mu/catalog'); expect(container.querySelector('[data-pretext-height="246"]')).toBeTruthy(); }); });