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 PostDesktop from '../post-desktop'; import PostMobile from '../post-mobile'; (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; shortAddress?: string; }; cid?: string; communityAddress?: string; content?: string; deleted?: boolean; index?: number; link?: string; linkHeight?: number; linkWidth?: number; number?: number; parentCid?: string; pinned?: boolean; postCid?: string; removed?: boolean; replyCount?: number; replies?: { pages?: Record< string, { comments?: TestComment[]; } >; }; state?: string; thumbnailUrl?: string; timestamp?: number; updatedAt?: number; }; const testState = vi.hoisted(() => ({ addChallengeMock: vi.fn(), hasMoreReplies: false, openReplyModalMock: vi.fn(), replyComments: [] as Array, setResetFunctionMock: vi.fn(), virtuosoProps: [] as Array<{ defaultItemHeight?: number; heightEstimates?: number[]; itemSize?: unknown }>, })); const getMockPreloadedReplies = (comment?: TestComment, sortType?: string) => { if (!comment) { return []; } const preloadedReplies = comment.replies?.pages?.[sortType || 'best']?.comments ?? Object.values(comment.replies?.pages ?? {}).find((page) => page?.comments?.length)?.comments ?? []; const compatibleReplies: TestComment[] = []; for (const reply of preloadedReplies) { if (!reply?.communityAddress || reply.communityAddress !== comment.communityAddress) { break; } compatibleReplies.push(reply); } return compatibleReplies; }; vi.mock('react-i18next', () => ({ Trans: ({ i18nKey, values }: { i18nKey?: string; values?: Record }) => createElement('span', {}, `${i18nKey ?? 'trans'}:${JSON.stringify(values ?? {})}`), useTranslation: () => ({ t: (key: string) => key, }), })); vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ useAccount: () => ({ author: { address: '0xviewer' } }), useAccountComment: () => undefined, useEditedComment: () => ({ editedComment: undefined }), usePublishCommentModeration: () => ({ error: undefined, publishCommentModeration: vi.fn(), state: 'initializing', }), useReplies: ({ comment, sortType }: { comment?: TestComment; sortType?: string }) => { testState.replyComments.push(comment); return { hasMore: testState.hasMoreReplies, loadMore: vi.fn(), replies: getMockPreloadedReplies(comment, sortType), }; }, })); vi.mock('react-virtuoso', () => ({ Virtuoso: React.forwardRef( ( { components, data = [], defaultItemHeight, heightEstimates, itemSize, itemContent, }: { components?: { Footer?: React.ComponentType }; data?: TestComment[]; defaultItemHeight?: number; heightEstimates?: number[]; itemSize?: unknown; itemContent: (index: number, item: TestComment) => React.ReactNode; }, ref: React.ForwardedRef<{ getState: (cb: (snapshot: { ranges: number[]; scrollTop: number }) => void) => void }>, ) => { testState.virtuosoProps.push({ defaultItemHeight, heightEstimates, itemSize }); React.useImperativeHandle(ref, () => ({ getState: (cb) => cb({ ranges: [0], scrollTop: 0 }), })); return createElement( 'div', { 'data-testid': 'virtuoso' }, data.map((item, index) => createElement('div', { key: item.cid ?? index }, itemContent(index, item))), components?.Footer ? createElement(components.Footer) : null, ); }, ), })); vi.mock('../../lib/get-short-address', () => ({ default: (value?: string) => (value ? value.slice(0, 4) : ''), })); vi.mock('../../views/post/post.module.css', () => ({ default: new Proxy( {}, { get: (_target, property) => String(property), }, ), })); vi.mock('../../lib/utils/media-utils', () => ({ getDisplayMediaInfoType: (type?: string) => type ?? 'unknown', getHasThumbnail: () => true, getMediaDimensions: () => '100x100', })); vi.mock('../../lib/utils/post-utils', () => ({ getTextColorForBackground: () => '#fff', hashStringToColor: () => '#000', })); vi.mock('../../lib/utils/time-utils', () => ({ getFormattedDate: () => '2026-03-13', getFormattedTimeAgo: () => 'moments ago', })); vi.mock('../../lib/utils/pending-approval-moderation', () => ({ approvePendingCommentModeration: {}, isPendingApprovalRejected: () => false, rejectPendingCommentModeration: {}, })); vi.mock('../../lib/utils/url-utils', () => ({ isValidURL: () => true, })); vi.mock('../../lib/utils/view-utils', () => ({ isAllView: (pathname: string) => pathname === '/all', isModQueueView: () => false, isModView: () => false, isPendingPostView: () => false, isPostPageView: (pathname: string) => pathname.includes('/thread/'), isSubscriptionsView: () => false, })); vi.mock('../../stores/use-mod-queue-store', () => ({ default: (selector?: (state: { getAlertThresholdSeconds: () => number }) => unknown) => { const state = { getAlertThresholdSeconds: () => 0, }; return selector ? selector(state) : state; }, })); vi.mock('../../hooks/use-directories', () => ({ findDirectoryByAddress: (_directories: unknown[], address?: string) => (address ? { address, features: {} } : undefined), useDirectories: () => [{ address: 'music-posting.eth', title: '/mu/ - Music' }], })); vi.mock('../../lib/utils/route-utils', () => ({ getBoardPath: (address?: string) => (address ? 'mu' : undefined), })); vi.mock('../../hooks/use-author-address-click', () => ({ default: () => vi.fn(), })); vi.mock('../../hooks/use-comment-media-info', () => ({ useCommentMediaInfo: (link?: string) => (link ? { type: 'image', url: link } : undefined), })); vi.mock('../../hooks/use-count-links-in-replies', () => ({ default: () => 0, })); vi.mock('../../hooks/use-fetch-gif-first-frame', () => ({ default: () => ({ status: 'idle', }), })); vi.mock('../../hooks/use-hide', () => ({ default: () => ({ hidden: false, hide: vi.fn(), unhide: vi.fn(), }), })); vi.mock('../../hooks/use-state-string', () => ({ default: () => undefined, })); vi.mock('../../hooks/use-scroll-to-reply', () => ({ default: () => undefined, })); vi.mock('../../hooks/use-current-time', () => ({ useCurrentTime: () => 1_710_000_000, })); vi.mock('../../hooks/use-board-pseudonymity-mode', () => ({ useBoardPseudonymityMode: () => 'none', })); vi.mock('../comment-content', () => ({ default: ({ comment }: { comment?: TestComment }) => createElement('div', { 'data-testid': 'comment-content' }, comment?.cid ?? 'missing'), })); vi.mock('../comment-media', () => ({ default: () => createElement('div', { 'data-testid': 'comment-media' }, 'media'), })); vi.mock('../edit-menu/edit-menu', () => ({ default: () => createElement('div', { 'data-testid': 'edit-menu' }, 'edit'), })); vi.mock('../failed-publish-notice', () => ({ default: () => createElement('div', { 'data-testid': 'failed-publish-notice' }, 'failed-publish-notice'), })); vi.mock('../embed', () => ({ canEmbed: () => false, })); vi.mock('../loading-ellipsis', () => ({ default: ({ string }: { string: string }) => createElement('div', { 'data-testid': 'loading-ellipsis' }, string), })); vi.mock('../post-desktop/post-menu-desktop', () => ({ default: ({ postMenu }: { postMenu: { communityAddress?: string } }) => createElement('div', { 'data-testid': 'post-menu-desktop' }, postMenu.communityAddress ?? 'missing'), })); vi.mock('../reply-quote-preview', () => ({ default: ({ backlinkReply }: { backlinkReply?: TestComment }) => createElement('div', { 'data-testid': 'reply-quote-preview' }, backlinkReply?.cid ?? 'missing'), })); vi.mock('../tooltip', () => ({ default: ({ children }: { children?: React.ReactNode }) => createElement(React.Fragment, {}, children), })); vi.mock('../../lib/snow', () => ({ shouldShowSnow: () => false, })); vi.mock('../../stores/use-reply-modal-store', () => ({ default: () => ({ openReplyModal: testState.openReplyModalMock, }), })); vi.mock('../../stores/use-challenges-store', () => ({ default: { getState: () => ({ addChallenge: testState.addChallengeMock, }), }, })); vi.mock('../../stores/use-feed-reset-store', () => ({ default: (selector: (state: { setResetFunction: typeof testState.setResetFunctionMock }) => unknown) => selector({ setResetFunction: testState.setResetFunctionMock, }), })); vi.mock('../../hooks/use-register-fresh-replies', () => ({ default: () => undefined, })); vi.mock('../../lib/utils/challenge-utils', () => ({ alertChallengeVerificationFailed: vi.fn(), })); vi.mock('../../hooks/use-quoted-by-map', () => ({ default: () => new Map(), })); vi.mock('../../hooks/use-progressive-render', () => ({ default: (replies: TestComment[]) => replies, })); vi.mock('../../hooks/use-fresh-replies', () => ({ default: (replies: TestComment[]) => replies, })); vi.mock('../../hooks/use-reply-height-estimates', () => ({ default: ({ isMobile, replies }: { isMobile: boolean; replies: TestComment[] }) => ({ defaultItemHeight: isMobile ? 222 : 111, heightEstimates: replies.map((_, index) => (isMobile ? 200 : 100) + index), itemSize: vi.fn(), }), })); vi.mock('../../lib/constants', () => ({ BOARD_REPLIES_PREVIEW_FETCH_SIZE: 5, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT: 3, REPLIES_PER_PAGE: 20, })); vi.mock('../../lib/utils/replies-preview-utils', () => ({ computeOmittedCount: () => 0, filterRepliesForDisplay: (replies: TestComment[]) => replies, getPreviewDisplayReplies: (replies: TestComment[]) => replies, getTotalReplyCount: ({ replyCount }: { replyCount?: number }) => replyCount ?? 0, hasEnoughPreviewReplies: ({ replyCount, loadedCount, visibleCount }: { replyCount?: number; loadedCount: number; visibleCount: number }) => loadedCount >= Math.min(visibleCount, replyCount ?? visibleCount), })); vi.mock('../../lib/utils/thread-scroll-utils', () => ({ getThreadTopNavigationState: () => undefined, scrollThreadContainerToTop: () => true, })); vi.mock('../../hooks/use-delete-failed-post', () => ({ default: () => ({ canDeleteFailedPost: false, canRetryFailedPost: false, isDeletingFailedPost: false, isRetryingFailedPost: false, onDeleteFailedPost: vi.fn(), onRetryFailedPost: vi.fn(), }), })); vi.mock('../post-mobile/post-menu-mobile', () => ({ default: ({ postMenu }: { postMenu: { communityAddress?: string } }) => createElement('div', { 'data-testid': 'post-menu-mobile' }, postMenu.communityAddress ?? 'missing'), })); vi.mock('../../lib/utils/reply-backlink-utils', () => ({ getRenderableMobileBacklinks: () => ({ directReplyBacklinks: [], opBacklinks: [], quotedReplyBacklinks: [], }), })); 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 renderWithRoute = async (element: React.ReactNode, initialEntry = '/all') => { await act(async () => { root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, element)); }); await flushEffects(); }; const makeLegacyThread = (): TestComment => ({ author: { address: '0xauthor', shortAddress: 'anon' }, cid: 'post-1', content: 'Original post', link: 'https://example.com/file.png', linkHeight: 100, linkWidth: 100, number: 1, postCid: 'post-1', replyCount: 1, replies: { pages: { new: { comments: [ { author: { address: '0xreply', shortAddress: 'reply' }, cid: 'reply-1', content: 'Reply', number: 2, parentCid: 'post-1', postCid: 'post-1', communityAddress: 'music-posting.eth', }, ], }, }, }, communityAddress: 'music-posting.eth', timestamp: 1_710_000_000, }); const makeLegacyThreadWithoutReplies = (): TestComment => ({ ...makeLegacyThread(), replyCount: 0, replies: { pages: { new: { comments: [], }, }, }, }); describe('post community address compatibility', () => { beforeEach(() => { vi.clearAllMocks(); testState.hasMoreReplies = false; testState.replyComments = []; testState.virtuosoProps = []; container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); container.remove(); }); it('renders desktop multiboard posts with only communityAddress and still fetches replies', async () => { await renderWithRoute(createElement(PostDesktop, { post: makeLegacyThread() })); const primaryRepliesComment = testState.replyComments.find((comment) => comment?.cid === 'post-1'); expect(primaryRepliesComment?.communityAddress).toBe('music-posting.eth'); expect(primaryRepliesComment?.replies?.pages?.new?.comments?.[0]?.communityAddress).toBe('music-posting.eth'); expect(container.querySelector('[data-testid="post-menu-desktop"]')?.textContent).toBe('music-posting.eth'); expect(document.body.querySelector('a[href="/mu"]')?.textContent).toContain('mu'); expect(container.querySelector('[data-testid="comment-media"]')).toBeTruthy(); expect(container.textContent).toContain('reply-1'); }); it('renders mobile multiboard posts with only communityAddress and still fetches replies', async () => { await renderWithRoute(createElement(PostMobile, { post: makeLegacyThread() })); const primaryRepliesComment = testState.replyComments.find((comment) => comment?.cid === 'post-1'); expect(primaryRepliesComment?.communityAddress).toBe('music-posting.eth'); expect(primaryRepliesComment?.replies?.pages?.new?.comments?.[0]?.communityAddress).toBe('music-posting.eth'); expect(container.querySelector('[data-testid="post-menu-mobile"]')?.textContent).toBe('music-posting.eth'); expect(document.body.querySelector('a[href="/mu"]')?.textContent).toContain('Board: mu'); expect(container.querySelector('[data-testid="comment-media"]')).toBeTruthy(); expect(container.textContent).toContain('reply-1'); }); it('forwards Pretext-backed reply estimates into Virtuoso for desktop and mobile thread views', async () => { testState.hasMoreReplies = true; await renderWithRoute(createElement(PostDesktop, { post: makeLegacyThread(), showAllReplies: true }), '/mu/thread/post-1'); expect(testState.virtuosoProps.at(-1)).toEqual({ defaultItemHeight: 111, heightEstimates: [100], itemSize: expect.any(Function), }); testState.virtuosoProps = []; await renderWithRoute(createElement(PostMobile, { post: makeLegacyThread(), showAllReplies: true }), '/mu/thread/post-1'); expect(testState.virtuosoProps.at(-1)).toEqual({ defaultItemHeight: 222, heightEstimates: [200], itemSize: expect.any(Function), }); }); it('keeps board-card Pretext heights when preview replies are rendered', async () => { await renderWithRoute(createElement(PostDesktop, { post: makeLegacyThread() })); expect(container.querySelector('.postDesktop')?.getAttribute('data-pretext-height')).toBeTruthy(); await renderWithRoute(createElement(PostMobile, { post: makeLegacyThread() })); expect(container.querySelector('.postMobile')?.getAttribute('data-pretext-height')).toBeTruthy(); }); it('keeps board-card Pretext heights for simple cards without preview replies', async () => { await renderWithRoute(createElement(PostDesktop, { post: makeLegacyThreadWithoutReplies() })); expect(container.querySelector('.postDesktop')?.getAttribute('data-pretext-height')).toBeTruthy(); await renderWithRoute(createElement(PostMobile, { post: makeLegacyThreadWithoutReplies() })); expect(container.querySelector('.postMobile')?.getAttribute('data-pretext-height')).toBeTruthy(); }); });