mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat: finalize pretext feed sizing rollout (#1120)
* feat(feeds): add pretext-backed item sizing across board, catalog, and thread replies * feat: finalize pretext feed sizing rollout * fix(catalog): raise multiboard viewport buffer * fix(board): preserve pretext query overrides
This commit is contained in:
@@ -35,7 +35,6 @@ const testState = vi.hoisted(() => ({
|
||||
account: { subscriptions: [] as string[] },
|
||||
accountComments: [] as TestComment[],
|
||||
accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>,
|
||||
clearMatchedFiltersMock: vi.fn(),
|
||||
directoryByAddress: {
|
||||
'music-posting.eth': {
|
||||
address: 'music-posting.eth',
|
||||
@@ -47,6 +46,7 @@ const testState = vi.hoisted(() => ({
|
||||
filterItems: [] as FilterItem[],
|
||||
filteredDirectoryAddresses: ['music-posting.eth'] as string[],
|
||||
hasMore: false,
|
||||
imageSize: 'Small' as 'Large' | 'Small',
|
||||
incrementFilterCountMock: vi.fn(),
|
||||
loadMoreMock: vi.fn(),
|
||||
pageSizes: {
|
||||
@@ -58,8 +58,8 @@ const testState = vi.hoisted(() => ({
|
||||
resolvedCommunityAddress: 'music-posting.eth' as string | undefined,
|
||||
searchText: '',
|
||||
setCurrentCommunityAddressMock: vi.fn(),
|
||||
setMatchedFilterMock: vi.fn(),
|
||||
setResetFunctionMock: vi.fn(),
|
||||
showOPComment: true,
|
||||
sortType: 'new' as 'active' | 'new',
|
||||
windowWidth: 900,
|
||||
community: {
|
||||
@@ -72,12 +72,10 @@ const testState = vi.hoisted(() => ({
|
||||
|
||||
function getCatalogFiltersState() {
|
||||
return {
|
||||
clearMatchedFilters: testState.clearMatchedFiltersMock,
|
||||
filterItems: testState.filterItems,
|
||||
incrementFilterCount: testState.incrementFilterCountMock,
|
||||
searchText: testState.searchText,
|
||||
setCurrentSubplebbitAddress: testState.setCurrentCommunityAddressMock,
|
||||
setMatchedFilter: testState.setMatchedFilterMock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,7 +189,8 @@ vi.mock('../../../hooks/use-window-width', () => ({
|
||||
|
||||
vi.mock('../../../stores/use-catalog-style-store', () => ({
|
||||
default: () => ({
|
||||
imageSize: 'Small',
|
||||
imageSize: testState.imageSize,
|
||||
showOPComment: testState.showOPComment,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -213,7 +212,8 @@ vi.mock('../../../stores/use-catalog-filters-store', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/catalog-row', () => ({
|
||||
default: ({ row }: { row: TestComment[] }) => createElement('div', { 'data-testid': 'catalog-row' }, `row:${row.map((comment) => comment.cid).join(',')}`),
|
||||
default: ({ estimatedHeight, row }: { estimatedHeight?: number; row: TestComment[] }) =>
|
||||
createElement('div', { 'data-pretext-height': estimatedHeight, 'data-testid': 'catalog-row' }, `row:${row.map((comment) => comment.cid).join(',')}`),
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/footer', () => ({
|
||||
@@ -308,6 +308,7 @@ describe('Catalog', () => {
|
||||
testState.filterItems = [];
|
||||
testState.filteredDirectoryAddresses = ['music-posting.eth'];
|
||||
testState.hasMore = false;
|
||||
testState.imageSize = 'Small';
|
||||
testState.pageSizes = {
|
||||
guiPostsPerPage: 2,
|
||||
maxGuiPages: 3,
|
||||
@@ -315,6 +316,7 @@ describe('Catalog', () => {
|
||||
};
|
||||
testState.resolvedCommunityAddress = 'music-posting.eth';
|
||||
testState.searchText = '';
|
||||
testState.showOPComment = true;
|
||||
testState.sortType = 'new';
|
||||
testState.windowWidth = 900;
|
||||
testState.community = {
|
||||
@@ -323,12 +325,10 @@ describe('Catalog', () => {
|
||||
state: 'ready',
|
||||
title: '/mu/ - Music',
|
||||
};
|
||||
testState.clearMatchedFiltersMock.mockReset();
|
||||
testState.incrementFilterCountMock.mockReset();
|
||||
testState.loadMoreMock.mockReset();
|
||||
testState.resetMock.mockReset();
|
||||
testState.setCurrentCommunityAddressMock.mockReset();
|
||||
testState.setMatchedFilterMock.mockReset();
|
||||
testState.setResetFunctionMock.mockReset();
|
||||
document.title = 'before';
|
||||
|
||||
@@ -342,7 +342,7 @@ describe('Catalog', () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('applies catalog filters, promotes top matches, and clears board filter state on unmount', async () => {
|
||||
it('applies catalog filters and promotes top matches', async () => {
|
||||
testState.feed = [
|
||||
{ cid: 'boring-post', title: 'plain talk', content: 'nothing special', communityAddress: 'music-posting.eth' },
|
||||
{ cid: 'hidden-post', title: 'cats and spoilers', content: 'spoiler content', communityAddress: 'music-posting.eth' },
|
||||
@@ -357,16 +357,13 @@ describe('Catalog', () => {
|
||||
|
||||
expect(document.title).toBe('/mu/ - catalog - 5chan');
|
||||
expect(testState.setCurrentCommunityAddressMock).toHaveBeenCalledWith('music-posting.eth');
|
||||
expect(testState.clearMatchedFiltersMock).toHaveBeenCalled();
|
||||
expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:top-post,boring-post']);
|
||||
expect(testState.incrementFilterCountMock).toHaveBeenCalledWith(0, 'hidden-post', 'music-posting.eth');
|
||||
expect(testState.incrementFilterCountMock).toHaveBeenCalledWith(1, 'top-post', 'music-posting.eth');
|
||||
expect(testState.setMatchedFilterMock).toHaveBeenCalledWith('top-post', 'red');
|
||||
|
||||
act(() => root.unmount());
|
||||
|
||||
expect(testState.setCurrentCommunityAddressMock).toHaveBeenLastCalledWith(null);
|
||||
expect(testState.clearMatchedFiltersMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useBoardFeedPageSize } from '../../hooks/use-board-feed-page-size';
|
||||
import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-directory-addresses';
|
||||
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
||||
import { useFeedStateString } from '../../hooks/use-state-string';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import useWindowWidth from '../../hooks/use-window-width';
|
||||
import useCatalogStyleStore from '../../stores/use-catalog-style-store';
|
||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||
@@ -24,6 +25,13 @@ import styles from './catalog.module.css';
|
||||
import { commentMatchesPattern } from '../../lib/utils/pattern-utils';
|
||||
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
|
||||
import { sortCatalogFeedForDisplay } from '../../lib/utils/catalog-sort';
|
||||
import {
|
||||
getCatalogRowHeightEstimates,
|
||||
getPretextItemSizeFromElement,
|
||||
getTypicalCatalogRowHeight,
|
||||
readReplyTypographyMetrics,
|
||||
resolveCatalogVirtualizationMode,
|
||||
} from '../../lib/utils/pretext-height-estimates';
|
||||
|
||||
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
|
||||
const RECENT_ACCOUNT_COMMENT_WINDOW_SECONDS = 60 * 60;
|
||||
@@ -34,9 +42,6 @@ interface CatalogFooterProps {
|
||||
communityAddresses: string[];
|
||||
hasMore: boolean;
|
||||
combinedFeedLength: number;
|
||||
isInAllView: boolean;
|
||||
isInSubscriptionsView: boolean;
|
||||
isInModView: boolean;
|
||||
/** When false, suppress the loading ellipsis (e.g. non-infinite mode) */
|
||||
showLoadingEllipsis?: boolean;
|
||||
}
|
||||
@@ -44,15 +49,7 @@ 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,
|
||||
isInAllView,
|
||||
isInSubscriptionsView,
|
||||
isInModView,
|
||||
showLoadingEllipsis = true,
|
||||
}: CatalogFooterProps) => {
|
||||
const CatalogFooter = ({ communityAddresses, hasMore, combinedFeedLength, showLoadingEllipsis = true }: CatalogFooterProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const loadingStateString = useFeedStateString(communityAddresses) || (combinedFeedLength === 0 ? t('loading_feed') : t('looking_for_more_posts'));
|
||||
@@ -141,11 +138,6 @@ const createContentFilter = (
|
||||
// Fallback to the store method if no callback provided
|
||||
useCatalogFiltersStore.getState().incrementFilterCount(filterIndex, comment.cid, communityAddress);
|
||||
}
|
||||
|
||||
// If the filter has a color, track it in the matchedFilters map
|
||||
if (item.color) {
|
||||
useCatalogFiltersStore.getState().setMatchedFilter(comment.cid, item.color);
|
||||
}
|
||||
}
|
||||
|
||||
// If this filter is set to hide, filter out the comment
|
||||
@@ -222,7 +214,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
return resolvedAddressFromUrl;
|
||||
}, [boardIdentifierProp, directories, resolvedAddressFromUrl]);
|
||||
|
||||
const { filterItems, searchText, clearMatchedFilters } = useCatalogFiltersStore();
|
||||
const { filterItems, searchText } = useCatalogFiltersStore();
|
||||
|
||||
const account = useAccount();
|
||||
const subscriptions = account?.subscriptions;
|
||||
@@ -239,11 +231,11 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
return communityAddress ? [communityAddress] : [];
|
||||
}, [isInAllView, isInSubscriptionsView, communityAddress, filteredDirectoryAddresses, subscriptions]);
|
||||
|
||||
const { imageSize } = useCatalogStyleStore();
|
||||
const { imageSize, showOPComment } = useCatalogStyleStore();
|
||||
const columnWidth = imageSize === 'Large' ? 270 : 180;
|
||||
|
||||
const columnCount = Math.floor(useWindowWidth() / columnWidth);
|
||||
const postsPerPage = columnCount <= 2 ? 10 : columnCount === 3 ? 15 : columnCount === 4 ? 20 : 25;
|
||||
const windowWidth = useWindowWidth();
|
||||
const isMobile = useIsMobile();
|
||||
const columnCount = Math.floor(windowWidth / columnWidth);
|
||||
|
||||
const communityDirectory = useDirectoryByAddress(isInAllView || isInSubscriptionsView || isInModView ? undefined : communityAddress);
|
||||
const { guiPostsPerPage: boardPostsPerPage, maxGuiPages, paginationFeedPostsPerPage } = useBoardFeedPageSize(communityDirectory);
|
||||
@@ -259,6 +251,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
|
||||
const { sortType } = useSortingStore();
|
||||
const feedSortType = sortType === 'new' ? 'new' : 'active';
|
||||
const catalogVirtualizationMode = useMemo(() => resolveCatalogVirtualizationMode(location.search, 'item-size'), [location.search]);
|
||||
const themeKey = typeof document !== 'undefined' ? document.body.className : '';
|
||||
|
||||
// Create a stable callback for filter matching
|
||||
const handleFilterMatch = useCallback((filterIndex: number, cid: string, communityAddress: string) => {
|
||||
@@ -372,15 +366,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
() => ({
|
||||
Footer: () => (
|
||||
<>
|
||||
<CatalogFooter
|
||||
communityAddresses={communityAddresses}
|
||||
hasMore={hasMore}
|
||||
combinedFeedLength={cappedFeed.length}
|
||||
isInAllView={isInAllView}
|
||||
isInSubscriptionsView={isInSubscriptionsView}
|
||||
isInModView={isInModView}
|
||||
showLoadingEllipsis={effectiveInfiniteScroll}
|
||||
/>
|
||||
<CatalogFooter communityAddresses={communityAddresses} hasMore={hasMore} combinedFeedLength={cappedFeed.length} showLoadingEllipsis={effectiveInfiniteScroll} />
|
||||
<PageFooterDesktop
|
||||
firstRow={
|
||||
<CatalogFooterFirstRow
|
||||
@@ -440,6 +426,29 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
return [...topPosts, ...regularPosts];
|
||||
}, [sortedFeed, filterItems]);
|
||||
|
||||
const matchedFilterColors = useMemo(() => {
|
||||
const nextMatchedFilterColors = new Map<string, string>();
|
||||
const activeColoredFilters = filterItems.filter((item) => item.enabled && item.text.trim() !== '' && item.color);
|
||||
|
||||
if (activeColoredFilters.length === 0) {
|
||||
return nextMatchedFilterColors;
|
||||
}
|
||||
|
||||
for (const comment of processedFeed) {
|
||||
const cid = comment?.cid;
|
||||
if (!cid) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const firstMatch = activeColoredFilters.find((item) => commentMatchesPattern(comment, item.text));
|
||||
if (firstMatch?.color) {
|
||||
nextMatchedFilterColors.set(cid, firstMatch.color);
|
||||
}
|
||||
}
|
||||
|
||||
return nextMatchedFilterColors;
|
||||
}, [filterItems, processedFeed]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!isFeedLoaded) {
|
||||
return [];
|
||||
@@ -453,6 +462,26 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
return nextRows;
|
||||
}, [columnCount, isFeedLoaded, processedFeed]);
|
||||
|
||||
const catalogMetrics = useMemo(() => readReplyTypographyMetrics(), [themeKey, windowWidth]);
|
||||
const rowHeightEstimates = useMemo(
|
||||
() =>
|
||||
catalogVirtualizationMode === 'off'
|
||||
? []
|
||||
: getCatalogRowHeightEstimates({
|
||||
imageSize,
|
||||
metrics: catalogMetrics,
|
||||
rows,
|
||||
showOPComment,
|
||||
}),
|
||||
[catalogMetrics, catalogVirtualizationMode, imageSize, rows, showOPComment],
|
||||
);
|
||||
const defaultCatalogRowHeight = useMemo(() => getTypicalCatalogRowHeight(rowHeightEstimates, imageSize), [imageSize, rowHeightEstimates]);
|
||||
// Omit the prop entirely in fallback mode. Passing `itemSize={undefined}` overrides
|
||||
// Virtuoso's default DOM measurement path and leaves rows on the fallback height.
|
||||
const catalogSizingProps = useMemo(() => (catalogVirtualizationMode === 'item-size' ? { itemSize: getPretextItemSizeFromElement } : {}), [catalogVirtualizationMode]);
|
||||
const isMultiboardView = isInAllView || isInSubscriptionsView || isInModView;
|
||||
const catalogViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 2400, top: 1200 } : { bottom: 1200, top: 900 }) : { bottom: 1200, top: 1200 };
|
||||
|
||||
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
||||
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}` : `${location.pathname}-${sortType}-catalog`;
|
||||
const navigationType = useNavigationType();
|
||||
@@ -501,37 +530,6 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
document.title = documentTitle + ` - ${t('catalog')} - 5chan`;
|
||||
}, [title, shortAddress, communityAddress, isInAllView, isInSubscriptionsView, t, isVisible, params.boardIdentifier, boardIdentifierProp, directories]);
|
||||
|
||||
// Clear matched filters when component mounts or when community changes
|
||||
useEffect(() => {
|
||||
clearMatchedFilters();
|
||||
return () => {
|
||||
clearMatchedFilters();
|
||||
};
|
||||
}, [clearMatchedFilters, communityAddress]);
|
||||
|
||||
// Memoize filter color application to avoid redundant iterations
|
||||
useMemo(() => {
|
||||
if (cappedFeed.length > 0 && filterItems.length > 0) {
|
||||
// Clear existing matched filters
|
||||
clearMatchedFilters();
|
||||
|
||||
// Apply colors to posts that match filters
|
||||
cappedFeed.forEach((comment) => {
|
||||
if (!comment?.cid) return;
|
||||
|
||||
// Check each filter
|
||||
for (const item of filterItems) {
|
||||
if (item.enabled && item.text.trim() !== '' && item.color) {
|
||||
if (commentMatchesPattern(comment, item.text)) {
|
||||
useCatalogFiltersStore.getState().setMatchedFilter(comment.cid, item.color);
|
||||
break; // Use the first matching filter's color
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [cappedFeed, filterItems, clearMatchedFilters]);
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<hr />
|
||||
@@ -539,11 +537,13 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
||||
{processedFeed?.length !== 0 ? (
|
||||
<>
|
||||
<Virtuoso
|
||||
defaultItemHeight={imageSize === 'Large' ? 320 : 200}
|
||||
increaseViewportBy={isInAllView || isInSubscriptionsView || isInModView ? { bottom: 600, top: 600 } : { bottom: 1200, top: 1200 }}
|
||||
defaultItemHeight={defaultCatalogRowHeight}
|
||||
heightEstimates={catalogVirtualizationMode === 'off' ? undefined : rowHeightEstimates}
|
||||
increaseViewportBy={catalogViewportBuffer}
|
||||
{...catalogSizingProps}
|
||||
totalCount={rows?.length || 0}
|
||||
data={rows}
|
||||
itemContent={(index, row) => <CatalogRow index={index} row={row} />}
|
||||
itemContent={(index, row) => <CatalogRow estimatedHeight={rowHeightEstimates[index]} index={index} matchedFilterColors={matchedFilterColors} row={row} />}
|
||||
useWindowScroll={true}
|
||||
components={footerComponents}
|
||||
endReached={effectiveInfiniteScroll && hasMore ? loadMore : undefined}
|
||||
|
||||
Reference in New Issue
Block a user