feat(board): default pagination with optional infinite scroll and URL-based page routing

This commit is contained in:
plebeius
2026-02-20 20:16:11 +08:00
parent f660d1a814
commit 60d2f6804d
51 changed files with 775 additions and 82 deletions
+4
View File
@@ -196,16 +196,19 @@ const App = () => (
<Route path='/faq' element={<FAQ />} />
<Route path='/rules/:boardIdentifier?' element={<Rules />} />
<Route element={<BoardLayout />}>
<Route path='/all/:timeFilterName/:pageNumber' element={null} />
<Route path='/all/:timeFilterName?' element={null} />
<Route path='/all/:timeFilterName?/settings' element={null} />
<Route path='/all/catalog/:timeFilterName?' element={null} />
<Route path='/all/catalog/:timeFilterName?/settings' element={null} />
<Route path='/subs/:timeFilterName/:pageNumber' element={null} />
<Route path='/subs/:timeFilterName?' element={null} />
<Route path='/subs/:timeFilterName?/settings' element={null} />
<Route path='/subs/catalog/:timeFilterName?' element={null} />
<Route path='/subs/catalog/:timeFilterName?/settings' element={null} />
<Route path='/mod/:timeFilterName/:pageNumber' element={null} />
<Route path='/mod/:timeFilterName?' element={null} />
<Route path='/mod/:timeFilterName?/settings' element={null} />
<Route path='/mod/catalog/:timeFilterName?' element={null} />
@@ -214,6 +217,7 @@ const App = () => (
<Route path='/mod/modqueue' element={<ModQueueRoute />} />
<Route path='/mod/modqueue/settings' element={<ModQueueRoute />} />
<Route path='/:boardIdentifier/:pageNumber' element={null} />
<Route path='/:boardIdentifier' element={null} />
<Route path='/:boardIdentifier/settings' element={null} />
<Route path='/:boardIdentifier/catalog' element={null} />
@@ -0,0 +1,36 @@
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 0 16px;
flex-wrap: wrap;
}
.paginationButton {
min-width: 32px;
padding: 4px 8px;
font-size: 14px;
cursor: pointer;
text-decoration: var(--button-text-decoration);
color: var(--button-desktop-text-color);
background: transparent;
border: 1px solid var(--border-color, #ccc);
border-radius: 4px;
}
.paginationButton:hover:not(:disabled) {
color: var(--button-desktop-text-color-hover);
cursor: pointer;
}
.paginationButton:disabled,
.paginationButton.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pageButtonActive {
font-weight: bold;
color: var(--button-desktop-text-color-hover);
}
@@ -0,0 +1,61 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import styles from './board-pagination.module.css';
export interface BoardPaginationProps {
basePath: string;
currentPage: number;
totalPages: number;
}
const BoardPagination = ({ basePath, currentPage, totalPages }: BoardPaginationProps) => {
const { t } = useTranslation();
const pageHref = (page: number) => (page === 1 ? basePath : `${basePath}/${page}`);
const prevHref = currentPage > 1 ? pageHref(currentPage - 1) : undefined;
const nextHref = currentPage < totalPages ? pageHref(currentPage + 1) : undefined;
if (totalPages <= 1) {
return null;
}
const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1);
return (
<div className={styles.pagination}>
{prevHref ? (
<Link to={prevHref} className={styles.paginationButton} aria-label={t('prev')}>
{t('prev')}
</Link>
) : (
<span className={`${styles.paginationButton} ${styles.disabled}`} aria-disabled>
{t('prev')}
</span>
)}
{pageNumbers.map((page) => {
const href = pageHref(page);
const isCurrent = page === currentPage;
return isCurrent ? (
<span key={page} className={`${styles.paginationButton} ${styles.pageButtonActive}`} aria-label={`Page ${page} (current)`} aria-current='page'>
{page}
</span>
) : (
<Link key={page} to={href} className={styles.paginationButton} aria-label={`Go to page ${page}`}>
{page}
</Link>
);
})}
{nextHref ? (
<Link to={nextHref} className={styles.paginationButton} aria-label={t('next')}>
{t('next')}
</Link>
) : (
<span className={`${styles.paginationButton} ${styles.disabled}`} aria-disabled>
{t('next')}
</span>
)}
</div>
);
};
export default BoardPagination;
+2
View File
@@ -0,0 +1,2 @@
export { default } from './board-pagination';
export type { BoardPaginationProps } from './board-pagination';
@@ -0,0 +1,102 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import InterfaceSettings from '../interface-settings';
import useFeedViewSettingsStore from '../../../../stores/use-feed-view-settings-store';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { changeLanguage: vi.fn(), language: 'en' },
}),
}));
vi.mock('../../../../hooks/use-theme', () => ({
default: () => ['yotsuba', vi.fn()],
}));
vi.mock('../../../../stores/use-expanded-media-store', () => ({
default: () => ({ fitExpandedImagesToScreen: false, setFitExpandedImagesToScreen: vi.fn() }),
}));
vi.mock('../../../../stores/use-special-theme-store', () => ({
default: () => ({ isEnabled: false, setIsEnabled: vi.fn() }),
}));
vi.mock('../../../../lib/utils/time-utils', () => ({
isChristmas: () => false,
}));
vi.mock('../../version', () => ({
default: () => null,
}));
/** Minimal component that subscribes to feed view settings (like Board) to verify re-renders. */
const BoardModeIndicator = () => {
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
return <span data-testid='board-mode'>{enableInfiniteScroll ? 'infinite' : 'pagination'}</span>;
};
const STORAGE_KEY = 'feed-view-settings-store';
let root: Root;
let container: HTMLDivElement;
const render = (children: React.ReactNode) => {
act(() => {
root.render(createElement(MemoryRouter, {}, children));
});
};
describe('InterfaceSettings', () => {
let setItemSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
localStorage.removeItem(STORAGE_KEY);
useFeedViewSettingsStore.getState().setEnableInfiniteScroll(false);
setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
setItemSpy.mockRestore();
});
it('renders enable_infinite_scroll checkbox unchecked by default', () => {
render(createElement(InterfaceSettings));
const label = Array.from(container.querySelectorAll('label')).find((l) => l.textContent?.toLowerCase().includes('enable_infinite_scroll'));
expect(label).toBeTruthy();
const checkbox = label?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox).toBeTruthy();
expect(checkbox?.checked).toBe(false);
});
it('toggling checkbox updates persisted state and re-renders board mode', async () => {
render(createElement(React.Fragment, {}, createElement(InterfaceSettings), createElement(BoardModeIndicator)));
const label = Array.from(container.querySelectorAll('label')).find((l) => l.textContent?.toLowerCase().includes('enable_infinite_scroll'));
const checkbox = label?.querySelector<HTMLInputElement>('input[type="checkbox"]');
expect(checkbox).toBeTruthy();
expect(container.querySelector('[data-testid="board-mode"]')?.textContent).toBe('pagination');
await act(async () => {
checkbox?.click();
});
expect(useFeedViewSettingsStore.getState().enableInfiniteScroll).toBe(true);
expect(checkbox?.checked).toBe(true);
expect(container.querySelector('[data-testid="board-mode"]')?.textContent).toBe('infinite');
expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, expect.stringContaining('"enableInfiniteScroll":true'));
});
});
@@ -5,6 +5,7 @@ import packageJson from '../../../../package.json';
import styles from './interface-settings.module.css';
import capitalize from 'lodash/capitalize';
import useExpandedMediaStore from '../../../stores/use-expanded-media-store';
import useFeedViewSettingsStore from '../../../stores/use-feed-view-settings-store';
import useSpecialThemeStore from '../../../stores/use-special-theme-store';
import { isChristmas } from '../../../lib/utils/time-utils';
import Version from '../../version';
@@ -124,6 +125,7 @@ const InterfaceLanguage = () => {
const InterfaceSettings = () => {
const { t } = useTranslation();
const { fitExpandedImagesToScreen, setFitExpandedImagesToScreen } = useExpandedMediaStore();
const { enableInfiniteScroll, setEnableInfiniteScroll } = useFeedViewSettingsStore();
return (
<div className={styles.interfaceSettings}>
@@ -146,6 +148,12 @@ const InterfaceSettings = () => {
</label>
<div className={styles.settingTip}>{capitalize(t('fit_expanded_images_to_screen_tip'))}</div>
</div>
<div className={styles.setting}>
<label>
<input type='checkbox' checked={enableInfiniteScroll} onChange={(e) => setEnableInfiniteScroll(e.target.checked)} />
{capitalize(t('enable_infinite_scroll'))}
</label>
</div>
</div>
);
};
+40 -20
View File
@@ -16,7 +16,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false
"noSpoilerReplies": false,
"postsPerPage": 15
}
},
{
@@ -31,7 +32,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 20
}
},
{
@@ -46,7 +48,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 20
}
},
{
@@ -61,7 +64,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -76,7 +80,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -91,7 +96,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false
"noSpoilerReplies": false,
"postsPerPage": 20
}
},
{
@@ -106,7 +112,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -121,7 +128,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -136,7 +144,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -151,7 +160,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -166,7 +176,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -181,7 +192,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -196,7 +208,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -211,7 +224,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -226,7 +240,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false
"noSpoilerReplies": false,
"postsPerPage": 15
}
},
{
@@ -241,7 +256,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": false,
"noSpoilerReplies": false
"noSpoilerReplies": false,
"postsPerPage": 15
}
},
{
@@ -256,7 +272,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -271,7 +288,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -286,7 +304,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
},
{
@@ -301,7 +320,8 @@
"requirePostLinkIsMedia": true,
"bumpLimit": 300,
"noSpoilers": true,
"noSpoilerReplies": true
"noSpoilerReplies": true,
"postsPerPage": 15
}
}
]
+20
View File
@@ -0,0 +1,20 @@
import { useMemo } from 'react';
import type { DirectoryCommunity } from './use-directories';
import { computeGuiPostsPerPage, getBoardFeedPageSizeConstants, type CommunityWithPostsPerPage } from '../lib/utils/board-feed-pagination';
export type BoardFeedPageSize = ReturnType<typeof getBoardFeedPageSizeConstants>;
/**
* Compute board feed page-size values from directory community.
*
* - Single directory board: uses community.features.postsPerPage when valid numeric > 0
* - Missing metadata, non-directory board, /all, /subs, /mod: fallback 15
*
* Returns: guiPostsPerPage, maxGuiPages (10), paginationFeedPostsPerPage, infiniteFeedPostsPerPage
*/
export const useBoardFeedPageSize = (community?: DirectoryCommunity | null): BoardFeedPageSize => {
return useMemo(() => {
const guiPostsPerPage = computeGuiPostsPerPage(community as CommunityWithPostsPerPage | null | undefined);
return getBoardFeedPageSizeConstants(guiPostsPerPage);
}, [community]);
};
+1
View File
@@ -9,6 +9,7 @@ export interface DirectoriesMetadata {
}
export interface DirectoryFeatures {
postsPerPage?: number;
pseudonymityMode?: string;
nsfw?: boolean;
noSpoilers?: boolean;
+6
View File
@@ -1,5 +1,6 @@
import assert from 'assert';
import { useParams } from 'react-router-dom';
import { isBoardFeedPageNumber } from '../lib/utils/route-utils';
// the timestamp the last time the user visited
const lastVisitTimestamp = localStorage.getItem('5chanLastVisitTimestamp');
@@ -110,6 +111,11 @@ const useTimeFilter = () => {
const params = useParams();
let timeFilterName = params.timeFilterName;
// Ignore when param is a board feed page number (e.g. /all/3, /biz/2)
if (timeFilterName && isBoardFeedPageNumber(timeFilterName)) {
timeFilterName = undefined;
}
// the default time filter is the last visit time filter
if (!timeFilterName) {
timeFilterName = lastVisitTimeFilterName;
@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest';
import { computeGuiPostsPerPage, getBoardFeedPageSizeConstants, getPageSlice } from '../board-feed-pagination';
describe('computeGuiPostsPerPage', () => {
it('uses community.features.postsPerPage when valid numeric > 0', () => {
expect(computeGuiPostsPerPage({ features: { postsPerPage: 15 } })).toBe(15);
expect(computeGuiPostsPerPage({ features: { postsPerPage: 20 } })).toBe(20);
expect(computeGuiPostsPerPage({ features: { postsPerPage: 1 } })).toBe(1);
expect(computeGuiPostsPerPage({ features: { postsPerPage: 100 } })).toBe(100);
});
it('falls back to 15 when postsPerPage is missing, invalid, or non-positive', () => {
expect(computeGuiPostsPerPage(undefined)).toBe(15);
expect(computeGuiPostsPerPage(null)).toBe(15);
expect(computeGuiPostsPerPage({})).toBe(15);
expect(computeGuiPostsPerPage({ features: {} })).toBe(15);
expect(computeGuiPostsPerPage({ features: { postsPerPage: 0 } })).toBe(15);
expect(computeGuiPostsPerPage({ features: { postsPerPage: -1 } })).toBe(15);
expect(computeGuiPostsPerPage({ features: { postsPerPage: NaN } })).toBe(15);
expect(computeGuiPostsPerPage({ features: { postsPerPage: Infinity } })).toBe(15);
expect(computeGuiPostsPerPage({ features: { postsPerPage: '15' as unknown as number } })).toBe(15);
expect(computeGuiPostsPerPage({ features: { postsPerPage: 15.7 } })).toBe(15);
});
});
describe('getBoardFeedPageSizeConstants', () => {
it('directory page size 15 -> pagination feed size 150, 10-page cap', () => {
const c = getBoardFeedPageSizeConstants(15);
expect(c.guiPostsPerPage).toBe(15);
expect(c.maxGuiPages).toBe(10);
expect(c.paginationFeedPostsPerPage).toBe(150);
expect(c.infiniteFeedPostsPerPage).toBe(15);
});
it('directory page size 20 -> pagination feed size 200', () => {
const c = getBoardFeedPageSizeConstants(20);
expect(c.guiPostsPerPage).toBe(20);
expect(c.maxGuiPages).toBe(10);
expect(c.paginationFeedPostsPerPage).toBe(200);
expect(c.infiniteFeedPostsPerPage).toBe(20);
});
});
describe('getPageSlice', () => {
const guiPostsPerPage = 15;
const maxGuiPages = 10;
it('returns correct slice for page 1', () => {
const items = Array.from({ length: 200 }, (_, i) => i);
const slice = getPageSlice(items, 1, guiPostsPerPage, maxGuiPages);
expect(slice).toHaveLength(15);
expect(slice[0]).toBe(0);
expect(slice[14]).toBe(14);
});
it('returns correct slice for middle page', () => {
const items = Array.from({ length: 200 }, (_, i) => i);
const slice = getPageSlice(items, 5, guiPostsPerPage, maxGuiPages);
expect(slice).toHaveLength(15);
expect(slice[0]).toBe(60);
expect(slice[14]).toBe(74);
});
it('returns correct slice for last page', () => {
const items = Array.from({ length: 200 }, (_, i) => i);
const slice = getPageSlice(items, 10, guiPostsPerPage, maxGuiPages);
expect(slice).toHaveLength(15);
expect(slice[0]).toBe(135);
expect(slice[14]).toBe(149);
});
it('returns partial slice when total items do not fill last page', () => {
const items = Array.from({ length: 37 }, (_, i) => i);
const slice = getPageSlice(items, 3, guiPostsPerPage, maxGuiPages);
expect(slice).toHaveLength(7);
expect(slice[0]).toBe(30);
expect(slice[6]).toBe(36);
});
it('clamps page when total items shrink (requested page beyond valid range)', () => {
const items = Array.from({ length: 20 }, (_, i) => i); // only 2 pages of 15
const slice = getPageSlice(items, 10, guiPostsPerPage, maxGuiPages);
expect(slice).toHaveLength(5); // page 2 has 5 items (15-19)
expect(slice[0]).toBe(15);
expect(slice[4]).toBe(19);
});
it('clamps page 1 when requesting page 0 or negative', () => {
const items = Array.from({ length: 50 }, (_, i) => i);
const slice0 = getPageSlice(items, 0, guiPostsPerPage, maxGuiPages);
const sliceNeg = getPageSlice(items, -1, guiPostsPerPage, maxGuiPages);
expect(slice0).toEqual(getPageSlice(items, 1, guiPostsPerPage, maxGuiPages));
expect(sliceNeg).toEqual(getPageSlice(items, 1, guiPostsPerPage, maxGuiPages));
});
it('returns empty array when guiPostsPerPage or maxGuiPages is 0', () => {
const items = [1, 2, 3];
expect(getPageSlice(items, 1, 0, 10)).toEqual([]);
expect(getPageSlice(items, 1, 15, 0)).toEqual([]);
});
it('returns empty array when items is empty', () => {
expect(getPageSlice([], 1, guiPostsPerPage, maxGuiPages)).toEqual([]);
});
});
+49
View File
@@ -0,0 +1,49 @@
const GUI_POSTS_PER_PAGE_FALLBACK = 15;
const MAX_GUI_PAGES = 10;
/** Minimal shape for directory community with optional postsPerPage */
export interface CommunityWithPostsPerPage {
features?: { postsPerPage?: number };
}
/**
* Compute posts-per-page for the GUI from directory features.
* Single directory board: uses community.features.postsPerPage when valid numeric > 0.
* Missing metadata, non-directory board, /all, /subs, /mod: fallback 15.
*/
export const computeGuiPostsPerPage = (community?: CommunityWithPostsPerPage | null): number => {
const value = community?.features?.postsPerPage;
if (typeof value === 'number' && value > 0 && Number.isFinite(value)) {
return Math.floor(value);
}
return GUI_POSTS_PER_PAGE_FALLBACK;
};
/**
* Board feed pagination constants.
* Fallback 15 and max 10 GUI pages are fixed.
*/
export const getBoardFeedPageSizeConstants = (guiPostsPerPage: number) => ({
guiPostsPerPage,
maxGuiPages: MAX_GUI_PAGES,
paginationFeedPostsPerPage: guiPostsPerPage * MAX_GUI_PAGES,
infiniteFeedPostsPerPage: guiPostsPerPage,
});
/**
* Slice items for a given page with page clamping.
* When total items shrink, the requested page is clamped to the last valid page.
*/
export const getPageSlice = <T>(items: T[], page: number, guiPostsPerPage: number, maxGuiPages: number): T[] => {
if (guiPostsPerPage <= 0 || maxGuiPages <= 0) {
return [];
}
const totalItems = items.length;
const totalGuiPages = Math.min(maxGuiPages, Math.ceil(totalItems / guiPostsPerPage) || 1);
const clampedPage = Math.max(1, Math.min(page, totalGuiPages));
const start = (clampedPage - 1) * guiPostsPerPage;
const end = start + guiPostsPerPage;
return items.slice(start, end);
};
+30 -2
View File
@@ -118,8 +118,10 @@ export const isFeedRoute = (pathname: string): boolean => {
if (segments.length >= 1) {
if (segments.length === 1) return true;
if (segments.length === 2 && segments[1] === 'catalog') return true;
if (segments.length === 2 && /^(?:\d+(?:h|d|w|m|y)|all)$/.test(segments[1])) return true;
if (segments.length === 2 && (/^(?:\d+(?:h|d|w|m|y)|all)$/.test(segments[1]) || /^([1-9]|10)$/.test(segments[1]))) return true;
if (segments.length === 3 && segments[1] === 'catalog' && /^(?:\d+(?:h|d|w|m|y)|all)$/.test(segments[2])) return true;
if (segments.length === 3 && /^(?:\d+(?:h|d|w|m|y)|all)$/.test(segments[1]) && /^([1-9]|10)$/.test(segments[2])) return true;
if (segments.length === 2 && segments[0] !== 'all' && segments[0] !== 'subs' && segments[0] !== 'mod' && /^([1-9]|10)$/.test(segments[1])) return true;
}
return false;
@@ -143,6 +145,32 @@ export const isModQueueRoute = (pathname: string): boolean => {
return normalizedPath.includes('/modqueue');
};
/** Page numbers 110 for board feed pagination */
export const BOARD_PAGE_REGEX = /^([1-9]|10)$/;
export const isBoardFeedPageNumber = (segment: string): boolean => BOARD_PAGE_REGEX.test(segment);
/** Strip trailing page number (110) from path for feed cache key */
export const stripPageFromFeedPath = (path: string): string => {
const segments = path.split('/').filter(Boolean);
if (segments.length > 1 && isBoardFeedPageNumber(segments[segments.length - 1])) {
return '/' + segments.slice(0, -1).join('/');
}
return path;
};
/** Parse current page number (110) from feed pathname; returns 1 if none */
export const getPageFromFeedPath = (pathname: string): number => {
const normalized = pathname.replace(/\/settings$/, '').replace(/\/$/, '');
const segments = normalized.split('/').filter(Boolean);
const last = segments[segments.length - 1];
if (last && isBoardFeedPageNumber(last)) {
const n = parseInt(last, 10);
return Math.min(10, Math.max(1, n));
}
return 1;
};
export const getFeedCacheKey = (pathname: string): string | null => {
let normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
normalizedPath = normalizedPath.replace(/\/settings$/, '');
@@ -161,7 +189,7 @@ export const getFeedCacheKey = (pathname: string): string | null => {
}
if (isFeedRoute(pathname)) {
return normalizedPath;
return stripPageFromFeedPath(normalizedPath);
}
return null;
@@ -0,0 +1,36 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import useFeedViewSettingsStore from '../use-feed-view-settings-store';
const STORAGE_KEY = 'feed-view-settings-store';
describe('useFeedViewSettingsStore', () => {
let setItemSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
localStorage.removeItem(STORAGE_KEY);
setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
});
afterEach(() => {
setItemSpy.mockRestore();
useFeedViewSettingsStore.getState().setEnableInfiniteScroll(false);
});
it('defaults enableInfiniteScroll to false', () => {
const { enableInfiniteScroll } = useFeedViewSettingsStore.getState();
expect(enableInfiniteScroll).toBe(false);
});
it('sets enableInfiniteScroll to true when setEnableInfiniteScroll is called with true', () => {
useFeedViewSettingsStore.getState().setEnableInfiniteScroll(true);
const { enableInfiniteScroll } = useFeedViewSettingsStore.getState();
expect(enableInfiniteScroll).toBe(true);
});
it('persists state to localStorage when setEnableInfiniteScroll is called', () => {
useFeedViewSettingsStore.getState().setEnableInfiniteScroll(true);
expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, expect.stringContaining('"state":'));
expect(setItemSpy).toHaveBeenCalledWith(STORAGE_KEY, expect.stringContaining('"enableInfiniteScroll":true'));
});
});
@@ -0,0 +1,21 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface FeedViewSettingsState {
enableInfiniteScroll: boolean;
setEnableInfiniteScroll: (enable: boolean) => void;
}
const useFeedViewSettingsStore = create<FeedViewSettingsState>()(
persist(
(set) => ({
enableInfiniteScroll: false,
setEnableInfiniteScroll: (enable) => set({ enableInfiniteScroll: enable }),
}),
{
name: 'feed-view-settings-store',
},
),
);
export default useFeedViewSettingsStore;
+114 -25
View File
@@ -1,21 +1,25 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
import { Link, useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom';
import { Comment, useAccount, useAccountComments, useAccountSubplebbits, useFeed, useSubplebbit } from '@plebbit/plebbit-react-hooks';
import { useSubplebbitField } from '../../hooks/use-stable-subplebbit';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
import { Trans, useTranslation } from 'react-i18next';
import styles from './board.module.css';
import { shouldShowSnow } from '../../lib/snow';
import { useDirectoryAddresses, useDirectories } from '../../hooks/use-directories';
import { useDirectoryAddresses, useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-directory-addresses';
import { useResolvedSubplebbitAddress, useBoardPath } from '../../hooks/use-resolved-subplebbit-address';
import { useFeedStateString } from '../../hooks/use-state-string';
import useTimeFilter, { timeFilterNameToSeconds } from '../../hooks/use-time-filter';
import useFeedResetStore from '../../stores/use-feed-reset-store';
import useSortingStore from '../../stores/use-sorting-store';
import { getSubplebbitAddress, isDirectoryBoard } from '../../lib/utils/route-utils';
import useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store';
import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size';
import { getPageSlice } from '../../lib/utils/board-feed-pagination';
import { getPageFromFeedPath, getSubplebbitAddress, isDirectoryBoard, stripPageFromFeedPath } from '../../lib/utils/route-utils';
import ErrorDisplay from '../../components/error-display/error-display';
import LoadingEllipsis from '../../components/loading-ellipsis';
import BoardPagination from '../../components/board-pagination';
import { Post } from '../post';
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
@@ -39,6 +43,8 @@ interface BoardFooterProps {
subplebbitState: string | undefined;
subscriptionsLength: number;
accountSubplebbitAddressesLength: number;
/** In pagination mode, suppress infinite-scroll loading cues */
showLoadingEllipsis?: boolean;
}
// Defined outside Board to preserve component identity across renders (Virtuoso optimization)
@@ -63,6 +69,7 @@ const BoardFooter = ({
subplebbitState,
subscriptionsLength,
accountSubplebbitAddressesLength,
showLoadingEllipsis = true,
}: BoardFooterProps) => {
const { t } = useTranslation();
@@ -141,7 +148,7 @@ const BoardFooter = ({
) : isInModView && accountSubplebbitAddressesLength === 0 ? (
<span className='red'>{t('not_mod_of_any_board')}</span>
) : (
hasMore && <LoadingEllipsis string={loadingStateString} />
showLoadingEllipsis && hasMore && <LoadingEllipsis string={loadingStateString} />
)}
</div>
</div>
@@ -201,16 +208,38 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
const timeFilterName = timeFilterNameFromCache || timeFilterNameFromHook;
const timeFilterSeconds = timeFilterNameFromCache ? timeFilterNameToSeconds(timeFilterNameFromCache) : timeFilterSecondsFromHook;
const feedOptions = {
subplebbitAddresses,
sortType,
postsPerPage: isInAllView || isInSubscriptionsView || isInModView ? 5 : 25,
...(isInAllView || isInSubscriptionsView || isInModView ? { newerThan: timeFilterSeconds } : {}),
};
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
const community = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : subplebbitAddress);
const { guiPostsPerPage, maxGuiPages, paginationFeedPostsPerPage, infiniteFeedPostsPerPage } = useBoardFeedPageSize(community);
const feedOptions = useMemo(
() => ({
subplebbitAddresses,
sortType,
postsPerPage: enableInfiniteScroll ? infiniteFeedPostsPerPage : paginationFeedPostsPerPage,
...(isInAllView || isInSubscriptionsView || isInModView ? { newerThan: timeFilterSeconds } : {}),
}),
[
subplebbitAddresses,
sortType,
enableInfiniteScroll,
infiniteFeedPostsPerPage,
paginationFeedPostsPerPage,
isInAllView,
isInSubscriptionsView,
isInModView,
timeFilterSeconds,
],
);
const { feed, hasMore, loadMore, reset, subplebbitAddressesWithNewerPosts } = useFeed(feedOptions);
const { accountComments } = useAccountComments();
const feedContextKey = `${isInAllView ? 'all' : isInSubscriptionsView ? 'subs' : isInModView ? 'mod' : (subplebbitAddress ?? 'board')}-${sortType}-${timeFilterSeconds}-${viewType ?? 'board'}-${enableInfiniteScroll}`;
const pathWithoutSettings = location.pathname.replace(/\/settings$/, '');
const currentPage = getPageFromFeedPath(pathWithoutSettings);
const paginationBasePath = stripPageFromFeedPath(pathWithoutSettings);
const resetTriggeredRef = useRef(false);
const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
@@ -249,6 +278,32 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
return newFeed;
}, [feed, filteredComments]);
const cappedFeed = useMemo(
() => (enableInfiniteScroll ? combinedFeed : combinedFeed.slice(0, guiPostsPerPage * maxGuiPages)),
[enableInfiniteScroll, combinedFeed, guiPostsPerPage, maxGuiPages],
);
const totalPages = useMemo(() => Math.min(maxGuiPages, Math.ceil(cappedFeed.length / guiPostsPerPage) || 1), [cappedFeed.length, guiPostsPerPage, maxGuiPages]);
const currentPageFeed = useMemo(
() => (enableInfiniteScroll ? [] : getPageSlice(cappedFeed, currentPage, guiPostsPerPage, maxGuiPages)),
[enableInfiniteScroll, cappedFeed, currentPage, guiPostsPerPage, maxGuiPages],
);
const navigate = useNavigate();
useEffect(() => {
if (!enableInfiniteScroll && currentPage > totalPages && totalPages > 0) {
const targetPage = totalPages;
const targetPath = targetPage === 1 ? paginationBasePath : `${paginationBasePath}/${targetPage}`;
navigate(targetPage === 1 ? paginationBasePath : `${paginationBasePath}/${targetPage}`, { replace: true });
}
}, [enableInfiniteScroll, currentPage, totalPages, paginationBasePath, navigate]);
// Scroll to top instantly when page changes in pagination mode
useEffect(() => {
if (!enableInfiniteScroll) {
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
}
}, [enableInfiniteScroll, currentPage]);
useEffect(() => {
if (filteredComments.length > 0 && !resetTriggeredRef.current) {
reset();
@@ -328,6 +383,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
subplebbitState={subplebbitState}
subscriptionsLength={subscriptions?.length || 0}
accountSubplebbitAddressesLength={accountSubplebbitAddresses?.length || 0}
showLoadingEllipsis={enableInfiniteScroll}
/>
),
}),
@@ -350,6 +406,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
subplebbitState,
subscriptions?.length,
accountSubplebbitAddresses?.length,
enableInfiniteScroll,
],
);
@@ -415,25 +472,56 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
<ErrorDisplay error={subplebbitError} />
</div>
)}
{/* Use Virtuoso for infinite scroll only when there's more content to paginate */}
{hasMore ? (
<Virtuoso
increaseViewportBy={{ bottom: 1200, top: 1200 }}
totalCount={combinedFeed.length}
data={combinedFeed}
itemContent={(index, post) => <Post index={index} post={post} />}
useWindowScroll={true}
components={footerComponents}
endReached={loadMore}
ref={virtuosoRef}
restoreStateFrom={lastVirtuosoState}
initialScrollTop={lastVirtuosoState?.scrollTop}
/>
{/* Infinite mode: Virtuoso when hasMore, else plain list */}
{enableInfiniteScroll ? (
hasMore ? (
<Virtuoso
increaseViewportBy={{ bottom: 1200, top: 1200 }}
totalCount={combinedFeed.length}
data={combinedFeed}
itemContent={(index, post) => <Post index={index} post={post} />}
useWindowScroll={true}
components={footerComponents}
endReached={loadMore}
ref={virtuosoRef}
restoreStateFrom={lastVirtuosoState}
initialScrollTop={lastVirtuosoState?.scrollTop}
/>
) : (
<>
{combinedFeed.map((post, index) => (
<Post key={post.cid} index={index} post={post} />
))}
<BoardFooter
subplebbitAddresses={subplebbitAddresses}
hasMore={hasMore}
combinedFeedLength={combinedFeed.length}
subplebbitAddressesWithNewerPosts={subplebbitAddressesWithNewerPosts}
onNewerPostsClick={handleNewerPostsButtonClick}
isInAllView={isInAllView}
isInSubscriptionsView={isInSubscriptionsView}
isInModView={isInModView}
showMorePostsSuggestion={showMorePostsSuggestion}
feedLength={feedLength}
weeklyFeedLength={weeklyFeedLength}
monthlyFeedLength={monthlyFeedLength}
yearlyFeedLength={yearlyFeedLength}
boardPath={boardPath}
currentTimeFilterName={currentTimeFilterName}
subplebbitState={subplebbitState}
subscriptionsLength={subscriptions?.length || 0}
accountSubplebbitAddressesLength={accountSubplebbitAddresses?.length || 0}
showLoadingEllipsis={true}
/>
</>
)
) : (
/* Pagination mode: plain list, no Virtuoso, no loadMore */
<>
{combinedFeed.map((post, index) => (
{currentPageFeed.map((post, index) => (
<Post key={post.cid} index={index} post={post} />
))}
<BoardPagination basePath={paginationBasePath} currentPage={currentPage} totalPages={totalPages} />
<BoardFooter
subplebbitAddresses={subplebbitAddresses}
hasMore={hasMore}
@@ -453,6 +541,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, t
subplebbitState={subplebbitState}
subscriptionsLength={subscriptions?.length || 0}
accountSubplebbitAddressesLength={accountSubplebbitAddresses?.length || 0}
showLoadingEllipsis={false}
/>
</>
)}