Merge branch 'master' of github.com:bitsocialnet/5chan

This commit is contained in:
Tommaso Casaburi
2026-06-18 19:02:28 +07:00
9 changed files with 821 additions and 240 deletions
@@ -61,6 +61,46 @@ describe('getRawBoardThreadState', () => {
).toBe(true);
});
it('treats explicit empty page CIDs as a fully loaded empty board', () => {
const community = {
posts: {
pageCids: {},
pages: {},
},
updatedAt: 1781773422,
} as Community;
expect(
getRawBoardThreadState({
accountId: undefined,
communitiesPages: {} as CommunitiesPages,
community,
sortType: 'active',
}),
).toMatchObject({
isFullyLoaded: true,
rootThreadCids: new Set<string>(),
});
});
it('does not treat placeholder empty page CIDs as fully loaded', () => {
const community = {
posts: {
pageCids: {},
pages: {},
},
} as Community;
expect(
getRawBoardThreadState({
accountId: undefined,
communitiesPages: {} as CommunitiesPages,
community,
sortType: 'active',
}).isFullyLoaded,
).toBe(false);
});
it('walks stored board pages without importing side-effectful stores', () => {
const community = {
posts: {
+7 -1
View File
@@ -1,11 +1,13 @@
import type { Comment, CommunitiesPages, Community, CommunityPage } from '@bitsocial/bitsocial-react-hooks';
export type RawBoardThreadState = {
hasExplicitEmptyPageCids: boolean;
isFullyLoaded: boolean;
rootThreadCids: Set<string>;
};
const EMPTY_RAW_BOARD_THREAD_STATE: RawBoardThreadState = {
hasExplicitEmptyPageCids: false,
isFullyLoaded: false,
rootThreadCids: new Set<string>(),
};
@@ -80,6 +82,7 @@ export const getRawBoardThreadState = ({
if (pages.length > 0) {
return {
hasExplicitEmptyPageCids: false,
isFullyLoaded: !pages[pages.length - 1]?.nextCid,
rootThreadCids,
};
@@ -88,6 +91,8 @@ export const getRawBoardThreadState = ({
const hasPageCid = Boolean(community.posts?.pageCids?.[sortType]);
const preloadedPages = (preloadedSortPage ? [preloadedSortPage] : []) as Array<{ comments?: Comment[]; nextCid?: string }>;
const hasCompletePreloadedPage = !hasPageCid && preloadedPages.some((page) => Array.isArray(page?.comments)) && preloadedPages.every((page) => !page?.nextCid);
const hasFetchedCommunityUpdate = typeof community.updatedAt === 'number' || typeof community.updateCid === 'string';
const hasExplicitEmptyPageCids = hasFetchedCommunityUpdate && Boolean(community.posts?.pageCids && !hasPageCid);
if (hasCompletePreloadedPage) {
for (const page of preloadedPages) {
@@ -96,7 +101,8 @@ export const getRawBoardThreadState = ({
}
return {
isFullyLoaded: hasCompletePreloadedPage,
hasExplicitEmptyPageCids,
isFullyLoaded: hasCompletePreloadedPage || hasExplicitEmptyPageCids,
rootThreadCids,
};
};
+103
View File
@@ -36,6 +36,7 @@ type TestComment = {
type TestCommunity = {
error?: Error;
nameResolved?: boolean;
updatedAt?: number;
posts?: {
pageCids?: Record<string, string>;
pages?: Record<string, { comments?: TestComment[]; nextCid?: string }>;
@@ -687,6 +688,43 @@ describe('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 = 'fetching-ipns';
testState.hasMore = true;
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 = [
@@ -1043,6 +1081,71 @@ describe('Board', () => {
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('shows no threads when a loaded board reports explicit empty page cids', 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).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 after board metadata loads but raw thread pages are still missing', async () => {
testState.feedStateString = undefined;
testState.feedState = 'succeeded';
+22 -9
View File
@@ -60,6 +60,7 @@ interface BoardFooterProps {
combinedFeedLength: number;
isSingleCommunityBoard: boolean;
isRawBoardThreadStateFullyLoaded: boolean;
isKnownEmptySingleCommunityBoard: boolean;
isInSubscriptionsView: boolean;
isInModView: boolean;
currentTimeFilterName: string;
@@ -84,6 +85,7 @@ const BoardFooter = ({
combinedFeedLength,
isSingleCommunityBoard,
isRawBoardThreadStateFullyLoaded,
isKnownEmptySingleCommunityBoard,
isInSubscriptionsView,
isInModView,
currentTimeFilterName,
@@ -102,7 +104,9 @@ const BoardFooter = ({
const isLoadedCommunityState = communityState === 'succeeded' || communityState === 'ready';
const isFeedSucceeded = feedState === 'succeeded';
const isFeedFailed = feedState === 'failed';
const canShowNoThreads = isSingleCommunityBoard ? isLoadedCommunityState && isFeedSucceeded && isRawBoardThreadStateFullyLoaded : isFeedSucceeded && !hasMore;
const canShowNoThreads =
isKnownEmptySingleCommunityBoard ||
(isSingleCommunityBoard ? isLoadedCommunityState && isFeedSucceeded && isRawBoardThreadStateFullyLoaded : isFeedSucceeded && !hasMore);
const isEmptyFeedLoading = combinedFeedLength === 0 && !canShowNoThreads && (isSingleCommunityBoard ? communityState !== 'failed' : !isFeedFailed);
const showFooterLoading = showLoadingEllipsis && (hasMore || isEmptyFeedLoading);
@@ -472,6 +476,14 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
[account?.id, communitiesPages, communityData, isMultiboardView],
);
const isRawBoardThreadStateFullyLoaded = rawBoardThreadState?.isFullyLoaded ?? false;
const hasExplicitEmptyPageCids = rawBoardThreadState?.hasExplicitEmptyPageCids ?? false;
const isRawBoardThreadStateEmpty = isRawBoardThreadStateFullyLoaded && (rawBoardThreadState?.rootThreadCids.size ?? 0) === 0;
const isSingleCommunityBoard = !isInAllView && !isInSubscriptionsView && !isInModView;
const isLoadedCommunityState = communityState === 'succeeded' || communityState === 'ready';
const isFeedSucceeded = feedState === 'succeeded';
const isKnownEmptySingleCommunityBoard =
isSingleCommunityBoard && combinedFeed.length === 0 && isLoadedCommunityState && isRawBoardThreadStateEmpty && (hasExplicitEmptyPageCids || isFeedSucceeded);
const effectiveHasMore = isKnownEmptySingleCommunityBoard ? false : hasMore;
const title = isInAllView ? t('all') : isInSubscriptionsView ? t('subscriptions') : isInModView ? t('mod') : communityTitle;
// Memoize footer component to preserve identity across renders (Virtuoso optimization)
@@ -483,11 +495,12 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
{shouldUseFlashTable ? null : (
<BoardFooter
communityAddresses={communityAddresses}
hasMore={hasMore}
hasMore={effectiveHasMore}
feedState={feedState}
combinedFeedLength={combinedFeed.length}
isSingleCommunityBoard={!isInAllView && !isInSubscriptionsView && !isInModView}
isSingleCommunityBoard={isSingleCommunityBoard}
isRawBoardThreadStateFullyLoaded={isRawBoardThreadStateFullyLoaded}
isKnownEmptySingleCommunityBoard={isKnownEmptySingleCommunityBoard}
isInSubscriptionsView={isInSubscriptionsView}
isInModView={isInModView}
currentTimeFilterName={currentTimeFilterName}
@@ -552,7 +565,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
</div>
</>
)}
{hasMore && !effectiveInfiniteScroll && !shouldUseFlashTable && (
{effectiveHasMore && !effectiveInfiniteScroll && !shouldUseFlashTable && (
<div className={mobileFooterStyles.mobileFooterButtons}>
<button type='button' className='button' onClick={() => setEnableInfiniteScroll(true)}>
{t('load_more')}
@@ -566,9 +579,11 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
}),
[
communityAddresses,
hasMore,
effectiveHasMore,
combinedFeed.length,
isRawBoardThreadStateFullyLoaded,
isKnownEmptySingleCommunityBoard,
isSingleCommunityBoard,
isInAllView,
isInSubscriptionsView,
isInModView,
@@ -665,9 +680,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
communityIdentifier.publicKey.length > 0 &&
communityData?.nameResolved === false;
const displayFeed = effectiveInfiniteScroll ? combinedFeed : currentPageFeed;
const isLoadedCommunityState = communityState === 'succeeded' || communityState === 'ready';
const isFeedSucceeded = feedState === 'succeeded';
const canShowEmptyFlashTable = isLoadedCommunityState && isFeedSucceeded && isRawBoardThreadStateFullyLoaded;
const canShowEmptyFlashTable = hasExplicitEmptyPageCids || (isLoadedCommunityState && isFeedSucceeded && isRawBoardThreadStateFullyLoaded);
const shouldShowFlashTableLoading = shouldUseFlashTable && displayFeed.length === 0 && !canShowEmptyFlashTable && communityState !== 'failed' && feedState !== 'failed';
return (
@@ -697,7 +710,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
itemContent={boardItemContent}
useWindowScroll={true}
components={footerComponents}
endReached={hasMore ? loadMore : undefined}
endReached={effectiveHasMore ? loadMore : undefined}
ref={virtuosoRef}
restoreStateFrom={lastVirtuosoState}
initialScrollTop={lastVirtuosoState?.scrollTop}