fix(catalog): hide threads across board catalogs

This commit is contained in:
Tommaso Casaburi
2026-05-14 16:59:55 +07:00
parent d1fbc7f46a
commit 8217bad735
17 changed files with 1544 additions and 123 deletions
+265 -9
View File
@@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Catalog, { getCatalogRenderFeed, type CatalogProps } from '../catalog';
import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../../../lib/utils/time-filter-utils';
import useHiddenCatalogThreadsStore from '../../../stores/use-hidden-catalog-threads-store';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -16,6 +17,7 @@ type TestComment = {
pinned?: boolean;
communityAddress?: string;
deleted?: boolean;
parentCid?: string;
postCid?: string;
removed?: boolean;
state?: string;
@@ -33,17 +35,26 @@ type FilterItem = {
};
const testState = vi.hoisted(() => ({
account: { subscriptions: [] as string[] },
account: { blockedCids: {} as Record<string, boolean>, subscriptions: [] as string[] },
accountComments: [] as TestComment[],
accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>,
accountCommunityAddresses: [] as string[],
blockCidMock: vi.fn(),
commentsByCid: {} as Record<string, TestComment>,
directoryByAddress: {
'music-posting.eth': {
address: 'music-posting.eth',
features: { postsPerPage: 2 },
},
} as Record<string, { address: string; features?: Record<string, unknown> }>,
directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>,
directories: [{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' }] as Array<{
address: string;
directoryCode?: string;
features?: Record<string, unknown>;
name?: string;
publicKey?: string;
title?: string;
}>,
feed: [] as TestComment[],
feedOptionsCalls: [] as Array<{ communitiesLength?: number; filterKey?: string; newerThan?: number; postsPerPage?: number; sortType?: string }>,
filterItems: [] as FilterItem[],
@@ -66,6 +77,7 @@ const testState = vi.hoisted(() => ({
setResetFunctionMock: vi.fn(),
showOPComment: true,
sortType: 'new' as 'active' | 'new',
unblockCidMock: vi.fn(),
virtuosoInitialScrollTops: [] as Array<number | undefined>,
windowWidth: 900,
community: {
@@ -168,6 +180,23 @@ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
};
},
useCommunity: () => testState.community,
useComments: ({ commentCids = [] }: { commentCids?: string[] } = {}) => ({
comments: commentCids.map((cid) => testState.commentsByCid[cid]),
state: 'succeeded',
}),
}));
vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts', () => ({
default: {
getState: () => ({
accounts: { account: testState.account },
accountsActions: {
blockCid: testState.blockCidMock,
unblockCid: testState.unblockCidMock,
},
activeAccountId: 'account',
}),
},
}));
vi.mock('react-virtuoso', () => ({
@@ -207,8 +236,21 @@ vi.mock('react-virtuoso', () => ({
vi.mock('../../../hooks/use-directories', () => ({
useDirectories: () => testState.directories,
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),
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
findDirectoryByAddress: (
directories: Array<{ address: string; title?: string; directoryCode?: string; name?: string; publicKey?: string }>,
address: string | undefined,
) => {
if (!address) {
return undefined;
}
const normalize = (value: string) => value.replace(/\.(bso|eth)$/, '');
return directories.find((entry) =>
[entry.address, entry.directoryCode, entry.name, entry.publicKey, entry.title].some(
(identifier) => identifier === address || (!!identifier && normalize(identifier) === normalize(address)),
),
);
},
}));
vi.mock('../../../hooks/use-board-feed-page-size', () => ({
@@ -262,8 +304,20 @@ vi.mock('../../../stores/use-catalog-filters-store', () => ({
}));
vi.mock('../../../components/catalog-row', () => ({
default: ({ estimatedHeight, row }: { estimatedHeight?: number; row: TestComment[] }) =>
createElement('div', { 'data-pretext-height': estimatedHeight, 'data-testid': 'catalog-row' }, `row:${row.map((comment) => comment.cid).join(',')}`),
default: ({ estimatedHeight, row, showHiddenPosts }: { estimatedHeight?: number; row: TestComment[]; showHiddenPosts?: boolean }) =>
createElement(
'div',
{ 'data-pretext-height': estimatedHeight, 'data-show-hidden': showHiddenPosts ? 'true' : 'false', 'data-testid': 'catalog-row' },
`row:${row.map((comment) => comment.cid).join(',')}`,
...row.map((comment) =>
createElement('button', {
'aria-label': `hide-${comment.cid}`,
key: `hide-${comment.cid}`,
onClick: () => testState.blockCidMock(comment.cid),
type: 'button',
}),
),
),
}));
vi.mock('../../../components/footer', () => ({
@@ -311,6 +365,8 @@ const LocationProbe = () => {
return null;
};
const getHiddenCatalogThreadsScopeKey = (communityAddresses: string[]) => communityAddresses.filter(Boolean).slice().sort().join('\u0000');
const flushEffects = async (count = 5) => {
for (let i = 0; i < count; i += 1) {
await act(async () => {
@@ -344,11 +400,22 @@ describe('Catalog', () => {
beforeEach(() => {
vi.clearAllMocks();
latestLocation = '';
testState.account = { subscriptions: [] };
testState.account = { blockedCids: {}, subscriptions: [] };
testState.accountComments = [];
testState.accountCommentsCalls = [];
testState.accountCommunityAddresses = [];
testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }];
testState.blockCidMock.mockReset();
testState.blockCidMock.mockImplementation(async (cid: string) => {
testState.account = {
...testState.account,
blockedCids: {
...testState.account.blockedCids,
[cid]: true,
},
};
});
testState.commentsByCid = {};
testState.directories = [{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' }];
testState.directoryByAddress = {
'music-posting.eth': {
address: 'music-posting.eth',
@@ -371,6 +438,8 @@ describe('Catalog', () => {
testState.searchText = '';
testState.showOPComment = true;
testState.sortType = 'new';
testState.unblockCidMock.mockReset();
testState.unblockCidMock.mockResolvedValue(undefined);
testState.virtuosoInitialScrollTops = [];
testState.windowWidth = 900;
testState.community = {
@@ -387,6 +456,7 @@ describe('Catalog', () => {
document.title = 'before';
clearStableLastVisitTimeFilterName();
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now()));
useHiddenCatalogThreadsStore.setState({ hiddenCommentsByCid: {}, scopeHiddenThreadsCounts: {}, shownScopeKey: null });
container = document.createElement('div');
document.body.appendChild(container);
@@ -396,6 +466,7 @@ describe('Catalog', () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
useHiddenCatalogThreadsStore.setState({ hiddenCommentsByCid: {}, scopeHiddenThreadsCounts: {}, shownScopeKey: null });
clearStableLastVisitTimeFilterName();
localStorage.clear();
});
@@ -438,6 +509,191 @@ describe('Catalog', () => {
expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:board-post-1,board-post-2']);
});
it('removes hidden account-backed threads from the normal board catalog feed', async () => {
testState.account = { blockedCids: { 'hidden-board-post': true }, subscriptions: [] };
testState.feed = [
{ cid: 'visible-board-post', title: 'visible', communityAddress: 'music-posting.eth' },
{ cid: 'hidden-board-post', title: 'hidden', communityAddress: 'music-posting.eth', postCid: 'hidden-board-post' },
];
testState.commentsByCid = {
'hidden-board-post': { cid: 'hidden-board-post', title: 'hidden', communityAddress: 'music-posting.eth', postCid: 'hidden-board-post' },
};
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:visible-board-post']);
expect(container.textContent).not.toContain('hidden-board-post');
});
it('shows only hidden threads for the current board when hidden catalog mode is enabled', async () => {
testState.account = { blockedCids: { 'hidden-board-post': true }, subscriptions: [] };
testState.feed = [
{ cid: 'visible-board-post', title: 'visible', communityAddress: 'music-posting.eth' },
{ cid: 'hidden-board-post', title: 'hidden', communityAddress: 'music-posting.eth', postCid: 'hidden-board-post' },
];
testState.commentsByCid = {
'hidden-board-post': { cid: 'hidden-board-post', title: 'hidden', communityAddress: 'music-posting.eth', postCid: 'hidden-board-post' },
};
useHiddenCatalogThreadsStore.getState().setShownScopeKey(getHiddenCatalogThreadsScopeKey(['music-posting.eth']));
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
const rows = Array.from(container.querySelectorAll<HTMLElement>('[data-testid="catalog-row"]'));
expect(rows.map((element) => element.textContent)).toEqual(['row:hidden-board-post']);
expect(rows[0]?.dataset.showHidden).toBe('true');
});
it('keeps a thread hidden on its board when it was hidden from a multiboard catalog', async () => {
const sportsPublicKey = 'sports-public-key';
testState.account = { blockedCids: { 'hidden-sp-post': true }, subscriptions: [] };
testState.directories = [
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
{ address: 'sports-posting.bso', directoryCode: 'sp', publicKey: sportsPublicKey, title: '/sp/ - Sports' },
];
testState.directoryByAddress = {
'sports-posting.bso': { address: 'sports-posting.bso', features: { postsPerPage: 2 } },
};
testState.resolvedCommunityAddress = 'sports-posting.bso';
testState.feed = [{ cid: 'hidden-sp-post', title: 'sports hidden', communityAddress: sportsPublicKey, postCid: 'hidden-sp-post' }];
testState.commentsByCid = {
'hidden-sp-post': { cid: 'hidden-sp-post', title: 'sports hidden', communityAddress: sportsPublicKey, postCid: 'hidden-sp-post' },
};
await renderCatalog({ initialEntry: '/sp/catalog', routePath: '/:boardIdentifier/catalog' });
expect(container.querySelector('[data-testid="catalog-row"]')).toBeNull();
useHiddenCatalogThreadsStore.getState().setShownScopeKey(getHiddenCatalogThreadsScopeKey(['sports-posting.bso']));
await renderCatalog({ initialEntry: '/sp/catalog', routePath: '/:boardIdentifier/catalog' });
const hiddenRow = container.querySelector<HTMLElement>('[data-testid="catalog-row"]');
expect(hiddenRow?.textContent).toBe('row:hidden-sp-post');
expect(hiddenRow?.dataset.showHidden).toBe('true');
});
it('hides a board thread from its own catalog after the thread is hidden in all catalog', async () => {
const musicThread = { cid: 'mu-thread-hidden-from-all', title: 'music thread', communityAddress: 'music-posting.eth', postCid: 'mu-thread-hidden-from-all' };
testState.filteredDirectoryAddresses = ['music-posting.eth', 'sports-posting.eth'];
testState.feed = [musicThread, { cid: 'sports-thread', title: 'sports thread', communityAddress: 'sports-posting.eth', postCid: 'sports-thread' }];
testState.commentsByCid = {
'mu-thread-hidden-from-all': musicThread,
};
await renderCatalog({
catalogProps: { viewType: 'all' },
initialEntry: '/all/catalog',
routePath: '/all/*',
});
expect(container.textContent).toContain('mu-thread-hidden-from-all');
await act(async () => {
container.querySelector<HTMLButtonElement>('[aria-label="hide-mu-thread-hidden-from-all"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.account.blockedCids).toEqual({ 'mu-thread-hidden-from-all': true });
testState.resolvedCommunityAddress = 'music-posting.eth';
testState.feed = [musicThread, { cid: 'mu-visible-thread', title: 'other music thread', communityAddress: 'music-posting.eth', postCid: 'mu-visible-thread' }];
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:mu-visible-thread']);
expect(container.textContent).not.toContain('mu-thread-hidden-from-all');
});
it('counts and shows hidden board threads from the raw feed when blocked cid lookup has not resolved them', async () => {
const hiddenThread = {
cid: 'raw-feed-hidden-thread',
communityAddress: 'music-posting.eth',
postCid: 'raw-feed-hidden-thread',
title: 'raw feed hidden',
};
testState.account = { blockedCids: { 'raw-feed-hidden-thread': true }, subscriptions: [] };
testState.commentsByCid = {};
testState.feed = [hiddenThread, { cid: 'raw-feed-visible-thread', communityAddress: 'music-posting.eth', postCid: 'raw-feed-visible-thread', title: 'visible' }];
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
expect(useHiddenCatalogThreadsStore.getState().scopeHiddenThreadsCounts[getHiddenCatalogThreadsScopeKey(['music-posting.eth'])]).toBe(1);
expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:raw-feed-visible-thread']);
useHiddenCatalogThreadsStore.getState().setShownScopeKey(getHiddenCatalogThreadsScopeKey(['music-posting.eth']));
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
const hiddenRow = container.querySelector<HTMLElement>('[data-testid="catalog-row"]');
expect(hiddenRow?.textContent).toBe('row:raw-feed-hidden-thread');
expect(hiddenRow?.dataset.showHidden).toBe('true');
});
it('uses the multiboard board scope for hidden catalog mode', async () => {
const sportsPublicKey = 'sports-public-key';
testState.account = {
blockedCids: {
'hidden-mu-post': true,
'hidden-sp-post': true,
'hidden-other-post': true,
},
subscriptions: [],
};
testState.directories = [
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
{ address: 'sports-posting.bso', directoryCode: 'sp', publicKey: sportsPublicKey, title: '/sp/ - Sports' },
];
testState.filteredDirectoryAddresses = ['music-posting.eth', 'sports-posting.bso'];
testState.feed = [
{ cid: 'visible-mu-post', title: 'visible', communityAddress: 'music-posting.eth' },
{ cid: 'hidden-mu-post', title: 'music hidden', communityAddress: 'music-posting.eth', postCid: 'hidden-mu-post' },
{ cid: 'hidden-sp-post', title: 'sports hidden', communityAddress: sportsPublicKey, postCid: 'hidden-sp-post' },
];
testState.commentsByCid = {
'hidden-mu-post': { cid: 'hidden-mu-post', title: 'music hidden', communityAddress: 'music-posting.eth', postCid: 'hidden-mu-post', timestamp: 100 },
'hidden-other-post': { cid: 'hidden-other-post', title: 'other hidden', communityAddress: 'other-board.eth', postCid: 'hidden-other-post', timestamp: 101 },
'hidden-sp-post': { cid: 'hidden-sp-post', title: 'sports hidden', communityAddress: sportsPublicKey, postCid: 'hidden-sp-post', timestamp: 102 },
};
useHiddenCatalogThreadsStore.getState().setShownScopeKey(getHiddenCatalogThreadsScopeKey(['music-posting.eth', 'sports-posting.bso']));
await renderCatalog({
catalogProps: { viewType: 'all' },
initialEntry: '/all/catalog',
routePath: '/all/*',
});
expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:hidden-sp-post,hidden-mu-post']);
});
it('leaves hidden mode automatically when the last hidden thread is unhidden', async () => {
testState.account = { blockedCids: { 'hidden-board-post': true }, subscriptions: [] };
testState.feed = [{ cid: 'hidden-board-post', title: 'hidden', communityAddress: 'music-posting.eth', postCid: 'hidden-board-post' }];
testState.commentsByCid = {
'hidden-board-post': { cid: 'hidden-board-post', title: 'hidden', communityAddress: 'music-posting.eth', postCid: 'hidden-board-post' },
};
useHiddenCatalogThreadsStore.getState().setShownScopeKey(getHiddenCatalogThreadsScopeKey(['music-posting.eth']));
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
expect(container.querySelector('[data-testid="catalog-row"]')?.textContent).toBe('row:hidden-board-post');
testState.account = { blockedCids: {}, subscriptions: [] };
testState.commentsByCid = {};
testState.feed = [];
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
expect(useHiddenCatalogThreadsStore.getState().shownScopeKey).toBeNull();
});
it('does not prune hidden board threads just because the visible board feed filtered them out', async () => {
testState.account = { blockedCids: { 'removed-hidden-post': true }, subscriptions: [] };
testState.commentsByCid = {
'removed-hidden-post': { cid: 'removed-hidden-post', title: 'removed', communityAddress: 'music-posting.eth', postCid: 'removed-hidden-post' },
};
testState.feed = [{ cid: 'visible-board-post', title: 'visible', communityAddress: 'music-posting.eth' }];
testState.hasMore = false;
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
expect(testState.unblockCidMock).not.toHaveBeenCalled();
});
it('prefers the immediate feed until deferred catalog rows exist', () => {
const immediateFeed = [{ cid: 'all-post', title: 'one', communityAddress: 'music-posting.eth' }];
@@ -634,7 +890,7 @@ describe('Catalog', () => {
});
it('shows the empty subscriptions state when there are no subscribed boards to browse', async () => {
testState.account = { subscriptions: [] };
testState.account = { blockedCids: {}, subscriptions: [] };
await renderCatalog({
catalogProps: { viewType: 'subs' },
+101 -37
View File
@@ -11,12 +11,16 @@ import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-director
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
import { useFeedStateString } from '../../hooks/use-state-string';
import useExpandedTimeFilter from '../../hooks/use-expanded-time-filter';
import { filterHiddenComments, isCidHidden, useHiddenCids } from '../../hooks/use-hide';
import useHiddenCatalogThreads from '../../hooks/use-hidden-catalog-threads';
import usePruneHiddenCatalogThreads from '../../hooks/use-prune-hidden-catalog-threads';
import { useSuggestionFeedLoader } from '../../hooks/use-suggestion-feed-loader';
import useTimeFilter from '../../hooks/use-time-filter';
import useIsMobile from '../../hooks/use-is-mobile';
import useWindowWidth from '../../hooks/use-window-width';
import useCatalogStyleStore from '../../stores/use-catalog-style-store';
import useFeedResetStore from '../../stores/use-feed-reset-store';
import useHiddenCatalogThreadsStore from '../../stores/use-hidden-catalog-threads-store';
import useSortingStore from '../../stores/use-sorting-store';
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
import { getCommunityAddress, isDirectoryBoard, normalizeMultiboardFeedPath } from '../../lib/utils/route-utils';
@@ -276,6 +280,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const searchText = useCatalogFiltersStore((state) => state.searchText);
const account = useAccount();
const hiddenCids = useHiddenCids();
const subscriptions = account?.subscriptions;
const accountCommunityAddresses = useAccountCommunityAddresses();
const filteredDirectoryAddresses = useFilteredDirectoryAddresses();
@@ -331,6 +336,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const feedSortType = sortType === 'new' ? 'new' : 'active';
const catalogVirtualizationMode = useMemo(() => resolveCatalogVirtualizationMode(location.search, 'item-size'), [location.search]);
const themeKey = typeof document !== 'undefined' ? document.body.className : '';
const hadVisibleHiddenThreadsRef = useRef(false);
// Create a stable callback for filter matching
const handleFilterMatch = useCallback((filterIndex: number, cid: string, communityAddress: string) => {
@@ -369,6 +375,45 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
]);
const { feed, hasMore, loadMore, reset, expandTimeWindow } = useFeed(feedOptions);
const {
hiddenCatalogThreads,
hiddenThreadCandidates,
isLoadingHiddenCatalogThreads,
scopeKey: hiddenCatalogThreadsScopeKey,
} = useHiddenCatalogThreads({
candidateComments: feed,
communityAddresses,
sortType: feedSortType,
});
const hiddenThreadsCount = hiddenCatalogThreads.length;
const requestedShowHiddenThreads = useHiddenCatalogThreadsStore((state) => state.shownScopeKey === hiddenCatalogThreadsScopeKey);
const setShownHiddenThreadsScopeKey = useHiddenCatalogThreadsStore((state) => state.setShownScopeKey);
const setScopeHiddenThreadsCount = useHiddenCatalogThreadsStore((state) => state.setScopeHiddenThreadsCount);
const showHiddenThreads = requestedShowHiddenThreads && (hiddenThreadsCount > 0 || isLoadingHiddenCatalogThreads);
useEffect(() => {
setScopeHiddenThreadsCount(hiddenCatalogThreadsScopeKey, hiddenThreadsCount);
return () => setScopeHiddenThreadsCount(hiddenCatalogThreadsScopeKey, 0);
}, [hiddenCatalogThreadsScopeKey, hiddenThreadsCount, setScopeHiddenThreadsCount]);
useEffect(() => {
if (!requestedShowHiddenThreads) {
hadVisibleHiddenThreadsRef.current = false;
return;
}
if (hiddenThreadsCount > 0) {
hadVisibleHiddenThreadsRef.current = true;
return;
}
if (hadVisibleHiddenThreadsRef.current || !isLoadingHiddenCatalogThreads) {
setShownHiddenThreadsScopeKey(null);
}
}, [hiddenThreadsCount, isLoadingHiddenCatalogThreads, requestedShowHiddenThreads, setShownHiddenThreadsScopeKey]);
usePruneHiddenCatalogThreads({
communityAddress,
enabled: !isMultiboard && !hasMore && !hasActiveCatalogFiltering,
hiddenThreadCandidates,
sortType: feedSortType,
});
const visibleFeed = useMemo(() => filterHiddenComments(feed, hiddenCids), [feed, hiddenCids]);
const { currentTimeFilterName, currentTimeFilterSeconds, expandSuggestionTimeWindow } = useExpandedTimeFilter({
timeFilterName,
timeFilterSeconds: multiboardTimeFilterSeconds,
@@ -396,6 +441,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
filter: suggestionFilter,
newerThan: WEEK_IN_SECONDS,
});
const visibleWeeklyFeed = useMemo(() => filterHiddenComments(weeklyFeed, hiddenCids), [hiddenCids, weeklyFeed]);
const {
feed: monthlyFeed,
hasMore: monthlyFeedHasMore,
@@ -407,6 +453,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
filter: suggestionFilter,
newerThan: MONTH_IN_SECONDS,
});
const visibleMonthlyFeed = useMemo(() => filterHiddenComments(monthlyFeed, hiddenCids), [hiddenCids, monthlyFeed]);
const {
feed: yearlyFeed,
hasMore: yearlyFeedHasMore,
@@ -418,25 +465,26 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
filter: suggestionFilter,
newerThan: YEAR_IN_SECONDS,
});
const visibleYearlyFeed = useMemo(() => filterHiddenComments(yearlyFeed, hiddenCids), [hiddenCids, yearlyFeed]);
useSuggestionFeedLoader({
currentFeedLength: feed.length,
feedLength: weeklyFeed.length,
currentFeedLength: visibleFeed.length,
feedLength: visibleWeeklyFeed.length,
hasMore: weeklyFeedHasMore,
loadMore: loadMoreWeeklyFeed,
requestKey: `${suggestionRequestKeyBase}:1w`,
shouldLoad: shouldProbeWeeklyFeed,
});
useSuggestionFeedLoader({
currentFeedLength: feed.length,
feedLength: monthlyFeed.length,
currentFeedLength: visibleFeed.length,
feedLength: visibleMonthlyFeed.length,
hasMore: monthlyFeedHasMore,
loadMore: loadMoreMonthlyFeed,
requestKey: `${suggestionRequestKeyBase}:1m`,
shouldLoad: shouldProbeMonthlyFeed,
});
useSuggestionFeedLoader({
currentFeedLength: feed.length,
feedLength: yearlyFeed.length,
currentFeedLength: visibleFeed.length,
feedLength: visibleYearlyFeed.length,
hasMore: yearlyFeedHasMore,
loadMore: loadMoreYearlyFeed,
requestKey: `${suggestionRequestKeyBase}:1y`,
@@ -474,7 +522,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
cid &&
cid === postCid &&
commentCommunityAddress === communityAddress &&
!feedCids.has(cid);
!feedCids.has(cid) &&
!isCidHidden(hiddenCids, cid);
// If search is active, also check search conditions
if (basicConditions && searchText.trim()) {
@@ -487,30 +536,34 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
return basicConditions;
}),
[recentAccountComments, communityAddress, feedCids, searchText],
[recentAccountComments, communityAddress, feedCids, hiddenCids, searchText],
);
// show newest account comment at the top of the feed but after pinned posts
const combinedFeed = useMemo(() => {
const newFeed = [...feed];
const newFeed = [...visibleFeed];
const lastPinnedIndex = newFeed.map((post) => post.pinned).lastIndexOf(true);
if (filteredComments.length > 0) {
newFeed.splice(lastPinnedIndex + 1, 0, ...filteredComments);
}
return newFeed;
}, [feed, filteredComments]);
}, [visibleFeed, filteredComments]);
const cappedFeed = useMemo(
() => (effectiveInfiniteScroll ? combinedFeed : combinedFeed.slice(0, boardPostsPerPage * maxGuiPages)),
[effectiveInfiniteScroll, combinedFeed, boardPostsPerPage, maxGuiPages],
);
const moreThreadsSuggestion = useMemo(
() => (isMultiboard ? getTimeFilterSuggestion(feed.length, weeklyFeed.length, monthlyFeed.length, yearlyFeed.length, currentTimeFilterSeconds) : null),
[currentTimeFilterSeconds, feed.length, isMultiboard, monthlyFeed.length, weeklyFeed.length, yearlyFeed.length],
() =>
isMultiboard
? getTimeFilterSuggestion(visibleFeed.length, visibleWeeklyFeed.length, visibleMonthlyFeed.length, visibleYearlyFeed.length, currentTimeFilterSeconds)
: null,
[currentTimeFilterSeconds, isMultiboard, visibleFeed.length, visibleMonthlyFeed.length, visibleWeeklyFeed.length, visibleYearlyFeed.length],
);
const moreThreadsSuggestionPathname = isInAllView ? '/all/catalog' : isInSubscriptionsView ? '/subs/catalog' : isInModView ? '/mod/catalog' : null;
const sortedFeed = useMemo(() => sortCatalogFeedForDisplay(cappedFeed, sortType), [cappedFeed, sortType]);
const catalogBaseFeed = showHiddenThreads ? hiddenCatalogThreads : cappedFeed;
const sortedFeed = useMemo(() => sortCatalogFeedForDisplay(catalogBaseFeed, sortType), [catalogBaseFeed, sortType]);
useEffect(() => {
if (filteredComments.length > 0 && !resetTriggeredRef.current) {
@@ -529,6 +582,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const community = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined);
const { error, shortAddress, state, title } = community || {};
const footerHasMore = showHiddenThreads ? isLoadingHiddenCatalogThreads : hasMore;
const footerCombinedFeedLength = catalogBaseFeed.length;
const footerMoreThreadsSuggestion = showHiddenThreads ? null : moreThreadsSuggestion;
const footerShowLoadingEllipsis = showHiddenThreads ? isLoadingHiddenCatalogThreads : effectiveInfiniteScroll;
// Memoize footer component to preserve identity across renders (Virtuoso optimization)
// Note: useFeedStateString is called inside CatalogFooter to isolate re-renders from backend state changes
@@ -538,14 +595,14 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
<>
<CatalogFooter
communityAddresses={communityAddresses}
hasMore={hasMore}
combinedFeedLength={cappedFeed.length}
hasMore={footerHasMore}
combinedFeedLength={footerCombinedFeedLength}
currentTimeFilterName={currentTimeFilterName}
moreThreadsSuggestion={moreThreadsSuggestion}
moreThreadsSuggestion={footerMoreThreadsSuggestion}
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
moreThreadsSuggestionSearch={location.search}
onExpandTimeWindow={expandSuggestionTimeWindow}
showLoadingEllipsis={effectiveInfiniteScroll}
showLoadingEllipsis={footerShowLoadingEllipsis}
/>
<PageFooterDesktop
firstRow={
@@ -570,10 +627,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
}),
[
communityAddresses,
hasMore,
cappedFeed.length,
footerHasMore,
footerCombinedFeedLength,
currentTimeFilterName,
moreThreadsSuggestion,
footerMoreThreadsSuggestion,
moreThreadsSuggestionPathname,
expandSuggestionTimeWindow,
communityAddress,
@@ -581,7 +638,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
isInSubscriptionsView,
isInModView,
location.search,
effectiveInfiniteScroll,
footerShowLoadingEllipsis,
],
);
const catalogFooter = useMemo(
@@ -589,14 +646,14 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
<>
<CatalogFooter
communityAddresses={communityAddresses}
hasMore={hasMore}
combinedFeedLength={cappedFeed.length}
hasMore={footerHasMore}
combinedFeedLength={footerCombinedFeedLength}
currentTimeFilterName={currentTimeFilterName}
moreThreadsSuggestion={moreThreadsSuggestion}
moreThreadsSuggestion={footerMoreThreadsSuggestion}
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
moreThreadsSuggestionSearch={location.search}
onExpandTimeWindow={expandSuggestionTimeWindow}
showLoadingEllipsis={effectiveInfiniteScroll}
showLoadingEllipsis={footerShowLoadingEllipsis}
/>
<PageFooterDesktop
firstRow={
@@ -620,10 +677,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
),
[
communityAddresses,
hasMore,
cappedFeed.length,
footerHasMore,
footerCombinedFeedLength,
currentTimeFilterName,
moreThreadsSuggestion,
footerMoreThreadsSuggestion,
moreThreadsSuggestionPathname,
expandSuggestionTimeWindow,
communityAddress,
@@ -631,10 +688,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
isInSubscriptionsView,
isInModView,
location.search,
effectiveInfiniteScroll,
footerShowLoadingEllipsis,
],
);
const isFeedLoaded = feed.length > 0 || state === 'failed';
const isFeedLoaded = feed.length > 0 || hiddenThreadsCount > 0 || state === 'failed';
// Process the feed to move "top" posts to the top (applied after display sort)
const processedFeed = useMemo(() => {
@@ -669,8 +726,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
return [...topPosts, ...regularPosts];
}, [sortedFeed, filterItems]);
const processedFeedMode = showHiddenThreads ? 'hidden' : 'visible';
const deferredProcessedFeed = useDeferredValue(processedFeed);
const catalogRenderFeed = getCatalogRenderFeed(processedFeed, deferredProcessedFeed);
const deferredProcessedFeedMode = useDeferredValue(processedFeedMode);
const catalogRenderFeed = getCatalogRenderFeed(processedFeed, deferredProcessedFeedMode === processedFeedMode ? deferredProcessedFeed : []);
const matchedFilterColors = useMemo(() => {
const nextMatchedFilterColors = new Map<string, string>();
@@ -739,7 +798,9 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const catalogViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 2400, top: 1200 } : { bottom: 900, top: 600 }) : { bottom: 1200, top: 1200 };
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}` : `${location.pathname}${location.search}-${sortType}-catalog`;
const virtuosoStateKey = feedCacheKey
? `${feedCacheKey}-${sortType}-${processedFeedMode}`
: `${location.pathname}${location.search}-${sortType}-${processedFeedMode}-catalog`;
const navigationType = useNavigationType();
const hasBeenVisibleRef = useRef(false);
@@ -773,8 +834,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const lastVirtuosoState = shouldVirtualizeCatalog && navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined;
const renderCatalogRow = useCallback(
(index: number, row: Comment[]) => <CatalogRow estimatedHeight={rowHeightEstimates[index]} index={index} matchedFilterColors={matchedFilterColors} row={row} />,
[matchedFilterColors, rowHeightEstimates],
(index: number, row: Comment[]) => (
<CatalogRow estimatedHeight={rowHeightEstimates[index]} index={index} matchedFilterColors={matchedFilterColors} row={row} showHiddenPosts={showHiddenThreads} />
),
[matchedFilterColors, rowHeightEstimates, showHiddenThreads],
);
useEffect(() => {
@@ -812,7 +875,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
itemContent={renderCatalogRow}
useWindowScroll={true}
components={footerComponents}
endReached={effectiveInfiniteScroll && hasMore ? loadMore : undefined}
endReached={!showHiddenThreads && effectiveInfiniteScroll && footerHasMore ? loadMore : undefined}
ref={virtuosoRef}
restoreStateFrom={lastVirtuosoState}
initialScrollTop={lastVirtuosoState?.scrollTop}
@@ -826,6 +889,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
index={index}
matchedFilterColors={matchedFilterColors}
row={row}
showHiddenPosts={showHiddenThreads}
/>
))}
{catalogFooter}
@@ -837,8 +901,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
<div className={styles.footer}>
<CatalogLoading
communityAddresses={communityAddresses}
hasMore={hasMore}
combinedFeedLength={cappedFeed.length}
hasMore={footerHasMore}
combinedFeedLength={footerCombinedFeedLength}
state={state}
subscriptionsLength={isInSubscriptionsView ? subscriptions?.length || 0 : 1}
error={error}