import * as React from 'react'; import { createElement } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import communitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages'; import Board, { type BoardProps } from '../board'; import { TRASH_BOARD_ADDRESS, TRASH_BOARD_TITLE } from '../../../lib/special-boards'; import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../../../lib/utils/time-filter-utils'; (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 = { accountId?: string; author?: { address?: string; avatar?: unknown; community?: unknown; displayName?: string; flair?: unknown; shortAddress?: string; }; cid?: string; content?: string; flairs?: Array<{ text?: string }>; index?: number; lastReplyTimestamp?: number; link?: string; number?: number | string; parentCid?: string; pinned?: boolean; postNumber?: number | string; communityAddress?: string; deleted?: boolean; postCid?: string; replyCount?: number; removed?: boolean; state?: string; timestamp?: number; title?: string; upvoteCount?: number; }; type TestAccount = { author?: { address?: string; avatar?: unknown; displayName?: string; flair?: unknown; shortAddress?: string; }; id?: string; subscriptions?: string[]; }; type TestCommunity = { error?: Error; nameResolved?: boolean; updatedAt?: number; posts?: { pageCids?: Record; pages?: Record; }; shortAddress?: string; state?: string; title?: string; }; const testState = vi.hoisted(() => ({ account: { subscriptions: [] as string[] } as TestAccount, accountComments: [] as Array, accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>, accountCommunityAddresses: [] as string[], directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string; directoryCode?: string }>, directoryByAddress: { 'music-posting.eth': { address: 'music-posting.eth', features: { postsPerPage: 2 }, }, } as Record; title?: string }>, feed: [] as TestComment[], feedOptionsCalls: [] as Array<{ communities?: unknown[]; communitiesLength?: number; newerThan?: number; postsPerPage?: number; sortType?: string }>, feedState: undefined as string | undefined, feedStateString: 'syncing' as string | undefined, filteredDirectoryAddresses: ['music-posting.eth'] as string[], hasMore: false, respectPostsPerPageForNewerThan: new Set(), lastVirtuosoDefaultItemHeight: undefined as number | undefined, lastVirtuosoIncreaseViewportBy: undefined as { top: number; bottom: number } | undefined, lastVirtuosoMinOverscanItemCount: undefined as { top: number; bottom: number } | undefined, expandTimeWindowMock: vi.fn(), loadMoreMock: vi.fn(), pageSizes: { guiPostsPerPage: 2, infiniteFeedPostsPerPage: 2, maxGuiPages: 3, paginationFeedPostsPerPage: 6, }, resetMock: vi.fn(), registerCommentsMock: vi.fn(), resolvedCommunityAddress: 'music-posting.eth' as string | undefined, setEnableInfiniteScrollMock: vi.fn(), setResetFunctionMock: vi.fn(), community: { error: undefined as Error | undefined, shortAddress: 'music-posting.eth', state: 'ready', title: '/mu/ - Music', } as TestCommunity, communitySnapshot: { shortAddress: 'music-posting.eth', title: '/mu/ - Music', } as { shortAddress?: string; title?: string }, })); vi.mock('react-i18next', () => ({ Trans: ({ components, i18nKey }: { components?: Record; i18nKey: string }) => createElement(React.Fragment, {}, i18nKey, components?.[1]), useTranslation: () => ({ t: (key: string) => key, }), })); const getScopedAccountComments = (options?: { commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' }) => { let scopedComments = testState.accountComments.filter(Boolean) as TestComment[]; if (options?.commentIndices?.length) { const normalizedCommentIndices = options.commentIndices.filter((commentIndex) => Number.isInteger(commentIndex) && commentIndex >= 0); scopedComments = normalizedCommentIndices.map((commentIndex) => testState.accountComments[commentIndex]).filter(Boolean) as TestComment[]; } else if (options?.communityAddress) { scopedComments = scopedComments.filter((comment) => comment.communityAddress === options.communityAddress); } if (typeof options?.newerThan === 'number') { const newerThanTimestamp = Math.floor(Date.now() / 1000) - options.newerThan; scopedComments = scopedComments.filter((comment) => (comment.timestamp || 0) > newerThanTimestamp); } if (options?.sortType === 'new') { scopedComments = [...scopedComments].reverse(); } return scopedComments; }; const enrichAccountCommentAuthor = (comment: TestComment): TestComment => { if (comment.author?.address || !comment.accountId || comment.accountId !== testState.account.id || !testState.account.author?.address) { return comment; } const accountAuthor = testState.account.author; return { ...comment, author: { ...accountAuthor, ...comment.author, address: accountAuthor.address, ...(accountAuthor.shortAddress ? { shortAddress: accountAuthor.shortAddress } : {}), }, }; }; const getScopedFeed = (options?: { filter?: { filter: (comment: TestComment) => boolean }; newerThan?: number; postsPerPage?: number }) => { let scopedFeed = [...testState.feed]; if (typeof options?.newerThan === 'number') { const newerThanTimestamp = Math.floor(Date.now() / 1000) - options.newerThan; scopedFeed = scopedFeed.filter((comment) => (comment.timestamp ?? Math.floor(Date.now() / 1000)) > newerThanTimestamp); } if (options?.filter) { scopedFeed = scopedFeed.filter((comment) => options.filter?.filter(comment)); } if (typeof options?.postsPerPage === 'number' && testState.respectPostsPerPageForNewerThan.has(options?.newerThan ?? -1)) { scopedFeed = scopedFeed.slice(0, options.postsPerPage); } return scopedFeed; }; vi.mock('@bitsocial/bitsocial-react-hooks', () => ({ useAccount: () => testState.account, useAccountComments: (options?: { commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' }) => { testState.accountCommentsCalls.push(options); return { accountComments: getScopedAccountComments(options).map(enrichAccountCommentAuthor) }; }, useFeed: (options?: { communities?: unknown[]; filter?: { filter: (comment: TestComment) => boolean }; newerThan?: number; postsPerPage?: number; sortType?: string; }) => { testState.feedOptionsCalls.push({ communities: options?.communities, communitiesLength: options?.communities?.length, newerThan: options?.newerThan, postsPerPage: options?.postsPerPage, sortType: options?.sortType, }); return { feed: getScopedFeed(options), hasMore: testState.hasMore, state: testState.feedState ?? (testState.hasMore ? 'fetching-ipns' : 'succeeded'), expandTimeWindow: testState.expandTimeWindowMock, loadMore: testState.loadMoreMock, reset: testState.resetMock, }; }, useCommunity: () => testState.community, })); vi.mock('../../../hooks/use-stable-community', () => ({ useCommunityField: (_address: string | undefined, selector: (community: typeof testState.communitySnapshot) => unknown) => selector(testState.communitySnapshot), })); vi.mock('react-virtuoso', () => ({ Virtuoso: React.forwardRef( ( { components, data = [], defaultItemHeight, increaseViewportBy, minOverscanItemCount, endReached, itemContent, }: { components?: { Footer?: React.ComponentType }; data?: TestComment[]; defaultItemHeight?: number; increaseViewportBy?: { top: number; bottom: number }; minOverscanItemCount?: { top: number; bottom: number }; endReached?: ((index: number) => void) | undefined; itemContent: (index: number, item: TestComment) => React.ReactNode; }, ref: React.ForwardedRef<{ getState: (cb: (snapshot: { ranges: number[]; scrollTop: number }) => void) => void }>, ) => { testState.lastVirtuosoDefaultItemHeight = defaultItemHeight; testState.lastVirtuosoIncreaseViewportBy = increaseViewportBy; testState.lastVirtuosoMinOverscanItemCount = minOverscanItemCount; React.useImperativeHandle(ref, () => ({ getState: (cb) => cb({ ranges: [0], scrollTop: 42 }), })); return createElement( 'div', { 'data-testid': 'virtuoso' }, data.map((item, index) => createElement('div', { key: item.cid ?? index }, itemContent(index, item))), endReached ? createElement('button', { 'data-testid': 'end-reached', onClick: () => endReached(data.length) }, 'end-reached') : null, components?.Footer ? createElement(components.Footer) : null, ); }, ), })); vi.mock('../../../hooks/use-account-community-addresses', () => ({ useAccountCommunityAddresses: () => testState.accountCommunityAddresses, })); vi.mock('../../../hooks/use-directories', () => ({ useDirectories: () => testState.directories, useDirectoryAddresses: () => testState.directories.map((entry) => entry.address), useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryByAddress[address] : undefined), findDirectoryByAddress: (directories: Array<{ address: string; title?: string; directoryCode?: string }>, address: string | undefined) => directories.find((entry) => entry.address === address || entry.directoryCode === address || entry.title === address), })); vi.mock('../../../hooks/use-filtered-directory-addresses', () => ({ useFilteredDirectoryAddresses: () => testState.filteredDirectoryAddresses, })); vi.mock('../../../hooks/use-resolved-community-address', () => ({ useResolvedCommunityAddress: () => testState.resolvedCommunityAddress, })); vi.mock('../../../hooks/use-state-string', () => ({ useFeedStateString: () => testState.feedStateString, })); vi.mock('../../../stores/use-feed-reset-store', () => ({ default: (selector: (state: { setResetFunction: typeof testState.setResetFunctionMock }) => unknown) => selector({ setResetFunction: testState.setResetFunctionMock, }), })); vi.mock('../../../stores/use-feed-view-settings-store', () => ({ default: (selector: (state: { enableInfiniteScroll: boolean; setEnableInfiniteScroll: typeof testState.setEnableInfiniteScrollMock }) => unknown) => selector({ enableInfiniteScroll: false, setEnableInfiniteScroll: testState.setEnableInfiniteScrollMock, }), })); vi.mock('../../../stores/use-post-number-store', () => ({ default: (selector: (state: { registerComments: typeof testState.registerCommentsMock }) => unknown) => selector({ registerComments: testState.registerCommentsMock, }), })); vi.mock('../../../hooks/use-board-feed-page-size', () => ({ useBoardFeedPageSize: () => testState.pageSizes, })); vi.mock('../../../components/error-display/error-display', () => ({ default: ({ error }: { error?: Error }) => createElement('div', { 'data-testid': 'error-display' }, error?.message || 'no-error'), })); vi.mock('../../../components/loading-ellipsis/loading-ellipsis', () => ({ default: ({ string }: { string: string }) => createElement('div', { 'data-testid': 'loading-ellipsis' }, string), })); vi.mock('../../../components/board-pagination/board-pagination', () => ({ default: ({ basePath, currentPage, totalPages }: { basePath: string; currentPage: number; totalPages: number }) => createElement('div', { 'data-testid': 'board-pagination' }, `${basePath}:${currentPage}:${totalPages}`), })); vi.mock('../../../components/board-buttons/board-buttons', () => ({ CatalogButton: ({ address }: { address?: string }) => createElement('div', { 'data-testid': 'catalog-button' }, address || 'catalog'), })); vi.mock('../../../components/footer/footer', () => ({ PageFooterDesktop: ({ firstRow }: { firstRow: React.ReactNode }) => createElement('div', { 'data-testid': 'footer-desktop' }, firstRow), PageFooterMobile: ({ children }: { children: React.ReactNode }) => createElement('div', { 'data-testid': 'footer-mobile' }, children), })); vi.mock('../../post/post', () => ({ Post: ({ post }: { post?: TestComment }) => createElement( 'div', { 'data-author-address': post?.author?.address || '', 'data-content': post?.content || '', 'data-testid': 'post', }, post?.cid || post?.content || 'missing-post', ), })); vi.mock('../../../lib/snow', () => ({ shouldShowSnow: () => false, })); let container: HTMLDivElement; let latestLocation = ''; let root: Root; const LocationProbe = () => { const location = useLocation(); React.useLayoutEffect(() => { latestLocation = location.pathname; }, [location.pathname]); return null; }; const flushEffects = async (count = 5) => { for (let i = 0; i < count; i += 1) { await act(async () => { await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); }); } }; const markRawBoardThreadsFullyLoaded = (comments: TestComment[] = [], options: { hasCommunityUpdate?: boolean } = {}) => { testState.community = { ...testState.community, ...(options.hasCommunityUpdate ? { updatedAt: 1781773422 } : {}), posts: { ...testState.community?.posts, pages: { ...testState.community?.posts?.pages, active: { comments, }, }, }, }; }; const renderBoard = async ({ boardProps, initialEntry, initialState, routePath, }: { boardProps?: BoardProps; initialEntry: string; initialState?: unknown; routePath: string; }) => { latestLocation = initialEntry; const initialEntries = initialState === undefined ? [initialEntry] : [{ pathname: initialEntry, state: initialState }]; await act(async () => { root.render( createElement( MemoryRouter, { initialEntries }, createElement( Routes, {}, createElement(Route, { path: routePath, element: createElement(Board, boardProps) }), createElement(Route, { path: '*', element: createElement(Board, boardProps) }), ), createElement(LocationProbe), ), ); }); await flushEffects(); }; describe('Board', () => { beforeEach(() => { vi.clearAllMocks(); latestLocation = ''; testState.account = { subscriptions: [] }; testState.accountComments = []; testState.accountCommentsCalls = []; testState.accountCommunityAddresses = []; testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }]; testState.directoryByAddress = { 'music-posting.eth': { address: 'music-posting.eth', features: { postsPerPage: 2 }, }, }; testState.feed = []; testState.feedOptionsCalls = []; testState.feedState = undefined; testState.feedStateString = 'syncing'; testState.filteredDirectoryAddresses = ['music-posting.eth']; testState.hasMore = false; testState.respectPostsPerPageForNewerThan = new Set(); testState.lastVirtuosoDefaultItemHeight = undefined; testState.lastVirtuosoIncreaseViewportBy = undefined; testState.lastVirtuosoMinOverscanItemCount = undefined; testState.pageSizes = { guiPostsPerPage: 2, infiniteFeedPostsPerPage: 2, maxGuiPages: 3, paginationFeedPostsPerPage: 6, }; testState.resolvedCommunityAddress = 'music-posting.eth'; testState.community = { error: undefined, shortAddress: 'music-posting.eth', state: 'ready', title: '/mu/ - Music', }; testState.communitySnapshot = { shortAddress: 'music-posting.eth', title: '/mu/ - Music', }; testState.loadMoreMock.mockReset(); testState.resetMock.mockReset(); testState.registerCommentsMock.mockReset(); testState.setEnableInfiniteScrollMock.mockReset(); testState.setResetFunctionMock.mockReset(); document.title = 'before'; clearStableLastVisitTimeFilterName(); localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now())); communitiesPagesStore.setState({ communitiesPages: {}, comments: {} }); Object.defineProperty(window, 'scrollTo', { configurable: true, value: vi.fn(), writable: true, }); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); }); afterEach(() => { act(() => root.unmount()); container.remove(); communitiesPagesStore.setState({ communitiesPages: {}, comments: {} }); clearStableLastVisitTimeFilterName(); localStorage.clear(); }); it('uses the resolved directory winner for cached board feeds', async () => { testState.directories = [{ address: 'business-and-finance.bso', directoryCode: 'biz', title: '/biz/ - Business & Finance' }]; testState.directoryByAddress = { 'bizraelis.bso': { address: 'bizraelis.bso', features: { postsPerPage: 2 }, }, }; testState.resolvedCommunityAddress = 'bizraelis.bso'; await renderBoard({ boardProps: { boardIdentifier: 'biz', viewType: 'board' }, initialEntry: '/biz', routePath: '/:boardIdentifier/*', }); expect(testState.feedOptionsCalls.map((call) => call.communities)).toContainEqual([ { name: 'bizraelis.bso', publicKey: '12D3KooWR7nTdKZqZ1twGWMfVsXYDGp1XAKUrnYznKP651jFrizE', }, ]); }); it('warns when a loaded board address is not verified', async () => { testState.directories = [{ address: 'business-and-finance.bso', directoryCode: 'biz', title: '/biz/ - Business & Finance' }]; testState.directoryByAddress = { 'business-and-finance.bso': { address: 'business-and-finance.bso', features: { postsPerPage: 2 }, }, }; testState.resolvedCommunityAddress = 'business-and-finance.bso'; testState.community = { nameResolved: false, shortAddress: 'business-and-finance.bso', state: 'ready', title: '/biz/ - Business & Finance', }; testState.communitySnapshot = { shortAddress: 'business-and-finance.bso', title: '/biz/ - Business & Finance', }; await renderBoard({ boardProps: { boardIdentifier: 'business-and-finance.bso', viewType: 'board' }, initialEntry: '/business-and-finance.bso', routePath: '/:boardIdentifier/*', }); expect(container.textContent).toContain('board_address_unverified_warning'); }); it('does not warn when a loaded board address is verified', async () => { testState.directories = [{ address: 'business-and-finance.bso', directoryCode: 'biz', title: '/biz/ - Business & Finance' }]; testState.directoryByAddress = { 'business-and-finance.bso': { address: 'business-and-finance.bso', features: { postsPerPage: 2 }, }, }; testState.resolvedCommunityAddress = 'business-and-finance.bso'; testState.community = { nameResolved: true, shortAddress: 'business-and-finance.bso', state: 'ready', title: '/biz/ - Business & Finance', }; testState.communitySnapshot = { shortAddress: 'business-and-finance.bso', title: '/biz/ - Business & Finance', }; await renderBoard({ boardProps: { boardIdentifier: 'business-and-finance.bso', viewType: 'board' }, initialEntry: '/business-and-finance.bso', routePath: '/:boardIdentifier/*', }); expect(container.textContent).not.toContain('board_address_unverified_warning'); }); it('uses hidden special board metadata for the page title', async () => { testState.directories = []; testState.directoryByAddress = {}; testState.resolvedCommunityAddress = TRASH_BOARD_ADDRESS; testState.communitySnapshot = {}; await renderBoard({ boardProps: { boardIdentifier: 'trash', viewType: 'board' }, initialEntry: '/trash', routePath: '/:boardIdentifier/*', }); expect(document.title).toBe(`${TRASH_BOARD_TITLE} - 5chan`); }); it('renders the current page feed, inserts recent account comments, and wires footer actions', async () => { const currentTimestamp = Math.floor(Date.now() / 1000); testState.feed = [ { cid: 'pinned-post', pinned: true, communityAddress: 'music-posting.eth' }, { cid: 'older-post', communityAddress: 'music-posting.eth' }, { cid: 'oldest-post', communityAddress: 'music-posting.eth' }, ]; testState.accountComments = [ { cid: 'fresh-post', postCid: 'fresh-post', state: 'succeeded', communityAddress: 'music-posting.eth', timestamp: currentTimestamp, }, { cid: 'fresh-reply', postCid: 'another-post', state: 'succeeded', communityAddress: 'music-posting.eth', timestamp: currentTimestamp, }, ]; testState.hasMore = true; await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); expect(document.title).toBe('/mu/ - 5chan'); expect(testState.setResetFunctionMock).toHaveBeenCalledWith(expect.any(Function)); expect(testState.accountCommentsCalls).toContainEqual({ communityAddress: 'music-posting.eth', newerThan: 3600, sortType: 'old', }); expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['pinned-post', 'fresh-post']); expect(container.querySelector('[data-testid="board-pagination"]')?.textContent).toBe('/mu:1:2'); await act(async () => { const topButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'top'); topButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(window.scrollTo).toHaveBeenCalledWith({ behavior: 'instant', left: 0, top: 0 }); await act(async () => { const refreshButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'refresh'); refreshButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); const loadMoreButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'load_more'); loadMoreButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(testState.resetMock).toHaveBeenCalledTimes(2); expect(testState.setEnableInfiniteScrollMock).toHaveBeenCalledWith(true); }); it('does not render recent account comments as the whole board while the feed is still hydrating', async () => { const currentTimestamp = Math.floor(Date.now() / 1000); testState.feedState = 'succeeded'; testState.feedStateString = undefined; testState.accountComments = [ { cid: 'fresh-post', postCid: 'fresh-post', state: 'succeeded', communityAddress: 'music-posting.eth', timestamp: currentTimestamp, }, ]; await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); expect(container.querySelector('[data-testid="post"]')).toBeNull(); expect(container.textContent).not.toContain('no_threads'); expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('downloading_board'); }); it('hides local account comments while a manual refresh is pending', async () => { const currentTimestamp = Math.floor(Date.now() / 1000); testState.feed = [{ cid: 'older-post', communityAddress: 'music-posting.eth', timestamp: currentTimestamp - 60 }]; testState.accountComments = [ { cid: 'fresh-post', postCid: 'fresh-post', state: 'succeeded', communityAddress: 'music-posting.eth', timestamp: currentTimestamp, }, ]; markRawBoardThreadsFullyLoaded(); await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['fresh-post', 'older-post']); let resolveManualRefresh: (() => void) | undefined; testState.resetMock.mockReset(); testState.resetMock.mockReturnValue( new Promise((resolve) => { resolveManualRefresh = resolve; }), ); await act(async () => { const refreshButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'refresh'); refreshButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(testState.resetMock).toHaveBeenCalledOnce(); expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['older-post']); await act(async () => { resolveManualRefresh?.(); await Promise.resolve(); }); await flushEffects(); expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['fresh-post', 'older-post']); }); it('keeps local account comments hidden after refresh settles until the canonical feed repopulates', async () => { const currentTimestamp = Math.floor(Date.now() / 1000); const canonicalFeed = [ { cid: 'fresh-post', communityAddress: 'music-posting.eth', postCid: 'fresh-post', timestamp: currentTimestamp }, { cid: 'older-post', communityAddress: 'music-posting.eth', timestamp: currentTimestamp - 60 }, ]; testState.feed = [canonicalFeed[1]]; testState.accountComments = [ { cid: 'fresh-post', postCid: 'fresh-post', state: 'succeeded', communityAddress: 'music-posting.eth', timestamp: currentTimestamp, }, ]; await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['fresh-post', 'older-post']); testState.resetMock.mockReset(); testState.resetMock.mockImplementation(() => { testState.feed = []; return Promise.resolve(); }); await act(async () => { const refreshButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'refresh'); refreshButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); await flushEffects(); expect(testState.resetMock).toHaveBeenCalledOnce(); expect(container.querySelector('[data-testid="post"]')).toBeNull(); expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('loading_feed'); testState.feed = canonicalFeed; await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['fresh-post', 'older-post']); expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull(); }); it('releases the manual refresh hold when feed reset rejects', async () => { const currentTimestamp = Math.floor(Date.now() / 1000); testState.feed = [{ cid: 'older-post', communityAddress: 'music-posting.eth', timestamp: currentTimestamp - 60 }]; testState.accountComments = [ { cid: 'fresh-post', postCid: 'fresh-post', state: 'succeeded', communityAddress: 'music-posting.eth', timestamp: currentTimestamp, }, ]; markRawBoardThreadsFullyLoaded(); await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['fresh-post', 'older-post']); const refreshError = new Error('reset failed'); const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); testState.resetMock.mockReset(); testState.resetMock.mockRejectedValue(refreshError); await act(async () => { const refreshButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'refresh'); refreshButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); await Promise.resolve(); }); await flushEffects(); expect(testState.resetMock).toHaveBeenCalledOnce(); expect(consoleError).toHaveBeenCalledWith('Failed to refresh board feed:', refreshError); expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['fresh-post', 'older-post']); expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull(); consoleError.mockRestore(); }); it('shows loading copy instead of no threads when refresh hides the only local thread', async () => { const currentTimestamp = Math.floor(Date.now() / 1000); testState.feedState = 'succeeded'; testState.feedStateString = undefined; testState.community = { error: undefined, posts: { pageCids: {}, pages: {}, }, shortAddress: 'music-posting.eth', state: 'succeeded', title: '/mu/ - Music', updatedAt: currentTimestamp, }; testState.accountComments = [ { cid: 'fresh-post', postCid: 'fresh-post', state: 'succeeded', communityAddress: 'music-posting.eth', timestamp: currentTimestamp, }, ]; await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['fresh-post']); expect(container.textContent).not.toContain('no_threads'); let resolveManualRefresh: (() => void) | undefined; testState.resetMock.mockReset(); testState.resetMock.mockReturnValue( new Promise((resolve) => { resolveManualRefresh = resolve; }), ); await act(async () => { const refreshButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'refresh'); refreshButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(testState.resetMock).toHaveBeenCalledOnce(); expect(container.querySelector('[data-testid="post"]')).toBeNull(); expect(container.textContent).not.toContain('no_threads'); expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('loading_feed'); await act(async () => { resolveManualRefresh?.(); await Promise.resolve(); }); await flushEffects(); expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['fresh-post']); expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull(); }); it('restores active account author data on local board posts before rendering', async () => { const currentTimestamp = Math.floor(Date.now() / 1000); testState.account = { id: 'active-account', author: { address: 'bitsocialist.bso', shortAddress: 'bitsocialist', }, subscriptions: [], }; testState.feed = [{ cid: 'older-post', communityAddress: 'music-posting.eth', timestamp: currentTimestamp - 60 }]; testState.accountComments = [ { accountId: 'active-account', author: { community: { displayName: 'Anonymous' }, }, cid: 'fresh-dev-post', communityAddress: 'music-posting.eth', content: '[color=red]hello[/color]', postCid: 'fresh-dev-post', state: 'succeeded', timestamp: currentTimestamp, }, ]; await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); const firstPost = container.querySelector('[data-testid="post"]'); expect(firstPost?.textContent).toBe('fresh-dev-post'); expect(firstPost?.getAttribute('data-author-address')).toBe('bitsocialist.bso'); expect(firstPost?.getAttribute('data-content')).toBe('[color=red]hello[/color]'); expect(testState.registerCommentsMock).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ author: expect.objectContaining({ address: 'bitsocialist.bso' }), cid: 'fresh-dev-post', content: '[color=red]hello[/color]', }), ]), ); }); it('preserves hook order when multiboard updates are appended during hydration', async () => { const currentTimestamp = Math.floor(Date.now() / 1000); testState.pageSizes = { guiPostsPerPage: 6, infiniteFeedPostsPerPage: 6, maxGuiPages: 3, paginationFeedPostsPerPage: 6, }; testState.feed = [ { cid: 'first-visible-post', communityAddress: 'music-posting.eth', lastReplyTimestamp: currentTimestamp - 200, timestamp: currentTimestamp - 200 }, { cid: 'second-visible-post', communityAddress: 'music-posting.eth', lastReplyTimestamp: currentTimestamp - 100, timestamp: currentTimestamp - 100 }, { cid: 'later-appended-newer-post', communityAddress: 'sports-posting.bso', postCid: 'later-appended-newer-post', timestamp: currentTimestamp }, ]; testState.filteredDirectoryAddresses = ['music-posting.eth', 'sports-posting.bso']; await renderBoard({ boardProps: { viewType: 'all' }, initialEntry: '/all', routePath: '/all/*', }); expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual([ 'first-visible-post', 'second-visible-post', 'later-appended-newer-post', ]); }); it('renders flash board posts as table rows instead of the normal feed', async () => { testState.directories = [{ address: 'flash-posting.bso', directoryCode: 'f', title: '/f/ - Flash' }]; testState.directoryByAddress = { 'flash-posting.bso': { address: 'flash-posting.bso', directoryCode: 'f', features: { postsPerPage: 50 }, title: '/f/ - Flash', }, }; testState.resolvedCommunityAddress = 'flash-posting.bso'; testState.community = { error: undefined, shortAddress: 'flash-posting.bso', state: 'ready', title: '/f/ - Flash', }; testState.communitySnapshot = { shortAddress: 'flash-posting.bso', title: '/f/ - Flash', }; testState.hasMore = true; testState.feed = [ { author: { displayName: 'FlashAnon' }, cid: 'flash-cid', communityAddress: 'flash-posting.bso', flairs: [{ text: 'flash:game' }], link: 'https://files.catbox.moe/movie.swf', number: 3524333, postCid: 'flash-cid', replyCount: 4, timestamp: 1_704_067_200, title: 'Flash game', }, ]; await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' }); const table = container.querySelector('#flash-list'); expect(table).toBeTruthy(); expect(container.querySelector('[data-testid="post"]')).toBeNull(); expect(table?.querySelectorAll('tbody tr').length).toBe(1); expect(table?.textContent).toContain('3524333'); expect(table?.textContent).toContain('FlashAnon'); expect(table?.textContent).toContain('movie.swf'); expect(table?.textContent).toContain('[G]'); expect(table?.textContent).toContain('Flash game'); expect(table?.textContent).toContain('4'); expect(table?.querySelector('a[href="/f/thread/flash-cid"]')?.textContent).toBe('3524333'); expect(Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'load_more')).toBeUndefined(); }); it('renders an empty flash table when the board has no posts', async () => { testState.directories = [{ address: 'flash-posting.bso', directoryCode: 'f', title: '/f/ - Flash' }]; testState.directoryByAddress = { 'flash-posting.bso': { address: 'flash-posting.bso', directoryCode: 'f', features: { postsPerPage: 50 }, title: '/f/ - Flash', }, }; testState.resolvedCommunityAddress = 'flash-posting.bso'; testState.community = { error: undefined, shortAddress: 'flash-posting.bso', state: 'succeeded', title: '/f/ - Flash', }; testState.communitySnapshot = { shortAddress: 'flash-posting.bso', title: '/f/ - Flash', }; markRawBoardThreadsFullyLoaded([], { hasCommunityUpdate: true }); await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' }); const table = container.querySelector('#flash-list'); expect(table).toBeTruthy(); expect(container.querySelector('[data-testid="post"]')).toBeNull(); expect(table?.textContent).toContain('no posts'); expect(container.textContent).not.toContain('no_threads'); expect(container.querySelector('[data-testid="board-pagination"]')).toBeTruthy(); }); it('keeps the flash table in loading state until the empty board feed finishes syncing', async () => { testState.directories = [{ address: 'flash-posting.bso', directoryCode: 'f', title: '/f/ - Flash' }]; testState.directoryByAddress = { 'flash-posting.bso': { address: 'flash-posting.bso', directoryCode: 'f', features: { postsPerPage: 50 }, title: '/f/ - Flash', }, }; testState.resolvedCommunityAddress = 'flash-posting.bso'; testState.community = { error: undefined, shortAddress: 'flash-posting.bso', state: 'ready', title: '/f/ - Flash', }; testState.communitySnapshot = { shortAddress: 'flash-posting.bso', title: '/f/ - Flash', }; testState.hasMore = true; markRawBoardThreadsFullyLoaded([], { hasCommunityUpdate: true }); await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' }); const table = container.querySelector('#flash-list'); expect(table).toBeTruthy(); expect(table?.textContent).not.toContain('no posts'); expect(table?.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('downloading_board'); expect(container.querySelectorAll('[data-testid="loading-ellipsis"]').length).toBe(1); }); it('renders an empty flash table when a loaded board reports explicit empty page cids', async () => { testState.directories = [{ address: 'flash-posting.bso', directoryCode: 'f', title: '/f/ - Flash' }]; testState.directoryByAddress = { 'flash-posting.bso': { address: 'flash-posting.bso', directoryCode: 'f', features: { postsPerPage: 50 }, title: '/f/ - Flash', }, }; testState.resolvedCommunityAddress = 'flash-posting.bso'; testState.feedState = 'succeeded'; testState.feedStateString = undefined; testState.hasMore = false; testState.community = { error: undefined, posts: { pageCids: {}, pages: {}, }, shortAddress: 'flash-posting.bso', state: 'succeeded', title: '/f/ - Flash', updatedAt: 1781773422, }; testState.communitySnapshot = { shortAddress: 'flash-posting.bso', title: '/f/ - Flash', }; await renderBoard({ initialEntry: '/f', routePath: '/:boardIdentifier/*' }); const table = container.querySelector('#flash-list'); expect(table).toBeTruthy(); expect(table?.textContent).toContain('no posts'); expect(table?.querySelector('[data-testid="loading-ellipsis"]')).toBeNull(); }); it('inserts a nonoko pending account comment after pinned posts on the redirected board index', async () => { const currentTimestamp = Math.floor(Date.now() / 1000); testState.feed = [ { cid: 'pinned-post', pinned: true, communityAddress: 'music-posting.eth' }, { cid: 'older-post', communityAddress: 'music-posting.eth' }, { cid: 'oldest-post', communityAddress: 'music-posting.eth' }, ]; testState.accountComments = []; testState.accountComments[7] = { content: 'pending thread body', communityAddress: 'music-posting.eth', index: 7, state: 'publishing-challenge', timestamp: currentTimestamp, }; await renderBoard({ initialEntry: '/mu', initialState: { nonokoPendingAccountCommentIndex: 7 }, routePath: '/:boardIdentifier/*', }); expect(testState.accountCommentsCalls).toContainEqual({ commentIndices: [7], }); expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['pinned-post', 'pending thread body']); }); it('keeps a nonoko pending account comment visible before the canonical board feed hydrates', async () => { const currentTimestamp = Math.floor(Date.now() / 1000); testState.accountComments[7] = { content: 'pending thread body', communityAddress: 'music-posting.eth', index: 7, state: 'publishing-challenge', timestamp: currentTimestamp, }; await renderBoard({ initialEntry: '/mu', initialState: { nonokoPendingAccountCommentIndex: 7 }, routePath: '/:boardIdentifier/*', }); expect(testState.accountCommentsCalls).toContainEqual({ commentIndices: [7], }); expect(Array.from(container.querySelectorAll('[data-testid="post"]')).map((element) => element.textContent)).toEqual(['pending thread body']); }); it('redirects oversized board pages back to the last available page', async () => { testState.feed = [ { cid: 'first-post', communityAddress: 'music-posting.eth' }, { cid: 'second-post', communityAddress: 'music-posting.eth' }, { cid: 'third-post', communityAddress: 'music-posting.eth' }, ]; await renderBoard({ initialEntry: '/mu/4', routePath: '/:boardIdentifier/*' }); expect(latestLocation).toBe('/mu/2'); }); it('registers visible feed posts with the post-number store', async () => { testState.feed = [ { cid: 'first-post', communityAddress: 'music-posting.eth' }, { cid: 'second-post', communityAddress: 'music-posting.eth' }, ]; await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); expect(testState.registerCommentsMock).toHaveBeenCalledWith(testState.feed); }); it('uses a taller Virtuoso default item height on mobile feeds', async () => { Object.defineProperty(window, 'innerWidth', { configurable: true, value: 480, writable: true, }); testState.feed = [ { cid: 'first-post', communityAddress: 'music-posting.eth' }, { cid: 'second-post', communityAddress: 'music-posting.eth' }, ]; await renderBoard({ boardProps: { viewType: 'all' }, initialEntry: '/all', routePath: '/all/*', }); expect(testState.lastVirtuosoDefaultItemHeight).toBe(420); expect(testState.lastVirtuosoIncreaseViewportBy).toEqual({ top: 2400, bottom: 1400 }); expect(testState.lastVirtuosoMinOverscanItemCount).toEqual({ top: 8, bottom: 4 }); }); it('uses an asymmetric reverse-scroll buffer on desktop multiboard feeds', async () => { Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280, writable: true, }); testState.feed = [ { cid: 'first-post', communityAddress: 'music-posting.eth' }, { cid: 'second-post', communityAddress: 'music-posting.eth' }, ]; await renderBoard({ boardProps: { viewType: 'all' }, initialEntry: '/all', routePath: '/all/*', }); expect(testState.lastVirtuosoIncreaseViewportBy).toEqual({ top: 2400, bottom: 1200 }); }); it('canonicalizes multiboard paths and shows the subscriptions empty state', async () => { testState.account = { subscriptions: [] }; testState.filteredDirectoryAddresses = []; await renderBoard({ boardProps: { viewType: 'subs' }, initialEntry: '/subs/9', routePath: '/subs/*', }); expect(latestLocation).toBe('/subs'); expect(container.textContent).toContain('not_subscribed_to_any_board'); expect(container.querySelector('output')?.className).toContain('searchNothingFound'); }); it('links empty mod feeds to account import settings', async () => { testState.accountCommunityAddresses = []; await renderBoard({ boardProps: { viewType: 'mod' }, initialEntry: '/mod', routePath: '/mod/*', }); expect(container.textContent).toContain('not_mod_of_any_board'); expect(container.textContent).toContain('go_to_settings_to_import_mod_account'); expect(container.querySelector('output')?.className).toContain('modEmptyState'); expect(container.querySelector('a')?.getAttribute('href')).toBe('/mod/settings?section=account-settings'); }); it('passes multiboard time filters to useFeed and honors cached overrides', async () => { testState.feed = [{ cid: 'all-post', communityAddress: 'music-posting.eth' }]; await renderBoard({ boardProps: { viewType: 'all', timeFilterNameFromCache: '1w' }, initialEntry: '/all?t=24h', routePath: '/all/*', }); expect(testState.feedOptionsCalls).toEqual( expect.arrayContaining([ expect.objectContaining({ newerThan: 7 * 24 * 60 * 60, postsPerPage: 2, sortType: 'active', }), ]), ); }); it('does not show no threads while an empty all feed is still loading', async () => { testState.feedState = 'initializing'; testState.feedStateString = 'Downloading 1 board (music-posting.eth)'; testState.hasMore = true; await renderBoard({ boardProps: { viewType: 'all' }, initialEntry: '/all', routePath: '/all/*', }); expect(container.textContent).not.toContain('no_threads'); expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('Downloading 1 board (music-posting.eth)'); }); it('shows no threads after an empty all feed finishes loading', async () => { testState.feedStateString = undefined; await renderBoard({ boardProps: { viewType: 'all' }, initialEntry: '/all', routePath: '/all/*', }); expect(container.textContent).toContain('no_threads'); expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull(); }); it('shows a wider multiboard time-filter suggestion when older threads exist', async () => { const now = Math.floor(Date.now() / 1000); localStorage.setItem(LAST_VISIT_STORAGE_KEY, String((now - 3 * 24 * 60 * 60) * 1000)); testState.feed = [ { cid: 'recent-post', communityAddress: 'music-posting.eth', timestamp: now - 2 * 24 * 60 * 60 }, { cid: 'older-post', communityAddress: 'music-posting.eth', timestamp: now - 5 * 24 * 60 * 60 }, ]; await renderBoard({ boardProps: { viewType: 'all' }, initialEntry: '/all?t=last', routePath: '/all/*', }); expect(testState.feedOptionsCalls).toEqual( expect.arrayContaining([ expect.objectContaining({ newerThan: 7 * 24 * 60 * 60 }), expect.objectContaining({ newerThan: 30 * 24 * 60 * 60 }), expect.objectContaining({ newerThan: 365 * 24 * 60 * 60 }), ]), ); expect(container.querySelector('[data-testid="expand-time-window-button"]')).toBeTruthy(); }); it('expands the multiboard time window in place from the footer suggestion', async () => { const now = Math.floor(Date.now() / 1000); localStorage.setItem(LAST_VISIT_STORAGE_KEY, String((now - 3 * 24 * 60 * 60) * 1000)); testState.feed = [ { cid: 'recent-post', communityAddress: 'music-posting.eth', timestamp: now - 2 * 24 * 60 * 60 }, { cid: 'older-post', communityAddress: 'music-posting.eth', timestamp: now - 5 * 24 * 60 * 60 }, ]; await renderBoard({ boardProps: { viewType: 'all' }, initialEntry: '/all?t=last', routePath: '/all/*', }); const expandButton = container.querySelector('[data-testid="expand-time-window-button"]'); expect(expandButton).toBeTruthy(); expect(Array.from(container.querySelectorAll('a')).some((link) => link.getAttribute('href') === '/all?t=1w')).toBe(false); await act(async () => { expandButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); expect(testState.expandTimeWindowMock).toHaveBeenCalledWith(7 * 24 * 60 * 60); }); it('keeps broader suggestion feeds on the base page size so their identities stay stable while scrolling', async () => { const now = Math.floor(Date.now() / 1000); testState.feed = [ { cid: 'post-1', communityAddress: 'music-posting.eth', timestamp: now - 2 * 24 * 60 * 60 }, { cid: 'post-2', communityAddress: 'music-posting.eth', timestamp: now - 3 * 24 * 60 * 60 }, { cid: 'post-3', communityAddress: 'music-posting.eth', timestamp: now - 5 * 24 * 60 * 60 }, { cid: 'post-4', communityAddress: 'music-posting.eth', timestamp: now - 20 * 24 * 60 * 60 }, ]; testState.pageSizes = { guiPostsPerPage: 2, infiniteFeedPostsPerPage: 2, maxGuiPages: 3, paginationFeedPostsPerPage: 6, }; testState.respectPostsPerPageForNewerThan = new Set([30 * 24 * 60 * 60, 365 * 24 * 60 * 60]); await renderBoard({ boardProps: { viewType: 'all' }, initialEntry: '/all?t=1w', routePath: '/all/*', }); expect(testState.feedOptionsCalls).toEqual( expect.arrayContaining([ expect.objectContaining({ newerThan: 30 * 24 * 60 * 60, postsPerPage: 2, sortType: 'active' }), expect.objectContaining({ newerThan: 365 * 24 * 60 * 60, postsPerPage: 2, sortType: 'active' }), ]), ); }); it('disables suggestion probe feeds for hidden cached multiboard views', async () => { testState.feed = [{ cid: 'recent-post', communityAddress: 'music-posting.eth' }]; await renderBoard({ boardProps: { viewType: 'all', isVisible: false }, initialEntry: '/all?t=24h', routePath: '/all/*', }); expect(testState.feedOptionsCalls).toEqual( expect.arrayContaining([ expect.objectContaining({ newerThan: 7 * 24 * 60 * 60, communitiesLength: 0 }), expect.objectContaining({ newerThan: 30 * 24 * 60 * 60, communitiesLength: 0 }), expect.objectContaining({ newerThan: 365 * 24 * 60 * 60, communitiesLength: 0 }), ]), ); }); it('waits to probe broader multiboard suggestions until the current time window is exhausted', async () => { const now = Math.floor(Date.now() / 1000); testState.feed = [ { cid: 'recent-post', communityAddress: 'music-posting.eth', timestamp: now - 12 * 60 * 60 }, { cid: 'older-post', communityAddress: 'music-posting.eth', timestamp: now - 5 * 24 * 60 * 60 }, ]; testState.feedState = 'fetching-ipns'; testState.hasMore = true; await renderBoard({ boardProps: { viewType: 'all' }, initialEntry: '/all?t=24h', routePath: '/all/*', }); expect(testState.feedOptionsCalls).toEqual( expect.arrayContaining([ expect.objectContaining({ newerThan: 7 * 24 * 60 * 60, communitiesLength: 0 }), expect.objectContaining({ newerThan: 30 * 24 * 60 * 60, communitiesLength: 0 }), expect.objectContaining({ newerThan: 365 * 24 * 60 * 60, communitiesLength: 0 }), ]), ); expect(container.querySelector('[data-testid="expand-time-window-button"]')).toBeNull(); }); it('surfaces board load errors when the feed is empty', async () => { testState.community = { error: new Error('board failed'), shortAddress: 'music-posting.eth', state: 'failed', title: '/mu/ - Music', }; await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); expect(container.querySelector('[data-testid="error-display"]')?.textContent).toBe('board failed'); expect(container.textContent).toContain('failed'); }); it('does not show no threads while a board is still loading', async () => { testState.feedStateString = undefined; testState.community = { error: undefined, shortAddress: 'music-posting.eth', state: 'fetching-community-ipfs', title: '/mu/ - Music', }; await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); expect(container.textContent).not.toContain('no_threads'); expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('downloading_board'); }); it('does not show no threads while an empty board feed is still loading', async () => { testState.feedStateString = undefined; testState.hasMore = true; testState.community = { error: undefined, shortAddress: 'music-posting.eth', state: 'succeeded', title: '/mu/ - Music', }; await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); expect(container.textContent).not.toContain('no_threads'); expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('downloading_board'); }); it('shows no threads after an empty board feed finishes loading', async () => { testState.feedStateString = undefined; testState.community = { error: undefined, shortAddress: 'music-posting.eth', state: 'succeeded', title: '/mu/ - Music', }; markRawBoardThreadsFullyLoaded([], { hasCommunityUpdate: true }); await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); expect(container.textContent).toContain('no_threads'); expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull(); }); it('keeps loading when an empty preloaded board page finishes before the feed', async () => { testState.feedStateString = undefined; testState.feedState = 'fetching-ipns'; testState.hasMore = true; testState.community = { error: undefined, shortAddress: 'music-posting.eth', state: 'succeeded', title: '/mu/ - Music', }; markRawBoardThreadsFullyLoaded(); await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); expect(container.textContent).not.toContain('no_threads'); expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('downloading_board'); expect(container.textContent).toContain('load_more'); }); it('keeps loading when explicit empty page cids arrive before the feed finishes', async () => { testState.feedStateString = 'Downloading board from peers'; testState.feedState = 'fetching-ipns'; testState.hasMore = true; testState.community = { error: undefined, posts: { pageCids: {}, pages: {}, }, shortAddress: 'blog.bitsocial.bso', state: 'succeeded', title: 'Bitsocial Updates', updatedAt: 1781773422, }; testState.communitySnapshot = { shortAddress: 'blog.bitsocial.bso', title: 'Bitsocial Updates', }; await renderBoard({ initialEntry: '/blog.bitsocial.bso', routePath: '/:boardIdentifier/*' }); expect(container.textContent).not.toContain('no_threads'); expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('Downloading board from peers'); expect(container.textContent).toContain('load_more'); }); it('shows no threads when a loaded empty board feed finishes', async () => { testState.feedStateString = undefined; testState.feedState = 'succeeded'; testState.hasMore = false; testState.community = { error: undefined, posts: { pageCids: {}, pages: {}, }, shortAddress: 'blog.bitsocial.bso', state: 'succeeded', title: 'Bitsocial Updates', updatedAt: 1781773422, }; testState.communitySnapshot = { shortAddress: 'blog.bitsocial.bso', title: 'Bitsocial Updates', }; await renderBoard({ initialEntry: '/blog.bitsocial.bso', routePath: '/:boardIdentifier/*' }); expect(container.textContent).toContain('no_threads'); expect(container.querySelector('[data-testid="loading-ellipsis"]')).toBeNull(); expect(container.textContent).not.toContain('load_more'); }); it('keeps loading when raw board pages contain threads but the feed has not caught up', async () => { testState.feedStateString = undefined; testState.feedState = 'fetching-ipns'; testState.hasMore = true; testState.community = { error: undefined, shortAddress: 'music-posting.eth', state: 'succeeded', title: '/mu/ - Music', }; markRawBoardThreadsFullyLoaded([{ cid: 'post-1' }]); await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); expect(container.textContent).not.toContain('no_threads'); expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('downloading_board'); expect(container.textContent).toContain('load_more'); }); it('does not show no threads when a succeeded feed has not caught up to loaded raw thread pages', async () => { testState.feedStateString = undefined; testState.feedState = 'succeeded'; testState.hasMore = false; testState.community = { error: undefined, shortAddress: 'music-posting.eth', state: 'succeeded', title: '/mu/ - Music', }; markRawBoardThreadsFullyLoaded([{ cid: 'post-1' }]); await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); expect(container.textContent).not.toContain('no_threads'); expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('downloading_board'); }); it('does not show no threads after board metadata loads but raw thread pages are still missing', async () => { testState.feedStateString = undefined; testState.feedState = 'succeeded'; testState.hasMore = false; testState.community = { error: undefined, shortAddress: 'music-posting.eth', state: 'succeeded', title: '/mu/ - Music', }; await renderBoard({ initialEntry: '/mu', routePath: '/:boardIdentifier/*' }); expect(container.textContent).not.toContain('no_threads'); expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('downloading_board'); }); });