mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(multiboards): restore stable time-filter suggestions
This commit is contained in:
@@ -111,6 +111,7 @@ function makeNamedComponent(name: string) {
|
||||
|
||||
vi.mock('../components/board-buttons', () => ({
|
||||
DesktopBoardButtons: makeNamedComponent('desktop-board-buttons'),
|
||||
MobileAllFeedFilter: makeNamedComponent('mobile-all-feed-filter'),
|
||||
MobileBoardButtons: makeNamedComponent('mobile-board-buttons'),
|
||||
}));
|
||||
|
||||
|
||||
+1
-1
@@ -298,7 +298,7 @@ const App = () => {
|
||||
}
|
||||
/>
|
||||
<Route element={<BoardLayout />}>
|
||||
{/* Canonical multiboard routes (no time filter) */}
|
||||
{/* Canonical multiboard routes (time filter lives in ?t=) */}
|
||||
<Route path='/all' element={boardFeedElement} />
|
||||
<Route path='/all/settings' element={boardFeedElement} />
|
||||
<Route path='/all/catalog' element={catalogFeedElement} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DesktopBoardButtons, MobileBoardButtons } from '../board-buttons';
|
||||
import useThreadLiveUpdatesStore from '../../../stores/use-thread-live-updates-store';
|
||||
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>;
|
||||
@@ -184,7 +185,12 @@ const renderWithRoute = async (element: React.ReactElement, initialEntry: string
|
||||
createElement(
|
||||
Routes,
|
||||
{},
|
||||
createElement(Route, { path: '/all', element }),
|
||||
createElement(Route, { path: '/all/catalog', element }),
|
||||
createElement(Route, { path: '/subs', element }),
|
||||
createElement(Route, { path: '/subs/catalog', element }),
|
||||
createElement(Route, { path: '/mod', element }),
|
||||
createElement(Route, { path: '/mod/catalog', element }),
|
||||
createElement(Route, { path: '/mod/queue', element }),
|
||||
createElement(Route, { path: '/:boardIdentifier/catalog', element }),
|
||||
createElement(Route, { path: '/:boardIdentifier/archive', element }),
|
||||
@@ -240,6 +246,8 @@ describe('BoardButtons', () => {
|
||||
testState.subscribed = false;
|
||||
testState.viewMode = 'compact';
|
||||
useThreadLiveUpdatesStore.getState().resetState();
|
||||
clearStableLastVisitTimeFilterName();
|
||||
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now()));
|
||||
Object.defineProperty(globalThis, 'alert', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
@@ -264,6 +272,8 @@ describe('BoardButtons', () => {
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
clearStableLastVisitTimeFilterName();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('renders desktop board actions for browsing boards, then searches OPs and triggers refresh, vote, subscribe, and archive flows', async () => {
|
||||
@@ -300,7 +310,7 @@ describe('BoardButtons', () => {
|
||||
it('renders desktop catalog controls and wires sort, style, filter, and refresh updates', async () => {
|
||||
testState.filteredCount = 4;
|
||||
|
||||
await renderWithRoute(createElement(DesktopBoardButtons), '/all/catalog');
|
||||
await renderWithRoute(createElement(DesktopBoardButtons), '/all/catalog?t=24h');
|
||||
|
||||
expect(container.textContent).toContain('filtered_threads');
|
||||
expect(container.textContent).toContain('4');
|
||||
@@ -309,21 +319,55 @@ describe('BoardButtons', () => {
|
||||
expect(container.querySelector('[data-testid="catalog-search"]')?.textContent).toBe('catalog-search');
|
||||
|
||||
const selects = Array.from(container.querySelectorAll<HTMLSelectElement>('select'));
|
||||
expect(selects).toHaveLength(4);
|
||||
expect(selects).toHaveLength(5);
|
||||
|
||||
await changeSelect(selects[0]!, 'replyCount');
|
||||
await changeSelect(selects[1]!, 'Large');
|
||||
await changeSelect(selects[2]!, 'On');
|
||||
await changeSelect(selects[3]!, 'nsfw');
|
||||
await changeSelect(selects[4]!, '1w');
|
||||
await clickButton('refresh');
|
||||
|
||||
expect(testState.setSortTypeMock).toHaveBeenCalledWith('replyCount');
|
||||
expect(testState.setImageSizeMock).toHaveBeenCalledWith('Large');
|
||||
expect(testState.setShowOPCommentMock).toHaveBeenCalledWith(true);
|
||||
expect(testState.setFilterMock).toHaveBeenCalledWith('nsfw');
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith({ pathname: '/all/catalog', search: '?t=1w' });
|
||||
expect(testState.resetMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('preserves the current multiboard time filter when searching OPs', async () => {
|
||||
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now() - 3 * 24 * 60 * 60 * 1000));
|
||||
|
||||
await renderWithRoute(createElement(DesktopBoardButtons), '/all?t=last');
|
||||
|
||||
const searchInput = container.querySelector<HTMLInputElement>('input[type="text"]');
|
||||
expect(searchInput).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
if (searchInput) {
|
||||
searchInput.value = 'cats';
|
||||
searchInput.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'Enter' }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith({ pathname: '/all/catalog', search: '?t=last&q=cats' });
|
||||
});
|
||||
|
||||
it('lets multiboard views switch back to the last-visit filter alias', async () => {
|
||||
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now() - (2 * 24 * 60 * 60 + 1) * 1000));
|
||||
|
||||
await renderWithRoute(createElement(DesktopBoardButtons), '/all/catalog?t=1w');
|
||||
|
||||
const timeFilterSelect = Array.from(container.querySelectorAll<HTMLSelectElement>('select')).at(-1);
|
||||
expect(timeFilterSelect).toBeTruthy();
|
||||
expect(Array.from(timeFilterSelect?.options || []).some((option) => option.value === 'last')).toBe(true);
|
||||
|
||||
await changeSelect(timeFilterSelect!, 'last');
|
||||
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith({ pathname: '/all/catalog', search: '?t=last' });
|
||||
});
|
||||
|
||||
it('renders thread actions and post stats, then requests refreshes, toggles auto updates, and scrolls to the bottom', async () => {
|
||||
testState.commentsByCid = {
|
||||
'comment-1': {
|
||||
|
||||
@@ -17,11 +17,13 @@ import useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store'
|
||||
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
||||
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import useTimeFilter from '../../hooks/use-time-filter';
|
||||
import CatalogFilters from '../catalog-filters';
|
||||
import CatalogSearch from '../catalog-search';
|
||||
import Tooltip from '../tooltip';
|
||||
import { ModQueueButton } from '../../views/mod-queue/mod-queue';
|
||||
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
||||
import { getSearchWithTimeFilter, getTimeFilterOptionLabel } from '../../lib/utils/time-filter-utils';
|
||||
import styles from './board-buttons.module.css';
|
||||
import capitalize from 'lodash/capitalize';
|
||||
|
||||
@@ -35,14 +37,39 @@ interface BoardButtonsProps {
|
||||
isTopbar?: boolean;
|
||||
}
|
||||
|
||||
const getMultiboardPath = ({
|
||||
isInAllView,
|
||||
isInCatalogView,
|
||||
isInSubscriptionsView,
|
||||
isInModView,
|
||||
}: Pick<BoardButtonsProps, 'isInAllView' | 'isInCatalogView' | 'isInSubscriptionsView' | 'isInModView'>) => {
|
||||
if (isInAllView) {
|
||||
return isInCatalogView ? '/all/catalog' : '/all';
|
||||
}
|
||||
if (isInSubscriptionsView) {
|
||||
return isInCatalogView ? '/subs/catalog' : '/subs';
|
||||
}
|
||||
if (isInModView) {
|
||||
return isInCatalogView ? '/mod/catalog' : '/mod';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const CatalogButton = ({ address, isInAllView, isInSubscriptionsView, isInModView }: BoardButtonsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const directories = useDirectories();
|
||||
const { timeFilterValue } = useTimeFilter();
|
||||
|
||||
const createCatalogLink = () => {
|
||||
if (isInAllView) return `/all/catalog`;
|
||||
if (isInSubscriptionsView) return `/subs/catalog`;
|
||||
if (isInModView) return `/mod/catalog`;
|
||||
const multiboardPath = getMultiboardPath({ isInAllView, isInCatalogView: true, isInSubscriptionsView, isInModView });
|
||||
if (multiboardPath) {
|
||||
return {
|
||||
pathname: multiboardPath,
|
||||
search: getSearchWithTimeFilter(location.search, timeFilterValue),
|
||||
};
|
||||
}
|
||||
let boardPath = '';
|
||||
if (address) {
|
||||
boardPath = getBoardPath(address, directories);
|
||||
@@ -94,12 +121,12 @@ const SubscribeButton = ({ address }: BoardButtonsProps) => {
|
||||
|
||||
export const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isInModView, isInModQueueView }: BoardButtonsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
const directories = useDirectories();
|
||||
const { timeFilterValue } = useTimeFilter();
|
||||
|
||||
const createReturnLink = () => {
|
||||
if (isInAllView) return `/all`;
|
||||
if (isInSubscriptionsView) return `/subs`;
|
||||
if (isInModQueueView) {
|
||||
// If in mod queue view, return to /mod or /:boardIdentifier
|
||||
if (params?.boardIdentifier) {
|
||||
@@ -107,7 +134,13 @@ export const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isIn
|
||||
}
|
||||
return `/mod`;
|
||||
}
|
||||
if (isInModView) return `/mod`;
|
||||
const multiboardPath = getMultiboardPath({ isInAllView, isInCatalogView: false, isInSubscriptionsView, isInModView });
|
||||
if (multiboardPath) {
|
||||
return {
|
||||
pathname: multiboardPath,
|
||||
search: getSearchWithTimeFilter(location.search, timeFilterValue, { removeKeys: ['q'] }),
|
||||
};
|
||||
}
|
||||
let boardPath = '';
|
||||
if (address) {
|
||||
boardPath = getBoardPath(address, directories);
|
||||
@@ -351,6 +384,42 @@ const ShowOPCommentOption = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const TimeFilter = ({ isInAllView, isInCatalogView, isInSubscriptionsView, isInModView, isTopbar = false }: BoardButtonsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { lastVisitTimeFilterName, timeFilterValue, timeFilterValues } = useTimeFilter();
|
||||
const multiboardPath = getMultiboardPath({ isInAllView, isInCatalogView, isInSubscriptionsView, isInModView });
|
||||
|
||||
if (!multiboardPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const changeTimeFilter = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
navigate({
|
||||
pathname: multiboardPath,
|
||||
search: getSearchWithTimeFilter(location.search, event.target.value),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isTopbar && (
|
||||
<>
|
||||
<span>{t('filter')}</span>:
|
||||
</>
|
||||
)}
|
||||
<select onChange={changeTimeFilter} className={[styles.feedName, styles.menuItem, 'capitalize'].join(' ')} value={timeFilterValue}>
|
||||
{timeFilterValues.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{getTimeFilterOptionLabel(value, lastVisitTimeFilterName)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AllFeedFilter = () => {
|
||||
const { t } = useTranslation();
|
||||
const { filter, setFilter } = useAllFeedFilterStore();
|
||||
@@ -395,6 +464,7 @@ export const MobileBoardButtons = () => {
|
||||
const { filteredCount, searchText } = useCatalogFiltersStore();
|
||||
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
|
||||
const isMultiboard = isInAllView || isInSubscriptionsView || isInModView;
|
||||
const showTimeFilter = isMultiboard;
|
||||
const effectiveInfiniteScroll = isMultiboard || enableInfiniteScroll;
|
||||
const showBottomButton = !effectiveInfiniteScroll;
|
||||
|
||||
@@ -447,24 +517,24 @@ export const MobileBoardButtons = () => {
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{isInAllView && (
|
||||
{(isInAllView || showTimeFilter || isInCatalogView) && (
|
||||
<>
|
||||
<hr />
|
||||
<div className={styles.options}>
|
||||
<AllFeedFilter />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isInCatalogView && (
|
||||
<>
|
||||
<hr />
|
||||
<div className={styles.options}>
|
||||
<div>
|
||||
<SortOptions /> <ImageSizeOptions />
|
||||
</div>
|
||||
<div className={styles.mobileCatalogOptionsPadding}>
|
||||
<ShowOPCommentOption /> <CatalogFilters /> <CatalogSearch />
|
||||
</div>
|
||||
{(isInAllView || showTimeFilter) && (
|
||||
<div>
|
||||
{isInAllView && <AllFeedFilter />}{' '}
|
||||
{showTimeFilter && (
|
||||
<TimeFilter isInAllView={isInAllView} isInCatalogView={isInCatalogView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isInCatalogView && (
|
||||
<div className={styles.mobileCatalogOptionsPadding}>
|
||||
<SortOptions /> <ImageSizeOptions />
|
||||
<ShowOPCommentOption /> <CatalogFilters /> <CatalogSearch />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -479,6 +549,16 @@ export const MobileBoardButtons = () => {
|
||||
{!(isInAllView || isInSubscriptionsView || isInModView) && <SubscribeButton address={communityAddress} />}
|
||||
{!(isInAllView || isInSubscriptionsView) && <ModQueueButton boardIdentifier={boardIdentifier} isMobile={true} />}
|
||||
</div>
|
||||
{showTimeFilter && (
|
||||
<>
|
||||
<hr />
|
||||
<div className={styles.options}>
|
||||
<div>
|
||||
<TimeFilter isInAllView={isInAllView} isInCatalogView={isInCatalogView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -550,6 +630,7 @@ export const DesktopBoardButtons = () => {
|
||||
const { filteredCount, searchText } = useCatalogFiltersStore();
|
||||
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
|
||||
const isMultiboard = isInAllView || isInSubscriptionsView || isInModView;
|
||||
const showTimeFilter = isMultiboard;
|
||||
const effectiveInfiniteScroll = isMultiboard || enableInfiniteScroll;
|
||||
const showBottomButton = (isInCatalogView || isInPostView || isInPendingPostPage) && !effectiveInfiniteScroll;
|
||||
|
||||
@@ -653,6 +734,9 @@ export const DesktopBoardButtons = () => {
|
||||
</>
|
||||
)}
|
||||
{isInAllView && <AllFeedFilter />}
|
||||
{showTimeFilter && (
|
||||
<TimeFilter isInAllView={isInAllView} isInCatalogView={isInCatalogView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
|
||||
)}
|
||||
{showVoteButton && (
|
||||
<>
|
||||
[<VoteButton />]
|
||||
@@ -693,19 +777,19 @@ const SearchOPsBar = () => {
|
||||
if (event.key === 'Enter') {
|
||||
const searchQuery = (event.target as HTMLInputElement).value.trim();
|
||||
if (searchQuery) {
|
||||
let catalogUrl = '';
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set('q', searchQuery);
|
||||
const search = `?${params.toString()}`;
|
||||
|
||||
if (isInAllView) {
|
||||
catalogUrl = `/all/catalog?q=${encodeURIComponent(searchQuery)}`;
|
||||
navigate({ pathname: '/all/catalog', search });
|
||||
} else if (isInSubscriptionsView) {
|
||||
catalogUrl = `/subs/catalog?q=${encodeURIComponent(searchQuery)}`;
|
||||
navigate({ pathname: '/subs/catalog', search });
|
||||
} else if (isInModView) {
|
||||
catalogUrl = `/mod/catalog?q=${encodeURIComponent(searchQuery)}`;
|
||||
navigate({ pathname: '/mod/catalog', search });
|
||||
} else {
|
||||
catalogUrl = `/${boardPath}/catalog?q=${encodeURIComponent(searchQuery)}`;
|
||||
navigate(`/${boardPath}/catalog?q=${encodeURIComponent(searchQuery)}`);
|
||||
}
|
||||
|
||||
navigate(catalogUrl);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import useFeedCacheStore, { CachedFeed } from '../../stores/use-feed-cache-store';
|
||||
import { getFeedCacheKey, getFeedType, isFeedRoute } from '../../lib/utils/route-utils';
|
||||
import { TIME_FILTER_QUERY_PARAM } from '../../lib/utils/time-filter-utils';
|
||||
import Board from '../../views/board';
|
||||
import Catalog from '../../views/catalog';
|
||||
import styles from './feed-cache-container.module.css';
|
||||
@@ -9,20 +10,23 @@ import styles from './feed-cache-container.module.css';
|
||||
interface FeedContextFromKey {
|
||||
viewType: 'all' | 'subs' | 'mod' | 'board';
|
||||
boardIdentifier?: string;
|
||||
timeFilterName?: string;
|
||||
}
|
||||
|
||||
const parseFeedKey = (key: string): FeedContextFromKey => {
|
||||
const segments = key.split('/').filter(Boolean);
|
||||
const [pathname, search = ''] = key.split('?');
|
||||
const segments = pathname.split('/').filter(Boolean);
|
||||
const timeFilterName = new URLSearchParams(search).get(TIME_FILTER_QUERY_PARAM) || undefined;
|
||||
|
||||
const filteredSegments = segments.filter((s) => s !== 'catalog');
|
||||
if (filteredSegments[0] === 'all') {
|
||||
return { viewType: 'all' };
|
||||
return { viewType: 'all', timeFilterName };
|
||||
}
|
||||
if (filteredSegments[0] === 'subs') {
|
||||
return { viewType: 'subs' };
|
||||
return { viewType: 'subs', timeFilterName };
|
||||
}
|
||||
if (filteredSegments[0] === 'mod') {
|
||||
return { viewType: 'mod' };
|
||||
return { viewType: 'mod', timeFilterName };
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -42,9 +46,21 @@ const CachedFeedWrapper = ({ feed, isVisible }: CachedFeedWrapperProps) => {
|
||||
return (
|
||||
<div className={isVisible ? styles.visible : styles.hidden}>
|
||||
{feed.type === 'catalog' ? (
|
||||
<Catalog feedCacheKey={feed.key} viewType={context.viewType} boardIdentifier={context.boardIdentifier} isVisible={isVisible} />
|
||||
<Catalog
|
||||
feedCacheKey={feed.key}
|
||||
viewType={context.viewType}
|
||||
boardIdentifier={context.boardIdentifier}
|
||||
timeFilterNameFromCache={context.timeFilterName}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
) : (
|
||||
<Board feedCacheKey={feed.key} viewType={context.viewType} boardIdentifier={context.boardIdentifier} isVisible={isVisible} />
|
||||
<Board
|
||||
feedCacheKey={feed.key}
|
||||
viewType={context.viewType}
|
||||
boardIdentifier={context.boardIdentifier}
|
||||
timeFilterNameFromCache={context.timeFilterName}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -54,7 +70,7 @@ const FeedCacheContainer = () => {
|
||||
const location = useLocation();
|
||||
const { cachedFeeds, accessFeed } = useFeedCacheStore();
|
||||
|
||||
const currentFeedKey = getFeedCacheKey(location.pathname);
|
||||
const currentFeedKey = getFeedCacheKey(location.pathname, location.search);
|
||||
const isOnFeedRoute = isFeedRoute(location.pathname);
|
||||
const feedType = getFeedType(location.pathname);
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface UseSuggestionFeedLoaderOptions {
|
||||
currentFeedLength: number;
|
||||
feedLength: number;
|
||||
hasMore: boolean;
|
||||
loadMore: () => Promise<void>;
|
||||
requestKey: string;
|
||||
shouldLoad: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_REQUEST_KEY = '';
|
||||
|
||||
export const useSuggestionFeedLoader = ({ currentFeedLength, feedLength, hasMore, loadMore, requestKey, shouldLoad }: UseSuggestionFeedLoaderOptions) => {
|
||||
const lastRequestKeyRef = useRef(EMPTY_REQUEST_KEY);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldLoad || !requestKey || !hasMore || feedLength > currentFeedLength) {
|
||||
lastRequestKeyRef.current = EMPTY_REQUEST_KEY;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextRequestKey = `${requestKey}:${currentFeedLength}:${feedLength}`;
|
||||
if (lastRequestKeyRef.current === nextRequestKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastRequestKeyRef.current = nextRequestKey;
|
||||
void loadMore();
|
||||
}, [currentFeedLength, feedLength, hasMore, loadMore, requestKey, shouldLoad]);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
getEffectiveTimeFilterName,
|
||||
getEffectiveTimeFilterSeconds,
|
||||
getStableLastVisitTimeFilterName,
|
||||
getSelectedTimeFilterValue,
|
||||
getTimeFilterOptionValues,
|
||||
touchLastVisitTimestamp,
|
||||
} from '../lib/utils/time-filter-utils';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
_5chanLastVisitTimestampIntervalId?: number;
|
||||
_5chanLastVisitTimestampSubscribers?: number;
|
||||
}
|
||||
}
|
||||
|
||||
const ensureLastVisitTimestampTracking = (): (() => void) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
touchLastVisitTimestamp();
|
||||
window._5chanLastVisitTimestampSubscribers = (window._5chanLastVisitTimestampSubscribers || 0) + 1;
|
||||
|
||||
if (!window._5chanLastVisitTimestampIntervalId) {
|
||||
window._5chanLastVisitTimestampIntervalId = window.setInterval(() => {
|
||||
touchLastVisitTimestamp();
|
||||
}, 60 * 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window._5chanLastVisitTimestampSubscribers = Math.max((window._5chanLastVisitTimestampSubscribers || 1) - 1, 0);
|
||||
if (window._5chanLastVisitTimestampSubscribers === 0 && window._5chanLastVisitTimestampIntervalId) {
|
||||
window.clearInterval(window._5chanLastVisitTimestampIntervalId);
|
||||
delete window._5chanLastVisitTimestampIntervalId;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const useTimeFilter = (timeFilterNameOverride?: string) => {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => ensureLastVisitTimestampTracking(), []);
|
||||
|
||||
const lastVisitTimeFilterName = getStableLastVisitTimeFilterName();
|
||||
const timeFilterValue = timeFilterNameOverride || getSelectedTimeFilterValue(location.search);
|
||||
const timeFilterName = timeFilterNameOverride || getEffectiveTimeFilterName(location.search, lastVisitTimeFilterName);
|
||||
const timeFilterSeconds = getEffectiveTimeFilterSeconds('', timeFilterName);
|
||||
const timeFilterValues = getTimeFilterOptionValues(lastVisitTimeFilterName);
|
||||
|
||||
return { timeFilterSeconds, timeFilterName, timeFilterValue, timeFilterValues, lastVisitTimeFilterName };
|
||||
};
|
||||
|
||||
export default useTimeFilter;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
areSameBoardAddress,
|
||||
extractDirectoryFromTitle,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
normalizeMultiboardFeedPath,
|
||||
stripPageFromFeedPath,
|
||||
} from '../route-utils';
|
||||
import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY, touchLastVisitTimestamp } from '../time-filter-utils';
|
||||
|
||||
const communities = [
|
||||
{ address: 'business.eth', title: '/biz/ - Business & Finance' },
|
||||
@@ -32,6 +33,11 @@ const communities = [
|
||||
{ address: 'random.eth', directoryCode: 'b', title: 'Random' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
clearStableLastVisitTimeFilterName();
|
||||
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now()));
|
||||
});
|
||||
|
||||
describe('directory mapping helpers', () => {
|
||||
it('extracts short codes from titled directories', () => {
|
||||
expect(extractDirectoryFromTitle('/biz/ - Business & Finance')).toBe('biz');
|
||||
@@ -189,9 +195,23 @@ describe('feed cache helpers', () => {
|
||||
expect(getFeedCacheKey('/biz/3/settings')).toBe('/biz');
|
||||
expect(getFeedCacheKey('/biz/catalog/4')).toBe('/biz/catalog');
|
||||
expect(getFeedCacheKey('/biz/thread/abc')).toBe('/biz');
|
||||
expect(getFeedCacheKey('/all')).toBe('/all?t=24h');
|
||||
expect(getFeedCacheKey('/all/catalog', '?t=last')).toBe('/all/catalog?t=24h');
|
||||
expect(getFeedCacheKey('/all/catalog', '?t=1w&q=cats')).toBe('/all/catalog?t=1w');
|
||||
expect(getFeedCacheKey('/biz/archive')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the last-visit alias stable after the current visit starts updating storage', () => {
|
||||
const justOverTwoDaysAgo = Date.now() - (2 * 24 * 60 * 60 * 1000 + 1000);
|
||||
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(justOverTwoDaysAgo));
|
||||
|
||||
expect(getFeedCacheKey('/all/catalog', '?t=last')).toBe('/all/catalog?t=3d');
|
||||
|
||||
touchLastVisitTimestamp();
|
||||
|
||||
expect(getFeedCacheKey('/all/catalog', '?t=last')).toBe('/all/catalog?t=3d');
|
||||
});
|
||||
|
||||
it('returns null cache keys for non-feed routes', () => {
|
||||
expect(getFeedCacheKey('/pending/3')).toBeNull();
|
||||
expect(getFeedCacheKey('/biz/mod/queue')).toBeNull();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DirectoryCommunity, findDirectoryByAddress, normalizeBoardAddress } from '../../hooks/use-directories';
|
||||
import { getEffectiveTimeFilterName, getSearchWithTimeFilter } from './time-filter-utils';
|
||||
|
||||
/**
|
||||
* Extract directory short code from title (e.g., "/biz/ - Business & Finance" -> "biz")
|
||||
@@ -256,7 +257,7 @@ export const getPageFromFeedPath = (pathname: string): number => {
|
||||
return 1;
|
||||
};
|
||||
|
||||
export const getFeedCacheKey = (pathname: string): string | null => {
|
||||
export const getFeedCacheKey = (pathname: string, search = ''): string | null => {
|
||||
let normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
normalizedPath = normalizedPath.replace(/\/settings$/, '');
|
||||
|
||||
@@ -274,7 +275,12 @@ export const getFeedCacheKey = (pathname: string): string | null => {
|
||||
}
|
||||
|
||||
if (isFeedRoute(pathname)) {
|
||||
return stripPageFromFeedPath(normalizedPath);
|
||||
const strippedFeedPath = stripPageFromFeedPath(normalizedPath);
|
||||
if (isMultiboardFeedPath(strippedFeedPath)) {
|
||||
const timeFilterName = getEffectiveTimeFilterName(search);
|
||||
return `${strippedFeedPath}${getSearchWithTimeFilter('', timeFilterName)}`;
|
||||
}
|
||||
return strippedFeedPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
const DAY_IN_SECONDS = 24 * 60 * 60;
|
||||
const LAST_VISIT_STORAGE_KEY = '5chanLastVisitTimestamp';
|
||||
const LAST_VISIT_TIME_FILTER_VALUE = 'last';
|
||||
const TIME_FILTER_QUERY_PARAM = 't';
|
||||
const FALLBACK_TIME_FILTER_NAME = '24h';
|
||||
const WEEK_IN_SECONDS = 7 * DAY_IN_SECONDS;
|
||||
const MONTH_IN_SECONDS = 30 * DAY_IN_SECONDS;
|
||||
const YEAR_IN_SECONDS = 365 * DAY_IN_SECONDS;
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
_5chanLastVisitTimeFilterSnapshot?: string;
|
||||
}
|
||||
}
|
||||
|
||||
const presetTimeFilterSeconds: Record<string, number | undefined> = {
|
||||
'1h': 60 * 60,
|
||||
'12h': 12 * 60 * 60,
|
||||
'24h': DAY_IN_SECONDS,
|
||||
'48h': 2 * DAY_IN_SECONDS,
|
||||
'1w': WEEK_IN_SECONDS,
|
||||
'1m': MONTH_IN_SECONDS,
|
||||
'1y': YEAR_IN_SECONDS,
|
||||
all: undefined,
|
||||
};
|
||||
|
||||
const timeFilterPresetNames = ['1h', '12h', '24h', '48h', '1w', '1m', '1y', 'all'] as const;
|
||||
export { LAST_VISIT_STORAGE_KEY, TIME_FILTER_QUERY_PARAM };
|
||||
|
||||
const convertDynamicTimeFilterNameToSeconds = (timeFilterName: string): number | undefined => {
|
||||
const match = timeFilterName.match(/^(\d+)([hdwmy])$/);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [, rawValue, unit] = match;
|
||||
const value = Number.parseInt(rawValue, 10);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (unit) {
|
||||
case 'h':
|
||||
return value * 60 * 60;
|
||||
case 'd':
|
||||
return value * DAY_IN_SECONDS;
|
||||
case 'w':
|
||||
return value * 7 * DAY_IN_SECONDS;
|
||||
case 'm':
|
||||
return value * MONTH_IN_SECONDS;
|
||||
case 'y':
|
||||
return value * YEAR_IN_SECONDS;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getTimeFilterSeconds = (timeFilterName: string | null | undefined): number | undefined => {
|
||||
if (!timeFilterName || timeFilterName === 'all') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (timeFilterName in presetTimeFilterSeconds) {
|
||||
return presetTimeFilterSeconds[timeFilterName as keyof typeof presetTimeFilterSeconds];
|
||||
}
|
||||
|
||||
return convertDynamicTimeFilterNameToSeconds(timeFilterName);
|
||||
};
|
||||
|
||||
const isValidTimeFilterName = (timeFilterName: string | null | undefined): timeFilterName is string =>
|
||||
timeFilterName === 'all' || typeof getTimeFilterSeconds(timeFilterName) === 'number';
|
||||
|
||||
const readLastVisitTimestamp = (): number | null => {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storedValue = window.localStorage.getItem(LAST_VISIT_STORAGE_KEY);
|
||||
if (!storedValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsedValue = Number.parseInt(storedValue, 10);
|
||||
return Number.isFinite(parsedValue) ? parsedValue : null;
|
||||
};
|
||||
|
||||
export const touchLastVisitTimestamp = (timestamp = Date.now()) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(timestamp));
|
||||
};
|
||||
|
||||
const getLastVisitTimeFilterName = (now = Date.now(), lastVisitTimestamp = readLastVisitTimestamp()): string => {
|
||||
const secondsSinceLastVisit = lastVisitTimestamp ? (now - lastVisitTimestamp) / 1000 : Number.POSITIVE_INFINITY;
|
||||
|
||||
if (secondsSinceLastVisit > 30 * DAY_IN_SECONDS) {
|
||||
return '1m';
|
||||
}
|
||||
if (secondsSinceLastVisit > 7 * DAY_IN_SECONDS) {
|
||||
return `${Math.ceil(secondsSinceLastVisit / (7 * DAY_IN_SECONDS))}w`;
|
||||
}
|
||||
if (secondsSinceLastVisit > DAY_IN_SECONDS) {
|
||||
return `${Math.ceil(secondsSinceLastVisit / DAY_IN_SECONDS)}d`;
|
||||
}
|
||||
|
||||
return FALLBACK_TIME_FILTER_NAME;
|
||||
};
|
||||
|
||||
export const getStableLastVisitTimeFilterName = (): string => {
|
||||
if (typeof window === 'undefined') {
|
||||
return getLastVisitTimeFilterName();
|
||||
}
|
||||
|
||||
if (!window._5chanLastVisitTimeFilterSnapshot) {
|
||||
window._5chanLastVisitTimeFilterSnapshot = getLastVisitTimeFilterName();
|
||||
}
|
||||
|
||||
return window._5chanLastVisitTimeFilterSnapshot;
|
||||
};
|
||||
|
||||
export const clearStableLastVisitTimeFilterName = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
delete window._5chanLastVisitTimeFilterSnapshot;
|
||||
};
|
||||
|
||||
const getExplicitTimeFilterValueFromSearch = (search: string): string | undefined => {
|
||||
const timeFilterValue = new URLSearchParams(search).get(TIME_FILTER_QUERY_PARAM);
|
||||
if (timeFilterValue === LAST_VISIT_TIME_FILTER_VALUE) {
|
||||
return timeFilterValue;
|
||||
}
|
||||
|
||||
return isValidTimeFilterName(timeFilterValue) ? timeFilterValue : undefined;
|
||||
};
|
||||
|
||||
export const getEffectiveTimeFilterName = (search: string, fallbackTimeFilterName = getStableLastVisitTimeFilterName()): string => {
|
||||
const explicitTimeFilterValue = getExplicitTimeFilterValueFromSearch(search);
|
||||
if (explicitTimeFilterValue === LAST_VISIT_TIME_FILTER_VALUE) {
|
||||
return fallbackTimeFilterName;
|
||||
}
|
||||
|
||||
return explicitTimeFilterValue ?? fallbackTimeFilterName;
|
||||
};
|
||||
|
||||
export const getSelectedTimeFilterValue = (search: string): string => getExplicitTimeFilterValueFromSearch(search) ?? LAST_VISIT_TIME_FILTER_VALUE;
|
||||
|
||||
export const getTimeFilterOptionValues = (lastVisitTimeFilterName: string): string[] => {
|
||||
const optionValues = [LAST_VISIT_TIME_FILTER_VALUE, ...timeFilterPresetNames];
|
||||
const seenLabels = new Set<string>();
|
||||
|
||||
return optionValues.filter((value) => {
|
||||
const label = value === LAST_VISIT_TIME_FILTER_VALUE ? lastVisitTimeFilterName : value;
|
||||
if (seenLabels.has(label)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seenLabels.add(label);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
export const getTimeFilterOptionLabel = (value: string, lastVisitTimeFilterName: string): string =>
|
||||
value === LAST_VISIT_TIME_FILTER_VALUE ? lastVisitTimeFilterName : value;
|
||||
|
||||
export const getEffectiveTimeFilterSeconds = (search: string, fallbackTimeFilterName = getStableLastVisitTimeFilterName()): number | undefined => {
|
||||
const effectiveTimeFilterName = getEffectiveTimeFilterName(search, fallbackTimeFilterName);
|
||||
const effectiveTimeFilterSeconds = getTimeFilterSeconds(effectiveTimeFilterName);
|
||||
|
||||
if (effectiveTimeFilterName === 'all') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return effectiveTimeFilterSeconds ?? presetTimeFilterSeconds[FALLBACK_TIME_FILTER_NAME];
|
||||
};
|
||||
|
||||
export const getSearchWithTimeFilter = (
|
||||
search: string,
|
||||
timeFilterName: string | undefined,
|
||||
options?: {
|
||||
removeKeys?: string[];
|
||||
},
|
||||
): string => {
|
||||
const params = new URLSearchParams(search);
|
||||
for (const key of options?.removeKeys || []) {
|
||||
params.delete(key);
|
||||
}
|
||||
|
||||
if (timeFilterName) {
|
||||
params.set(TIME_FILTER_QUERY_PARAM, timeFilterName);
|
||||
} else {
|
||||
params.delete(TIME_FILTER_QUERY_PARAM);
|
||||
}
|
||||
|
||||
const nextSearch = params.toString();
|
||||
return nextSearch ? `?${nextSearch}` : '';
|
||||
};
|
||||
|
||||
export type TimeFilterSuggestion =
|
||||
| {
|
||||
i18nKey: 'more_threads_last_week';
|
||||
timeFilterName: '1w';
|
||||
}
|
||||
| {
|
||||
i18nKey: 'more_threads_last_month';
|
||||
timeFilterName: '1m';
|
||||
}
|
||||
| {
|
||||
i18nKey: 'more_threads_last_year';
|
||||
timeFilterName: '1y';
|
||||
};
|
||||
|
||||
export const getTimeFilterSuggestion = (
|
||||
currentFeedLength: number,
|
||||
weeklyFeedLength: number,
|
||||
monthlyFeedLength: number,
|
||||
yearlyFeedLength: number,
|
||||
currentTimeFilterSeconds?: number,
|
||||
): TimeFilterSuggestion | null => {
|
||||
if (typeof currentTimeFilterSeconds !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentTimeFilterSeconds < WEEK_IN_SECONDS && weeklyFeedLength > currentFeedLength) {
|
||||
return {
|
||||
i18nKey: 'more_threads_last_week',
|
||||
timeFilterName: '1w',
|
||||
};
|
||||
}
|
||||
|
||||
if (currentTimeFilterSeconds < MONTH_IN_SECONDS && monthlyFeedLength > currentFeedLength) {
|
||||
return {
|
||||
i18nKey: 'more_threads_last_month',
|
||||
timeFilterName: '1m',
|
||||
};
|
||||
}
|
||||
|
||||
if (currentTimeFilterSeconds < YEAR_IN_SECONDS && yearlyFeedLength > currentFeedLength) {
|
||||
return {
|
||||
i18nKey: 'more_threads_last_year',
|
||||
timeFilterName: '1y',
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user