fix(multiboards): restore stable time-filter suggestions

This commit is contained in:
Tommaso Casaburi
2026-04-18 15:58:27 +07:00
parent d07f5b3962
commit 49422591b8
51 changed files with 1296 additions and 119 deletions
+141 -6
View File
@@ -4,6 +4,7 @@ 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 Board, { type BoardProps } from '../board';
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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -32,9 +33,11 @@ const testState = vi.hoisted(() => ({
},
} as Record<string, { address: string; features?: Record<string, unknown> }>,
feed: [] as TestComment[],
feedOptionsCalls: [] as Array<{ communitiesLength?: number; newerThan?: number; postsPerPage?: number; sortType?: string }>,
feedStateString: 'syncing',
filteredDirectoryAddresses: ['music-posting.eth'] as string[],
hasMore: false,
respectPostsPerPageForNewerThan: new Set<number>(),
lastVirtuosoDefaultItemHeight: undefined as number | undefined,
lastVirtuosoIncreaseViewportBy: undefined as { top: number; bottom: number } | undefined,
lastVirtuosoMinOverscanItemCount: undefined as { top: number; bottom: number } | undefined,
@@ -63,6 +66,7 @@ const testState = vi.hoisted(() => ({
}));
vi.mock('react-i18next', () => ({
Trans: ({ components, i18nKey }: { components?: Record<number, React.ReactNode>; i18nKey: string }) => createElement(React.Fragment, {}, i18nKey, components?.[1]),
useTranslation: () => ({
t: (key: string) => key,
}),
@@ -90,18 +94,51 @@ const getScopedAccountComments = (options?: { commentIndices?: number[]; communi
return scopedComments;
};
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('@bitsocialnet/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) };
},
useFeed: () => ({
feed: testState.feed,
hasMore: testState.hasMore,
loadMore: testState.loadMoreMock,
reset: testState.resetMock,
}),
useFeed: (options?: {
communities?: unknown[];
filter?: { filter: (comment: TestComment) => boolean };
newerThan?: number;
postsPerPage?: number;
sortType?: string;
}) => {
testState.feedOptionsCalls.push({
communitiesLength: options?.communities?.length,
newerThan: options?.newerThan,
postsPerPage: options?.postsPerPage,
sortType: options?.sortType,
});
return {
feed: getScopedFeed(options),
hasMore: testState.hasMore,
loadMore: testState.loadMoreMock,
reset: testState.resetMock,
};
},
useCommunity: () => testState.community,
}));
@@ -286,9 +323,11 @@ describe('Board', () => {
},
};
testState.feed = [];
testState.feedOptionsCalls = [];
testState.feedStateString = 'syncing';
testState.filteredDirectoryAddresses = ['music-posting.eth'];
testState.hasMore = false;
testState.respectPostsPerPageForNewerThan = new Set();
testState.lastVirtuosoDefaultItemHeight = undefined;
testState.lastVirtuosoIncreaseViewportBy = undefined;
testState.lastVirtuosoMinOverscanItemCount = undefined;
@@ -315,6 +354,8 @@ describe('Board', () => {
testState.setEnableInfiniteScrollMock.mockReset();
testState.setResetFunctionMock.mockReset();
document.title = 'before';
clearStableLastVisitTimeFilterName();
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now()));
Object.defineProperty(window, 'scrollTo', {
configurable: true,
value: vi.fn(),
@@ -329,6 +370,8 @@ describe('Board', () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
clearStableLastVisitTimeFilterName();
localStorage.clear();
});
it('renders the current page feed, inserts recent account comments, and wires footer actions', async () => {
@@ -446,6 +489,98 @@ describe('Board', () => {
expect(container.textContent).toContain('not_subscribed_to_any_board');
});
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('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(Array.from(container.querySelectorAll('a')).some((link) => link.getAttribute('href') === '/all?t=1w')).toBe(true);
});
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('surfaces board load errors when the feed is empty', async () => {
testState.community = {
error: new Error('board failed'),
+6 -1
View File
@@ -12,6 +12,11 @@
padding: 15px 0 20px 5px;
}
.morePostsSuggestion {
color: var(--body-font-color);
padding-bottom: 5px;
}
.footer a {
cursor: pointer;
text-decoration: var(--button-text-decoration);
@@ -47,4 +52,4 @@
border-image-source: url('/assets/garland.png');
border-style: solid;
padding-top: 50px;
}
}
+117 -11
View File
@@ -3,7 +3,7 @@ import { Link, useLocation, useNavigate, useNavigationType, useParams } from 're
import { Comment, useAccount, useAccountComments, useCommunity, useFeed } from '@bitsocialnet/bitsocial-react-hooks';
import { useCommunityField } from '../../hooks/use-stable-community';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import styles from './board.module.css';
import mobileFooterStyles from '../../components/footer/footer.module.css';
import { shouldShowSnow } from '../../lib/snow';
@@ -18,10 +18,13 @@ import useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store'
import usePostNumberStore from '../../stores/use-post-number-store';
import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size';
import useIsMobile from '../../hooks/use-is-mobile';
import { useSuggestionFeedLoader } from '../../hooks/use-suggestion-feed-loader';
import useTimeFilter from '../../hooks/use-time-filter';
import { getPageSlice } from '../../lib/utils/board-feed-pagination';
import { getPageFromFeedPath, getCommunityAddress, isDirectoryBoard, normalizeMultiboardFeedPath, stripPageFromFeedPath } from '../../lib/utils/route-utils';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { getSearchWithTimeFilter, getTimeFilterSuggestion, type TimeFilterSuggestion } from '../../lib/utils/time-filter-utils';
import { getPretextItemSizeFromElement, resolveFeedVirtualizationMode } from '../../lib/utils/pretext-height-estimates';
import ErrorDisplay from '../../components/error-display/error-display';
import LoadingEllipsis from '../../components/loading-ellipsis';
@@ -32,6 +35,9 @@ import { Post } from '../post';
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
const RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS = 60 * 60;
const WEEK_IN_SECONDS = 7 * 24 * 60 * 60;
const MONTH_IN_SECONDS = 30 * 24 * 60 * 60;
const YEAR_IN_SECONDS = 365 * 24 * 60 * 60;
// Keep the hook on its indexed fast path when this view should not inject local posts.
const EMPTY_ACCOUNT_COMMENT_LOOKUP = { commentIndices: [-1] };
@@ -42,9 +48,12 @@ interface BoardFooterProps {
communityAddresses: string[];
hasMore: boolean;
combinedFeedLength: number;
isInAllView: boolean;
isInSubscriptionsView: boolean;
isInModView: boolean;
currentTimeFilterName: string;
moreThreadsSuggestion: TimeFilterSuggestion | null;
moreThreadsSuggestionPathname: string | null;
moreThreadsSuggestionSearch: string;
communityState: string | undefined;
subscriptionsLength: number;
accountCommunityAddressesLength: number;
@@ -59,9 +68,12 @@ const BoardFooter = ({
communityAddresses,
hasMore,
combinedFeedLength,
isInAllView,
isInSubscriptionsView,
isInModView,
currentTimeFilterName,
moreThreadsSuggestion,
moreThreadsSuggestionPathname,
moreThreadsSuggestionSearch,
communityState,
subscriptionsLength,
accountCommunityAddressesLength,
@@ -72,10 +84,26 @@ const BoardFooter = ({
const loadingStateString = useFeedStateString(communityAddresses) || (combinedFeedLength === 0 ? t('loading_feed') : t('looking_for_more_posts'));
let footerContent;
if (combinedFeedLength === 0) {
if (moreThreadsSuggestion && moreThreadsSuggestionPathname) {
footerContent = (
<div className={styles.morePostsSuggestion}>
<Trans
i18nKey={moreThreadsSuggestion.i18nKey}
values={{ currentTimeFilterName, count: combinedFeedLength }}
components={{
1: (
<Link
to={{ pathname: moreThreadsSuggestionPathname, search: getSearchWithTimeFilter(moreThreadsSuggestionSearch, moreThreadsSuggestion.timeFilterName) }}
/>
),
}}
/>
</div>
);
} else if (combinedFeedLength === 0) {
footerContent = t('no_threads');
}
if (hasMore || (communityAddresses && communityAddresses.length === 0)) {
if (communityAddresses && communityAddresses.length === 0) {
footerContent = null;
}
return (
@@ -100,16 +128,20 @@ export interface BoardProps {
feedCacheKey?: string;
viewType?: 'all' | 'subs' | 'mod' | 'board';
boardIdentifier?: string;
timeFilterNameFromCache?: string;
isVisible?: boolean;
}
const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, isVisible = true }: BoardProps) => {
const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, timeFilterNameFromCache, isVisible = true }: BoardProps) => {
const { t } = useTranslation();
const location = useLocation();
const params = useParams();
const isInAllView = viewType ? viewType === 'all' : false;
const isInSubscriptionsView = viewType ? viewType === 'subs' : false;
const isInModView = viewType ? viewType === 'mod' : false;
const { timeFilterName, timeFilterSeconds } = useTimeFilter(timeFilterNameFromCache);
const isMultiboardView = isInAllView || isInSubscriptionsView || isInModView;
const multiboardTimeFilterSeconds = isMultiboardView ? timeFilterSeconds : undefined;
const directories = useDirectories();
const resolvedAddressFromUrl = useResolvedCommunityAddress();
@@ -164,11 +196,76 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
sortType: BOARD_SORT_TYPE,
postsPerPage: effectiveInfiniteScroll ? infiniteFeedPostsPerPage : paginationFeedPostsPerPage,
filter: excludeArchivedFilter,
newerThan: multiboardTimeFilterSeconds,
}),
[communities, effectiveInfiniteScroll, infiniteFeedPostsPerPage, paginationFeedPostsPerPage, excludeArchivedFilter],
[communities, effectiveInfiniteScroll, infiniteFeedPostsPerPage, paginationFeedPostsPerPage, excludeArchivedFilter, multiboardTimeFilterSeconds],
);
const { feed, hasMore, loadMore, reset } = useFeed(feedOptions);
const shouldProbeSuggestionFeeds = isVisible && isMultiboardView && typeof multiboardTimeFilterSeconds === 'number';
const shouldProbeWeeklyFeed = shouldProbeSuggestionFeeds && multiboardTimeFilterSeconds < WEEK_IN_SECONDS;
const shouldProbeMonthlyFeed = shouldProbeSuggestionFeeds && multiboardTimeFilterSeconds < MONTH_IN_SECONDS;
const shouldProbeYearlyFeed = shouldProbeSuggestionFeeds && multiboardTimeFilterSeconds < YEAR_IN_SECONDS;
// Keep suggestion feeds on a stable hook identity; the loader widens them by paging, not by recreating the feed.
const suggestionPostsPerPage = infiniteFeedPostsPerPage;
const suggestionRequestKeyBase = `${location.pathname}${location.search}`;
const {
feed: weeklyFeed,
hasMore: weeklyFeedHasMore,
loadMore: loadMoreWeeklyFeed,
} = useFeed({
communities: shouldProbeWeeklyFeed ? communities : [],
sortType: BOARD_SORT_TYPE,
postsPerPage: suggestionPostsPerPage,
filter: excludeArchivedFilter,
newerThan: WEEK_IN_SECONDS,
});
const {
feed: monthlyFeed,
hasMore: monthlyFeedHasMore,
loadMore: loadMoreMonthlyFeed,
} = useFeed({
communities: shouldProbeMonthlyFeed ? communities : [],
sortType: BOARD_SORT_TYPE,
postsPerPage: suggestionPostsPerPage,
filter: excludeArchivedFilter,
newerThan: MONTH_IN_SECONDS,
});
const {
feed: yearlyFeed,
hasMore: yearlyFeedHasMore,
loadMore: loadMoreYearlyFeed,
} = useFeed({
communities: shouldProbeYearlyFeed ? communities : [],
sortType: BOARD_SORT_TYPE,
postsPerPage: suggestionPostsPerPage,
filter: excludeArchivedFilter,
newerThan: YEAR_IN_SECONDS,
});
useSuggestionFeedLoader({
currentFeedLength: feed.length,
feedLength: weeklyFeed.length,
hasMore: weeklyFeedHasMore,
loadMore: loadMoreWeeklyFeed,
requestKey: `${suggestionRequestKeyBase}:1w`,
shouldLoad: shouldProbeWeeklyFeed,
});
useSuggestionFeedLoader({
currentFeedLength: feed.length,
feedLength: monthlyFeed.length,
hasMore: monthlyFeedHasMore,
loadMore: loadMoreMonthlyFeed,
requestKey: `${suggestionRequestKeyBase}:1m`,
shouldLoad: shouldProbeMonthlyFeed,
});
useSuggestionFeedLoader({
currentFeedLength: feed.length,
feedLength: yearlyFeed.length,
hasMore: yearlyFeedHasMore,
loadMore: loadMoreYearlyFeed,
requestKey: `${suggestionRequestKeyBase}:1y`,
shouldLoad: shouldProbeYearlyFeed,
});
const accountCommentLookupOptions = useMemo(
() =>
communityAddress
@@ -230,6 +327,11 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
() => (effectiveInfiniteScroll ? combinedFeed : combinedFeed.slice(0, guiPostsPerPage * maxGuiPages)),
[effectiveInfiniteScroll, combinedFeed, guiPostsPerPage, maxGuiPages],
);
const moreThreadsSuggestion = useMemo(
() => (isMultiboardView ? getTimeFilterSuggestion(feed.length, weeklyFeed.length, monthlyFeed.length, yearlyFeed.length, multiboardTimeFilterSeconds) : null),
[feed.length, isMultiboardView, monthlyFeed.length, multiboardTimeFilterSeconds, weeklyFeed.length, yearlyFeed.length],
);
const moreThreadsSuggestionPathname = isInAllView ? '/all' : isInSubscriptionsView ? '/subs' : isInModView ? '/mod' : null;
const registerComments = usePostNumberStore((state) => state.registerComments);
const totalPages = useMemo(() => Math.min(maxGuiPages, Math.ceil(cappedFeed.length / guiPostsPerPage) || 1), [cappedFeed.length, guiPostsPerPage, maxGuiPages]);
const currentPageFeed = useMemo(
@@ -238,7 +340,6 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
);
const navigate = useNavigate();
const isMultiboardView = isInAllView || isInSubscriptionsView || isInModView;
const defaultFeedVirtualizationMode = isMobile && isMultiboardView ? 'off' : 'item-size';
const feedVirtualizationMode = useMemo(
() => resolveFeedVirtualizationMode(location.search, defaultFeedVirtualizationMode),
@@ -305,9 +406,12 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
communityAddresses={communityAddresses}
hasMore={hasMore}
combinedFeedLength={combinedFeed.length}
isInAllView={isInAllView}
isInSubscriptionsView={isInSubscriptionsView}
isInModView={isInModView}
currentTimeFilterName={timeFilterName}
moreThreadsSuggestion={moreThreadsSuggestion}
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
moreThreadsSuggestionSearch={location.search}
communityState={communityState}
subscriptionsLength={subscriptions?.length || 0}
accountCommunityAddressesLength={accountCommunityAddresses?.length || 0}
@@ -380,9 +484,11 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
communityAddresses,
hasMore,
combinedFeed.length,
isInAllView,
isInSubscriptionsView,
isInModView,
timeFilterName,
moreThreadsSuggestion,
moreThreadsSuggestionPathname,
communityState,
communityAddress,
subscriptions?.length,
@@ -400,7 +506,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
);
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${BOARD_SORT_TYPE}` : `${location.pathname}-${BOARD_SORT_TYPE}`;
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${BOARD_SORT_TYPE}` : `${location.pathname}${location.search}-${BOARD_SORT_TYPE}`;
const navigationType = useNavigationType();
const boardViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 1400, top: 2400 } : { bottom: 600, top: 600 }) : { bottom: 1200, top: 1200 };
const boardMinOverscanItemCount = isMultiboardView && isMobile ? { bottom: 4, top: 8 } : undefined;
+148 -8
View File
@@ -4,6 +4,7 @@ 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 Catalog, { getCatalogRenderFeed, type CatalogProps } from '../catalog';
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>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -35,6 +36,7 @@ const testState = vi.hoisted(() => ({
account: { subscriptions: [] as string[] },
accountComments: [] as TestComment[],
accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>,
accountCommunityAddresses: [] as string[],
directoryByAddress: {
'music-posting.eth': {
address: 'music-posting.eth',
@@ -43,7 +45,7 @@ const testState = vi.hoisted(() => ({
} as Record<string, { address: string; features?: Record<string, unknown> }>,
directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>,
feed: [] as TestComment[],
feedOptionsCalls: [] as Array<{ postsPerPage?: number }>,
feedOptionsCalls: [] as Array<{ communitiesLength?: number; newerThan?: number; postsPerPage?: number; sortType?: string }>,
filterItems: [] as FilterItem[],
filteredDirectoryAddresses: ['music-posting.eth'] as string[],
hasMore: false,
@@ -55,6 +57,7 @@ const testState = vi.hoisted(() => ({
maxGuiPages: 3,
paginationFeedPostsPerPage: 6,
},
respectPostsPerPageForNewerThan: new Set<number>(),
resetMock: vi.fn(),
resolvedCommunityAddress: 'music-posting.eth' as string | undefined,
searchText: '',
@@ -89,6 +92,7 @@ function useCatalogFiltersStoreMock<T>(selector?: (state: ReturnType<typeof getC
useCatalogFiltersStoreMock.getState = getCatalogFiltersState;
vi.mock('react-i18next', () => ({
Trans: ({ components, i18nKey }: { components?: Record<number, React.ReactNode>; i18nKey: string }) => createElement(React.Fragment, {}, i18nKey, components?.[1]),
useTranslation: () => ({
t: (key: string) => key,
}),
@@ -116,16 +120,46 @@ const getScopedAccountComments = (options?: { commentIndices?: number[]; communi
return scopedComments;
};
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('@bitsocialnet/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) };
},
useFeed: (options: { filter?: { filter: (comment: TestComment) => boolean }; postsPerPage?: number }) => {
testState.feedOptionsCalls.push({ postsPerPage: options.postsPerPage });
useFeed: (options: {
communities?: unknown[];
filter?: { filter: (comment: TestComment) => boolean };
newerThan?: number;
postsPerPage?: number;
sortType?: string;
}) => {
testState.feedOptionsCalls.push({
communitiesLength: options.communities?.length,
newerThan: options.newerThan,
postsPerPage: options.postsPerPage,
sortType: options.sortType,
});
return {
feed: options.filter ? testState.feed.filter((comment) => options.filter?.filter(comment)) : testState.feed,
feed: getScopedFeed(options),
hasMore: testState.hasMore,
loadMore: testState.loadMoreMock,
reset: testState.resetMock,
@@ -179,6 +213,10 @@ vi.mock('../../../hooks/use-board-feed-page-size', () => ({
useBoardFeedPageSize: () => testState.pageSizes,
}));
vi.mock('../../../hooks/use-account-community-addresses', () => ({
useAccountCommunityAddresses: () => testState.accountCommunityAddresses,
}));
vi.mock('../../../hooks/use-filtered-directory-addresses', () => ({
useFilteredDirectoryAddresses: () => testState.filteredDirectoryAddresses,
}));
@@ -264,8 +302,8 @@ let root: Root;
const LocationProbe = () => {
const location = useLocation();
React.useLayoutEffect(() => {
latestLocation = location.pathname;
}, [location.pathname]);
latestLocation = `${location.pathname}${location.search}`;
}, [location.pathname, location.search]);
return null;
};
@@ -305,6 +343,7 @@ describe('Catalog', () => {
testState.account = { subscriptions: [] };
testState.accountComments = [];
testState.accountCommentsCalls = [];
testState.accountCommunityAddresses = [];
testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }];
testState.directoryByAddress = {
'music-posting.eth': {
@@ -323,6 +362,7 @@ describe('Catalog', () => {
maxGuiPages: 3,
paginationFeedPostsPerPage: 6,
};
testState.respectPostsPerPageForNewerThan = new Set();
testState.resolvedCommunityAddress = 'music-posting.eth';
testState.searchText = '';
testState.showOPComment = true;
@@ -341,6 +381,8 @@ describe('Catalog', () => {
testState.setCurrentCommunityAddressMock.mockReset();
testState.setResetFunctionMock.mockReset();
document.title = 'before';
clearStableLastVisitTimeFilterName();
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now()));
container = document.createElement('div');
document.body.appendChild(container);
@@ -350,6 +392,8 @@ describe('Catalog', () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
clearStableLastVisitTimeFilterName();
localStorage.clear();
});
it('applies catalog filters and promotes top matches', async () => {
@@ -408,13 +452,109 @@ describe('Catalog', () => {
});
expect(latestLocation).toBe('/all/catalog');
expect(testState.feedOptionsCalls.at(-1)?.postsPerPage).toBe(24);
expect(testState.feedOptionsCalls).toEqual(expect.arrayContaining([expect.objectContaining({ postsPerPage: 24 })]));
const loadMoreCallCountBeforeEndReached = testState.loadMoreMock.mock.calls.length;
await act(async () => {
container.querySelector<HTMLButtonElement>('[data-testid="end-reached"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.loadMoreMock).toHaveBeenCalledTimes(1);
expect(testState.loadMoreMock.mock.calls.length).toBe(loadMoreCallCountBeforeEndReached + 1);
});
it('passes multiboard time filters to useFeed and keeps query params during canonical redirects', async () => {
testState.feed = [{ cid: 'all-post', title: 'one', communityAddress: 'music-posting.eth' }];
await renderCatalog({
catalogProps: { viewType: 'all', timeFilterNameFromCache: '48h' },
initialEntry: '/all/catalog/7?t=24h',
routePath: '/all/*',
});
expect(latestLocation).toBe('/all/catalog?t=24h');
expect(testState.feedOptionsCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({
newerThan: 48 * 60 * 60,
postsPerPage: 24,
sortType: 'new',
}),
]),
);
});
it('shows a wider multiboard catalog 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', title: 'recent', communityAddress: 'music-posting.eth', timestamp: now - 2 * 24 * 60 * 60 },
{ cid: 'older-post', title: 'older', communityAddress: 'music-posting.eth', timestamp: now - 5 * 24 * 60 * 60 },
];
await renderCatalog({
catalogProps: { viewType: 'all' },
initialEntry: '/all/catalog?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(Array.from(container.querySelectorAll('a')).some((link) => link.getAttribute('href') === '/all/catalog?t=1w')).toBe(true);
});
it('keeps broader catalog suggestion feeds on the base page size so their identities stay stable while scrolling', async () => {
const now = Math.floor(Date.now() / 1000);
testState.feed = [
...Array.from({ length: 25 }, (_, index) => ({
cid: `post-${index + 1}`,
title: `thread ${index + 1}`,
communityAddress: 'music-posting.eth',
timestamp: now - (index + 1) * 60 * 60,
})),
{ cid: 'older-post', title: 'older thread', communityAddress: 'music-posting.eth', timestamp: now - 20 * 24 * 60 * 60 },
];
testState.pageSizes = {
guiPostsPerPage: 2,
maxGuiPages: 3,
paginationFeedPostsPerPage: 6,
};
testState.respectPostsPerPageForNewerThan = new Set([30 * 24 * 60 * 60, 365 * 24 * 60 * 60]);
await renderCatalog({
catalogProps: { viewType: 'all' },
initialEntry: '/all/catalog?t=1w',
routePath: '/all/*',
});
expect(testState.feedOptionsCalls).toEqual(
expect.arrayContaining([
expect.objectContaining({ newerThan: 30 * 24 * 60 * 60, postsPerPage: 24, sortType: 'new' }),
expect.objectContaining({ newerThan: 365 * 24 * 60 * 60, postsPerPage: 24, sortType: 'new' }),
]),
);
});
it('disables broader suggestion probe feeds when the multiboard filter is already all', async () => {
testState.feed = [{ cid: 'all-post', title: 'one', communityAddress: 'music-posting.eth' }];
await renderCatalog({
catalogProps: { viewType: 'all' },
initialEntry: '/all/catalog?t=all',
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('captures Virtuoso state on pagehide instead of wiring a scroll hot-path listener', async () => {
+5 -1
View File
@@ -3,6 +3,10 @@
color: var(--post-mobile-abbr-text-color);
}
.morePostsSuggestion {
padding-bottom: 5px;
}
.footer a {
text-decoration: var(--button-text-decoration);
color: var(--button-desktop-text-color);
@@ -16,4 +20,4 @@
.footer {
padding-left: 5px;
}
}
}
+191 -16
View File
@@ -1,14 +1,17 @@
import { useDeferredValue, useEffect, useMemo, useRef, useCallback, useLayoutEffect } from 'react';
import { useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Link, useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { Comment, useAccount, useCommunity, useFeed, useAccountComments } from '@bitsocialnet/bitsocial-react-hooks';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
import { useCommunityIdentifier, useCommunityIdentifiers } from '../../hooks/use-community-identifiers';
import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size';
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-directory-addresses';
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
import { useFeedStateString } from '../../hooks/use-state-string';
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';
@@ -27,6 +30,7 @@ import { commentMatchesPattern } from '../../lib/utils/pattern-utils';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
import { sortCatalogFeedForDisplay } from '../../lib/utils/catalog-sort';
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { getSearchWithTimeFilter, getTimeFilterSuggestion, type TimeFilterSuggestion } from '../../lib/utils/time-filter-utils';
import {
getCatalogRowHeightEstimates,
getPretextItemSizeFromElement,
@@ -37,6 +41,9 @@ import {
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
const RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS = 60 * 60;
const WEEK_IN_SECONDS = 7 * 24 * 60 * 60;
const MONTH_IN_SECONDS = 30 * 24 * 60 * 60;
const YEAR_IN_SECONDS = 365 * 24 * 60 * 60;
// Keep the hook on its indexed fast path when this view should not inject local posts.
const EMPTY_ACCOUNT_COMMENT_LOOKUP = { commentIndices: [-1] };
export const getCatalogRenderFeed = <T,>(processedFeed: readonly T[], deferredProcessedFeed: readonly T[]): readonly T[] =>
@@ -46,6 +53,10 @@ interface CatalogFooterProps {
communityAddresses: string[];
hasMore: boolean;
combinedFeedLength: number;
currentTimeFilterName: string;
moreThreadsSuggestion: TimeFilterSuggestion | null;
moreThreadsSuggestionPathname: string | null;
moreThreadsSuggestionSearch: string;
/** When false, suppress the loading ellipsis (e.g. non-infinite mode) */
showLoadingEllipsis?: boolean;
}
@@ -53,18 +64,44 @@ interface CatalogFooterProps {
// Defined outside Catalog to preserve component identity across renders (Virtuoso optimization)
// The useFeedStateString hook is called here instead of in Catalog to isolate re-renders
// caused by backend IPFS state changes to just this footer component
const CatalogFooter = ({ communityAddresses, hasMore, combinedFeedLength, showLoadingEllipsis = true }: CatalogFooterProps) => {
const CatalogFooter = ({
communityAddresses,
hasMore,
combinedFeedLength,
currentTimeFilterName,
moreThreadsSuggestion,
moreThreadsSuggestionPathname,
moreThreadsSuggestionSearch,
showLoadingEllipsis = true,
}: CatalogFooterProps) => {
const { t } = useTranslation();
const loadingStateString = useFeedStateString(communityAddresses) || (combinedFeedLength === 0 ? t('loading_feed') : t('looking_for_more_posts'));
let footerContent;
if (combinedFeedLength === 0) {
if (moreThreadsSuggestion && moreThreadsSuggestionPathname) {
footerContent = (
<div className={styles.morePostsSuggestion}>
<Trans
i18nKey={moreThreadsSuggestion.i18nKey}
values={{ currentTimeFilterName, count: combinedFeedLength }}
components={{
1: (
<Link
to={{ pathname: moreThreadsSuggestionPathname, search: getSearchWithTimeFilter(moreThreadsSuggestionSearch, moreThreadsSuggestion.timeFilterName) }}
/>
),
}}
/>
</div>
);
} else if (combinedFeedLength === 0) {
footerContent = t('no_threads');
}
if (hasMore || (communityAddresses && communityAddresses.length === 0)) {
footerContent = (
<>
{footerContent}
{showLoadingEllipsis && (
<div className={styles.stateString}>
<LoadingEllipsis string={loadingStateString} />
@@ -113,6 +150,7 @@ const createContentFilter = (
filterItems: { text: string; enabled: boolean; count: number; filteredCids: Set<string>; hide: boolean; top: boolean; color?: string }[],
communityAddress: string,
onFilterMatch?: (filterIndex: number, cid: string, communityAddress: string) => void,
trackMatches = true,
) => {
// Create a unique key based on the enabled filter items
const enabledFilters = filterItems.filter((item) => item.enabled && item.text.trim() !== '');
@@ -135,7 +173,7 @@ const createContentFilter = (
if (commentMatchesPattern(comment, pattern)) {
// Find the original filter index to increment count
const filterIndex = filterItems.findIndex((f) => f.text === item.text && f.enabled);
if (filterIndex !== -1) {
if (trackMatches && filterIndex !== -1) {
if (onFilterMatch) {
onFilterMatch(filterIndex, comment.cid, communityAddress);
} else {
@@ -165,8 +203,9 @@ const createCombinedFilter = (
searchText: string,
communityAddress: string,
onFilterMatch?: (filterIndex: number, cid: string, communityAddress: string) => void,
trackMatches = true,
) => {
const contentFilter = createContentFilter(filterItems, communityAddress, onFilterMatch);
const contentFilter = createContentFilter(filterItems, communityAddress, onFilterMatch, trackMatches);
const searchFilter = {
filter: (comment: Comment) => {
@@ -192,10 +231,11 @@ export interface CatalogProps {
feedCacheKey?: string;
viewType?: 'all' | 'subs' | 'mod' | 'board';
boardIdentifier?: string;
timeFilterNameFromCache?: string;
isVisible?: boolean;
}
const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, isVisible = true }: CatalogProps) => {
const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, timeFilterNameFromCache, isVisible = true }: CatalogProps) => {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
@@ -206,6 +246,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const isInModView = viewType ? viewType === 'mod' : false;
const isMultiboard = isInAllView || isInSubscriptionsView || isInModView;
const { timeFilterName, timeFilterSeconds } = useTimeFilter(timeFilterNameFromCache);
const multiboardTimeFilterSeconds = isMultiboard ? timeFilterSeconds : undefined;
// Single-board catalogs always cap at maxGuiPages (no infinite scroll beyond the board's page limit)
const effectiveInfiniteScroll = isMultiboard;
@@ -222,6 +264,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const account = useAccount();
const subscriptions = account?.subscriptions;
const accountCommunityAddresses = useAccountCommunityAddresses();
const filteredDirectoryAddresses = useFilteredDirectoryAddresses();
const communityAddresses = useMemo(() => {
@@ -231,9 +274,12 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
if (isInSubscriptionsView) {
return (subscriptions || []).filter(Boolean); // Filter out any undefined/null values
}
if (isInModView) {
return accountCommunityAddresses;
}
// Only include communityAddress if it's defined
return communityAddress ? [communityAddress] : [];
}, [isInAllView, isInSubscriptionsView, communityAddress, filteredDirectoryAddresses, subscriptions]);
}, [accountCommunityAddresses, isInAllView, isInSubscriptionsView, isInModView, communityAddress, filteredDirectoryAddresses, subscriptions]);
const communities = useCommunityIdentifiers(communityAddresses);
const communityIdentifier = useCommunityIdentifier(communityAddress);
@@ -252,9 +298,9 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
if (!(isInAllView || isInSubscriptionsView || isInModView)) return;
const canonical = normalizeMultiboardFeedPath(location.pathname);
if (location.pathname !== canonical) {
navigate(canonical, { replace: true });
navigate({ pathname: canonical, search: location.search }, { replace: true });
}
}, [isInAllView, isInSubscriptionsView, isInModView, location.pathname, navigate]);
}, [isInAllView, isInSubscriptionsView, isInModView, location.pathname, location.search, navigate]);
const { sortType } = useSortingStore();
const feedSortType = sortType === 'new' ? 'new' : 'active';
@@ -280,10 +326,90 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
sortType: feedSortType,
postsPerPage: isMultiboard ? multiboardCatalogPostsPerPage : paginationFeedPostsPerPage,
filter: createCombinedFilter(filterItems, searchText, communityAddress || 'all', handleFilterMatch),
newerThan: multiboardTimeFilterSeconds,
};
}, [communities, feedSortType, isMultiboard, paginationFeedPostsPerPage, multiboardCatalogPostsPerPage, filterItems, searchText, communityAddress, handleFilterMatch]);
}, [
communities,
feedSortType,
isMultiboard,
paginationFeedPostsPerPage,
multiboardCatalogPostsPerPage,
filterItems,
searchText,
communityAddress,
handleFilterMatch,
multiboardTimeFilterSeconds,
]);
const { feed, hasMore, loadMore, reset } = useFeed(feedOptions);
const shouldProbeSuggestionFeeds = isVisible && isMultiboard && typeof multiboardTimeFilterSeconds === 'number';
const shouldProbeWeeklyFeed = shouldProbeSuggestionFeeds && multiboardTimeFilterSeconds < WEEK_IN_SECONDS;
const shouldProbeMonthlyFeed = shouldProbeSuggestionFeeds && multiboardTimeFilterSeconds < MONTH_IN_SECONDS;
const shouldProbeYearlyFeed = shouldProbeSuggestionFeeds && multiboardTimeFilterSeconds < YEAR_IN_SECONDS;
const suggestionFilter = useMemo(
() => createCombinedFilter(filterItems, searchText, communityAddress || 'all', undefined, false),
[communityAddress, filterItems, searchText],
);
// Keep suggestion feeds on a stable hook identity; the loader widens them by paging, not by recreating the feed.
const suggestionPostsPerPage = multiboardCatalogPostsPerPage;
const suggestionRequestKeyBase = `${location.pathname}${location.search}`;
const {
feed: weeklyFeed,
hasMore: weeklyFeedHasMore,
loadMore: loadMoreWeeklyFeed,
} = useFeed({
communities: shouldProbeWeeklyFeed ? communities : [],
sortType: feedSortType,
postsPerPage: suggestionPostsPerPage,
filter: suggestionFilter,
newerThan: WEEK_IN_SECONDS,
});
const {
feed: monthlyFeed,
hasMore: monthlyFeedHasMore,
loadMore: loadMoreMonthlyFeed,
} = useFeed({
communities: shouldProbeMonthlyFeed ? communities : [],
sortType: feedSortType,
postsPerPage: suggestionPostsPerPage,
filter: suggestionFilter,
newerThan: MONTH_IN_SECONDS,
});
const {
feed: yearlyFeed,
hasMore: yearlyFeedHasMore,
loadMore: loadMoreYearlyFeed,
} = useFeed({
communities: shouldProbeYearlyFeed ? communities : [],
sortType: feedSortType,
postsPerPage: suggestionPostsPerPage,
filter: suggestionFilter,
newerThan: YEAR_IN_SECONDS,
});
useSuggestionFeedLoader({
currentFeedLength: feed.length,
feedLength: weeklyFeed.length,
hasMore: weeklyFeedHasMore,
loadMore: loadMoreWeeklyFeed,
requestKey: `${suggestionRequestKeyBase}:1w`,
shouldLoad: shouldProbeWeeklyFeed,
});
useSuggestionFeedLoader({
currentFeedLength: feed.length,
feedLength: monthlyFeed.length,
hasMore: monthlyFeedHasMore,
loadMore: loadMoreMonthlyFeed,
requestKey: `${suggestionRequestKeyBase}:1m`,
shouldLoad: shouldProbeMonthlyFeed,
});
useSuggestionFeedLoader({
currentFeedLength: feed.length,
feedLength: yearlyFeed.length,
hasMore: yearlyFeedHasMore,
loadMore: loadMoreYearlyFeed,
requestKey: `${suggestionRequestKeyBase}:1y`,
shouldLoad: shouldProbeYearlyFeed,
});
const accountCommentLookupOptions = useMemo(
() =>
communityAddress
@@ -346,6 +472,11 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
() => (effectiveInfiniteScroll ? combinedFeed : combinedFeed.slice(0, boardPostsPerPage * maxGuiPages)),
[effectiveInfiniteScroll, combinedFeed, boardPostsPerPage, maxGuiPages],
);
const moreThreadsSuggestion = useMemo(
() => (isMultiboard ? getTimeFilterSuggestion(feed.length, weeklyFeed.length, monthlyFeed.length, yearlyFeed.length, multiboardTimeFilterSeconds) : null),
[feed.length, isMultiboard, monthlyFeed.length, multiboardTimeFilterSeconds, weeklyFeed.length, yearlyFeed.length],
);
const moreThreadsSuggestionPathname = isInAllView ? '/all/catalog' : isInSubscriptionsView ? '/subs/catalog' : isInModView ? '/mod/catalog' : null;
const sortedFeed = useMemo(() => sortCatalogFeedForDisplay(cappedFeed, sortType), [cappedFeed, sortType]);
@@ -373,7 +504,16 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
() => ({
Footer: () => (
<>
<CatalogFooter communityAddresses={communityAddresses} hasMore={hasMore} combinedFeedLength={cappedFeed.length} showLoadingEllipsis={effectiveInfiniteScroll} />
<CatalogFooter
communityAddresses={communityAddresses}
hasMore={hasMore}
combinedFeedLength={cappedFeed.length}
currentTimeFilterName={timeFilterName}
moreThreadsSuggestion={moreThreadsSuggestion}
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
moreThreadsSuggestionSearch={location.search}
showLoadingEllipsis={effectiveInfiniteScroll}
/>
<PageFooterDesktop
firstRow={
<CatalogFooterFirstRow
@@ -395,12 +535,34 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
</>
),
}),
[communityAddresses, hasMore, cappedFeed.length, communityAddress, isInAllView, isInSubscriptionsView, isInModView, effectiveInfiniteScroll],
[
communityAddresses,
hasMore,
cappedFeed.length,
timeFilterName,
moreThreadsSuggestion,
moreThreadsSuggestionPathname,
communityAddress,
isInAllView,
isInSubscriptionsView,
isInModView,
location.search,
effectiveInfiniteScroll,
],
);
const catalogFooter = useMemo(
() => (
<>
<CatalogFooter communityAddresses={communityAddresses} hasMore={hasMore} combinedFeedLength={cappedFeed.length} showLoadingEllipsis={effectiveInfiniteScroll} />
<CatalogFooter
communityAddresses={communityAddresses}
hasMore={hasMore}
combinedFeedLength={cappedFeed.length}
currentTimeFilterName={timeFilterName}
moreThreadsSuggestion={moreThreadsSuggestion}
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
moreThreadsSuggestionSearch={location.search}
showLoadingEllipsis={effectiveInfiniteScroll}
/>
<PageFooterDesktop
firstRow={
<CatalogFooterFirstRow
@@ -421,7 +583,20 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
</PageFooterMobile>
</>
),
[communityAddresses, hasMore, cappedFeed.length, communityAddress, isInAllView, isInSubscriptionsView, isInModView, effectiveInfiniteScroll],
[
communityAddresses,
hasMore,
cappedFeed.length,
timeFilterName,
moreThreadsSuggestion,
moreThreadsSuggestionPathname,
communityAddress,
isInAllView,
isInSubscriptionsView,
isInModView,
location.search,
effectiveInfiniteScroll,
],
);
const isFeedLoaded = feed.length > 0 || state === 'failed';
@@ -529,7 +704,7 @@ 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}-${sortType}-catalog`;
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}` : `${location.pathname}${location.search}-${sortType}-catalog`;
const navigationType = useNavigationType();
const hasBeenVisibleRef = useRef(false);