mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(board): default pagination with optional infinite scroll and URL-based page routing
This commit is contained in:
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from './board-pagination';
|
||||
export type { BoardPaginationProps } from './board-pagination';
|
||||
+102
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ export interface DirectoriesMetadata {
|
||||
}
|
||||
|
||||
export interface DirectoryFeatures {
|
||||
postsPerPage?: number;
|
||||
pseudonymityMode?: string;
|
||||
nsfw?: boolean;
|
||||
noSpoilers?: boolean;
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 1–10 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 (1–10) 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 (1–10) 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
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user