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:
Tommaso Casaburi
2026-04-02 19:45:55 +07:00
committed by GitHub
parent 4fb7739991
commit 251e103db3
21 changed files with 2733 additions and 179 deletions
@@ -45,9 +45,11 @@ type TestComment = {
const testState = vi.hoisted(() => ({
addChallengeMock: vi.fn(),
hasMoreReplies: false,
openReplyModalMock: vi.fn(),
replyComments: [] as Array<TestComment | undefined>,
setResetFunctionMock: vi.fn(),
virtuosoProps: [] as Array<{ defaultItemHeight?: number; heightEstimates?: number[]; itemSize?: unknown }>,
}));
const getMockPreloadedReplies = (comment?: TestComment, sortType?: string) => {
@@ -89,7 +91,7 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
useReplies: ({ comment, sortType }: { comment?: TestComment; sortType?: string }) => {
testState.replyComments.push(comment);
return {
hasMore: false,
hasMore: testState.hasMoreReplies,
loadMore: vi.fn(),
replies: getMockPreloadedReplies(comment, sortType),
};
@@ -102,14 +104,22 @@ vi.mock('react-virtuoso', () => ({
{
components,
data = [],
defaultItemHeight,
heightEstimates,
itemSize,
itemContent,
}: {
components?: { Footer?: React.ComponentType };
data?: TestComment[];
defaultItemHeight?: number;
heightEstimates?: number[];
itemSize?: unknown;
itemContent: (index: number, item: TestComment) => React.ReactNode;
},
ref: React.ForwardedRef<{ getState: (cb: (snapshot: { ranges: number[]; scrollTop: number }) => void) => void }>,
) => {
testState.virtuosoProps.push({ defaultItemHeight, heightEstimates, itemSize });
React.useImperativeHandle(ref, () => ({
getState: (cb) => cb({ ranges: [0], scrollTop: 0 }),
}));
@@ -315,6 +325,14 @@ vi.mock('../../hooks/use-fresh-replies', () => ({
default: (replies: TestComment[]) => replies,
}));
vi.mock('../../hooks/use-reply-height-estimates', () => ({
default: ({ isMobile, replies }: { isMobile: boolean; replies: TestComment[] }) => ({
defaultItemHeight: isMobile ? 222 : 111,
heightEstimates: replies.map((_, index) => (isMobile ? 200 : 100) + index),
itemSize: vi.fn(),
}),
}));
vi.mock('../../lib/constants', () => ({
BOARD_REPLIES_PREVIEW_FETCH_SIZE: 5,
BOARD_REPLIES_PREVIEW_VISIBLE_COUNT: 3,
@@ -406,10 +424,24 @@ const makeLegacyThread = (): TestComment => ({
timestamp: 1_710_000_000,
});
const makeLegacyThreadWithoutReplies = (): TestComment => ({
...makeLegacyThread(),
replyCount: 0,
replies: {
pages: {
new: {
comments: [],
},
},
},
});
describe('post community address compatibility', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.hasMoreReplies = false;
testState.replyComments = [];
testState.virtuosoProps = [];
container = document.createElement('div');
document.body.appendChild(container);
@@ -444,4 +476,39 @@ describe('post community address compatibility', () => {
expect(container.querySelector('[data-testid="comment-media"]')).toBeTruthy();
expect(container.textContent).toContain('reply-1');
});
it('forwards Pretext-backed reply estimates into Virtuoso for desktop and mobile thread views', async () => {
testState.hasMoreReplies = true;
await renderWithRoute(createElement(PostDesktop, { post: makeLegacyThread(), showAllReplies: true }), '/mu/thread/post-1');
expect(testState.virtuosoProps.at(-1)).toEqual({
defaultItemHeight: 111,
heightEstimates: [100],
itemSize: expect.any(Function),
});
testState.virtuosoProps = [];
await renderWithRoute(createElement(PostMobile, { post: makeLegacyThread(), showAllReplies: true }), '/mu/thread/post-1');
expect(testState.virtuosoProps.at(-1)).toEqual({
defaultItemHeight: 222,
heightEstimates: [200],
itemSize: expect.any(Function),
});
});
it('keeps board-card Pretext heights when preview replies are rendered', async () => {
await renderWithRoute(createElement(PostDesktop, { post: makeLegacyThread() }));
expect(container.querySelector('.postDesktop')?.getAttribute('data-pretext-height')).toBeTruthy();
await renderWithRoute(createElement(PostMobile, { post: makeLegacyThread() }));
expect(container.querySelector('.postMobile')?.getAttribute('data-pretext-height')).toBeTruthy();
});
it('keeps board-card Pretext heights for simple cards without preview replies', async () => {
await renderWithRoute(createElement(PostDesktop, { post: makeLegacyThreadWithoutReplies() }));
expect(container.querySelector('.postDesktop')?.getAttribute('data-pretext-height')).toBeTruthy();
await renderWithRoute(createElement(PostMobile, { post: makeLegacyThreadWithoutReplies() }));
expect(container.querySelector('.postMobile')?.getAttribute('data-pretext-height')).toBeTruthy();
});
});
@@ -79,8 +79,8 @@ describe('BoardPagination', () => {
buttons[1]?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.navigateMock).toHaveBeenNthCalledWith(1, '/mu');
expect(testState.navigateMock).toHaveBeenNthCalledWith(2, '/mu/3');
expect(testState.navigateMock).toHaveBeenNthCalledWith(1, { pathname: '/mu', search: '' });
expect(testState.navigateMock).toHaveBeenNthCalledWith(2, { pathname: '/mu/3', search: '' });
});
it('shows the footer pagelist, catalog links, and enables infinite scroll from the all shortcut', async () => {
@@ -104,7 +104,7 @@ describe('BoardPagination', () => {
nextButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/2');
expect(testState.navigateMock).toHaveBeenCalledWith({ pathname: '/mu/2', search: '' });
});
it('hides the footer pagelist for multiboards or when infinite scroll is already enabled', () => {
@@ -8,6 +8,7 @@ import styles from './board-pagination.module.css';
interface BoardPaginationProps {
basePath: string;
currentPage: number;
search?: string;
totalPages: number;
/** When true, renders pagelist: [All] [1] [2] ... [10] Catalog Archive + Style select */
footerStyle?: boolean;
@@ -15,14 +16,14 @@ interface BoardPaginationProps {
isMultiboard?: boolean;
}
const BoardPagination = ({ basePath, currentPage, totalPages, footerStyle = false, isMultiboard = false }: BoardPaginationProps) => {
const BoardPagination = ({ basePath, currentPage, search = '', totalPages, footerStyle = false, isMultiboard = false }: BoardPaginationProps) => {
const { t } = useTranslation();
const navigate = useNavigate();
const enableInfiniteScroll = useFeedViewSettingsStore((state) => state.enableInfiniteScroll);
const setEnableInfiniteScroll = useFeedViewSettingsStore((state) => state.setEnableInfiniteScroll);
const pageHref = (page: number) => (page === 1 ? basePath : `${basePath}/${page}`);
const catalogHref = `${basePath}/catalog`;
const pageHref = (page: number) => ({ pathname: page === 1 ? basePath : `${basePath}/${page}`, search });
const catalogHref = { pathname: `${basePath}/catalog`, search };
if (totalPages <= 1 && !footerStyle) {
return null;
@@ -30,7 +31,7 @@ const BoardPagination = ({ basePath, currentPage, totalPages, footerStyle = fals
if (footerStyle) {
const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1);
const archiveHref = `${basePath}/archive`;
const archiveHref = { pathname: `${basePath}/archive`, search };
return (
<div className={`${footerStyles.footerRow} ${isMultiboard ? footerStyles.footerRowRightOnly : ''}`}>
@@ -54,7 +54,6 @@ const testState = vi.hoisted(() => ({
hiddenCids: new Set<string>(),
imageSize: 'Small' as 'Large' | 'Small',
linkCount: 0,
matchedFilters: new Map<string, string>(),
mediaInfoByLink: {} as Record<string, { patternThumbnailUrl?: string; thumbnail?: string; type: string; url: string }>,
lastRepliesComment: undefined as TestComment | undefined,
replies: [] as TestComment[],
@@ -63,12 +62,6 @@ const testState = vi.hoisted(() => ({
showSnow: false,
}));
function getCatalogFiltersState() {
return {
matchedFilters: testState.matchedFilters,
};
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
@@ -156,13 +149,6 @@ vi.mock('../../../hooks/use-directories', () => ({
useDirectories: () => testState.directories,
}));
vi.mock('../../../stores/use-catalog-filters-store', () => ({
default: <T,>(selector?: (state: ReturnType<typeof getCatalogFiltersState>) => T) => {
const state = getCatalogFiltersState();
return selector ? selector(state) : (state as T);
},
}));
vi.mock('../../../stores/use-catalog-style-store', () => ({
default: () => ({
imageSize: testState.imageSize,
@@ -232,7 +218,6 @@ describe('CatalogRow', () => {
testState.hiddenCids = new Set<string>();
testState.imageSize = 'Small';
testState.linkCount = 0;
testState.matchedFilters = new Map<string, string>();
testState.mediaInfoByLink = {};
testState.lastRepliesComment = undefined;
testState.replies = [];
@@ -253,7 +238,6 @@ describe('CatalogRow', () => {
it('renders gif frames with matched filter borders and falls back to deleted media on load errors', async () => {
testState.gifFrameStatus = 'ready';
testState.gifFrameUrl = 'https://cdn.example/frame.png';
testState.matchedFilters = new Map([['post-1', 'red']]);
await act(async () => {
root.render(
@@ -262,6 +246,7 @@ describe('CatalogRow', () => {
commentMediaInfo: { type: 'gif', url: 'https://example.com/source.gif' },
linkHeight: 200,
linkWidth: 400,
matchedFilterColor: 'red',
}),
);
});
@@ -416,7 +401,7 @@ describe('CatalogRow', () => {
expect(container.textContent).toContain('/ I: 3');
expect(container.textContent).not.toContain('/ L: 3');
expect(document.body.querySelector('a[href="/mu/thread/post-alias"]')).toBeTruthy();
expect(container.querySelector('[title=\"(R)eplies / (I)mage Replies\"]')).toBeTruthy();
expect(container.querySelector('[title="(R)eplies / (I)mage Replies"]')).toBeTruthy();
});
it('normalizes legacy board addresses before fetching hover preview replies', async () => {
@@ -495,4 +480,16 @@ describe('CatalogRow', () => {
expect(container.textContent).toContain('(hidden)');
expect(container.textContent).toContain('Text title: Plain thread body');
});
it('applies the estimated row height to the virtualization wrapper', async () => {
const post: TestComment = {
cid: 'estimated-post',
content: 'Estimated row body',
communityAddress: 'music-posting.eth',
};
await renderWithRouter(createElement(CatalogRow, { estimatedHeight: 246, row: [post] }), '/mu/catalog');
expect(container.querySelector('[data-pretext-height="246"]')).toBeTruthy();
});
});
+18 -10
View File
@@ -11,7 +11,6 @@ import { getFormattedTimeAgo } from '../../lib/utils/time-utils';
import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { findDirectoryByAddress, useDirectories } from '../../hooks/use-directories';
import { getBoardPath } from '../../lib/utils/route-utils';
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
import useCatalogStyleStore from '../../stores/use-catalog-style-store';
import useEditCommentPrivileges from '../../hooks/use-author-privileges';
import { useCommentMediaInfo } from '../../hooks/use-comment-media-info';
@@ -32,9 +31,11 @@ interface CatalogPostMediaProps {
isOutOfFeed?: boolean;
linkWidth?: number;
linkHeight?: number;
matchedFilterColor?: string;
}
export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight }: CatalogPostMediaProps) => {
export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight, matchedFilterColor }: CatalogPostMediaProps) => {
void cid;
const { patternThumbnailUrl, thumbnail, type, url } = commentMediaInfo || {};
const iframeThumbnail = patternThumbnailUrl || thumbnail;
const { frameUrl: gifFrameUrl, status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? url : undefined);
@@ -100,8 +101,6 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight
thumbnailComponent = <audio src={url} controls />;
}
const matchedFilterColor = useCatalogFiltersStore((state) => state.matchedFilters.get(cid || ''));
return (
<div
className={hasError ? '' : styles.mediaWrapper}
@@ -118,7 +117,7 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight
// Memoize CatalogPost to prevent rerenders when parent rerenders due to updatingState
const CatalogPost = memo(
({ post }: { post: Comment }) => {
({ matchedFilterColor, post }: { matchedFilterColor?: string; post: Comment }) => {
const { t } = useTranslation();
const resolvedPost = useMemo(() => withResolvedCommentCommunityAddress(post), [post]);
const { author, cid, content, link, linkHeight, linkWidth, locked, pinned, replyCount, spoiler, communityAddress, timestamp, title, thumbnailUrl } =
@@ -260,7 +259,13 @@ const CatalogPost = memo(
{spoiler ? (
<img src='assets/spoiler.png' alt='' />
) : (
<CatalogPostMedia cid={cid} commentMediaInfo={commentMediaInfo} linkWidth={linkWidth} linkHeight={linkHeight} />
<CatalogPostMedia
cid={cid}
commentMediaInfo={commentMediaInfo}
linkWidth={linkWidth}
linkHeight={linkHeight}
matchedFilterColor={matchedFilterColor}
/>
)}
</div>
</Link>
@@ -339,21 +344,24 @@ const CatalogPost = memo(
prev?.thumbnailUrl === next?.thumbnailUrl &&
prev?.linkWidth === next?.linkWidth &&
prev?.linkHeight === next?.linkHeight &&
prevCommunityAddress === nextCommunityAddress
prevCommunityAddress === nextCommunityAddress &&
prevProps.matchedFilterColor === nextProps.matchedFilterColor
);
},
);
interface CatalogRowProps {
estimatedHeight?: number;
index?: number;
matchedFilterColors?: Map<string, string>;
row: Comment[];
}
const CatalogRow = memo(({ row }: CatalogRowProps) => {
const CatalogRow = memo(({ estimatedHeight, matchedFilterColors, row }: CatalogRowProps) => {
return (
<div className={styles.row}>
<div className={styles.row} data-pretext-height={estimatedHeight}>
{row.map((post, index) => (
<CatalogPost key={post?.cid || index} post={post} />
<CatalogPost key={post?.cid || index} matchedFilterColor={matchedFilterColors?.get(post?.cid || '')} post={post} />
))}
</div>
);
+122 -20
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
@@ -45,6 +45,7 @@ import useChallengesStore from '../../stores/use-challenges-store';
import useFeedResetStore from '../../stores/use-feed-reset-store';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import useRegisterFreshReplies from '../../hooks/use-register-fresh-replies';
import useReplyHeightEstimates from '../../hooks/use-reply-height-estimates';
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
import { usePublishCommentModeration } from '@bitsocialnet/bitsocial-react-hooks';
import useQuotedByMap from '../../hooks/use-quoted-by-map';
@@ -63,6 +64,7 @@ import { getThreadTopNavigationState, scrollThreadContainerToTop } from '../../l
import useDeleteFailedPost from '../../hooks/use-delete-failed-post';
import { getThreadPostCountsByAuthor } from '../../lib/utils/author-post-counts';
import { withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { getFeedPostHeightEstimate, getReplyHeightEstimates, reportReplyHeightAuditSample } from '../../lib/utils/pretext-height-estimates';
const { addChallenge } = useChallengesStore.getState();
@@ -714,7 +716,8 @@ const Reply = ({
quotedByMap,
directRepliesByParentCid,
postsByAuthorInThread,
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]>; postsByAuthorInThread?: Map<string, number> }) => {
disableDeferredLayout,
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]>; postsByAuthorInThread?: Map<string, number>; disableDeferredLayout?: boolean }) => {
const accountReply = useSafeAccountComment({
commentIndex: typeof reply?.index === 'number' ? reply.index : undefined,
});
@@ -748,7 +751,7 @@ const Reply = ({
const failedPublishNotice = canDeleteFailedPost ? <FailedPublishNotice isDeleting={isDeletingFailedPost} onDelete={onDeleteFailedPost} /> : undefined;
return (
<div className={styles.replyDesktop}>
<div className={`${styles.replyDesktop} ${disableDeferredLayout ? styles.pretextVirtualizedReply : ''}`}>
<div className={styles.sideArrows}>{'>>'}</div>
<div className={`${styles.reply} ${isRouteLinkToReply && styles.highlight}`} data-cid={cid} data-author-address={author?.shortAddress} data-post-cid={postCid}>
<PostInfo
@@ -787,8 +790,11 @@ const Reply = ({
};
const PostDesktop = ({
feedVirtualizationModeOverride,
post,
roles,
replyPaginationOverride,
replyVirtualizationModeOverride,
showAllReplies,
showReplies = true,
targetReplyCid,
@@ -829,8 +835,9 @@ const PostDesktop = ({
const { showOmittedReplies, setShowOmittedReplies } = useShowOmittedReplies();
const shouldUsePreview = showReplies && !isModQueue && !showAllReplies;
const shouldFetchFull = showReplies && !isModQueue && (showAllReplies || showOmittedReplies[cid]);
const hasReplyPaginationOverride = !!replyPaginationOverride;
const shouldUsePreview = showReplies && !isModQueue && !showAllReplies && !hasReplyPaginationOverride;
const shouldFetchFull = showReplies && !isModQueue && !hasReplyPaginationOverride && (showAllReplies || showOmittedReplies[cid]);
const cachedPreviewRepliesResult = useReplies({
comment: shouldUsePreview ? resolvedPost : undefined,
@@ -866,15 +873,18 @@ const PostDesktop = ({
const livePreviewReplies = (previewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies?.length
? (previewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies!
: previewRepliesResult.replies || [];
const previewReplies = hasEnoughCachedPreview ? cachedPreviewReplies : livePreviewReplies;
const fullReplies = (fullRepliesResult as { updatedReplies?: Comment[] }).updatedReplies?.length
? (fullRepliesResult as { updatedReplies?: Comment[] }).updatedReplies!
: fullRepliesResult.replies || [];
const previewReplies = hasReplyPaginationOverride ? replyPaginationOverride.replies : hasEnoughCachedPreview ? cachedPreviewReplies : livePreviewReplies;
const fullReplies = hasReplyPaginationOverride
? replyPaginationOverride.replies
: (fullRepliesResult as { updatedReplies?: Comment[] }).updatedReplies?.length
? (fullRepliesResult as { updatedReplies?: Comment[] }).updatedReplies!
: fullRepliesResult.replies || [];
const { hasMore, loadMore } = fullRepliesResult;
const reset = (fullRepliesResult as { reset?: () => Promise<void> }).reset;
const hasMore = replyPaginationOverride?.hasMore ?? fullRepliesResult.hasMore;
const loadMore = replyPaginationOverride?.loadMore ?? fullRepliesResult.loadMore;
const reset = replyPaginationOverride?.reset ?? (fullRepliesResult as { reset?: () => Promise<void> }).reset;
const fullIsFetching = shouldFetchFull && fullReplies.length === 0 && fullRepliesResult.hasMore;
const fullIsFetching = shouldFetchFull && !hasReplyPaginationOverride && fullReplies.length === 0 && fullRepliesResult.hasMore;
const repliesForRender = showAllReplies
? fullReplies
@@ -924,9 +934,9 @@ const PostDesktop = ({
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
// Author-deleted replies are hidden from thread replies; moderator removals still render their placeholder.
const filteredReplies = filterRepliesForDisplay(freshRepliesForRender);
const postsByAuthorInThread = getThreadPostCountsByAuthor(resolvedPost, filteredReplies);
const directRepliesByParentCid = (() => {
const filteredReplies = useMemo(() => filterRepliesForDisplay(freshRepliesForRender), [freshRepliesForRender]);
const postsByAuthorInThread = useMemo(() => getThreadPostCountsByAuthor(resolvedPost, filteredReplies), [resolvedPost, filteredReplies]);
const directRepliesByParentCid = useMemo(() => {
const map = new Map<string, Comment[]>();
for (const reply of filteredReplies) {
const directParentCid = reply?.parentCid;
@@ -941,9 +951,92 @@ const PostDesktop = ({
}
}
return map;
})();
}, [filteredReplies]);
const quotedByMap = useQuotedByMap(filteredReplies, communityAddress);
const {
defaultItemHeight: defaultReplyItemHeight,
heightEstimates: replyHeightEstimates,
itemSize: replyItemSize,
metrics,
windowWidth,
} = useReplyHeightEstimates({
directRepliesByParentCid,
enabled: showAllReplies,
isMobile: false,
maxContentChars: showAllReplies ? 2000 : 1000,
mode: replyVirtualizationModeOverride,
quotedByMap,
replies: filteredReplies,
});
const replyVirtualizationProps = replyItemSize ? { itemSize: replyItemSize } : {};
const shouldUseFeedHeightEstimate = !showAllReplies;
const shouldUsePretextFeedHeightEstimate = shouldUseFeedHeightEstimate && feedVirtualizationModeOverride !== 'off';
const previewReplyHeightEstimates = useMemo(
() =>
!shouldUsePretextFeedHeightEstimate || filteredReplies.length === 0
? []
: getReplyHeightEstimates({
context: 'preview',
directRepliesByParentCid,
isMobile: false,
maxContentChars: 1000,
metrics,
quotedByMap,
replies: filteredReplies,
windowWidth,
}),
[directRepliesByParentCid, filteredReplies, metrics, quotedByMap, shouldUsePretextFeedHeightEstimate, windowWidth],
);
const getPreviewReplyDebugProps = useCallback(
(index: number) => {
if (!import.meta.env.DEV || !shouldUsePretextFeedHeightEstimate) {
return {};
}
const reply = filteredReplies[index];
return {
'data-pretext-reply-estimate': previewReplyHeightEstimates[index],
'data-pretext-reply-content-length': reply?.content?.length || 0,
'data-pretext-reply-has-media': reply?.link ? '1' : '0',
'data-pretext-reply-number': reply?.number,
'data-pretext-reply-title-length': reply?.title?.trim().length || 0,
};
},
[filteredReplies, previewReplyHeightEstimates, shouldUsePretextFeedHeightEstimate],
);
const feedHeightEstimate = useMemo(
() =>
!shouldUsePretextFeedHeightEstimate
? undefined
: getFeedPostHeightEstimate({
directRepliesByParentCid,
isMobile: false,
metrics,
post: resolvedPost,
previewReplies: filteredReplies,
previewReplyEstimates: previewReplyHeightEstimates,
quotedByMap,
showBoardLabel: isMultiboardView && Boolean(boardPath),
showSummary: showReplies && repliesCount > 0 && !isInPostPageView,
windowWidth,
}),
[
directRepliesByParentCid,
filteredReplies,
isInPostPageView,
metrics,
boardPath,
previewReplyHeightEstimates,
quotedByMap,
repliesCount,
resolvedPost,
shouldUsePretextFeedHeightEstimate,
showReplies,
isMultiboardView,
windowWidth,
],
);
const visibleReplies = useProgressiveRender(filteredReplies, {
batchSize: 50,
@@ -986,7 +1079,7 @@ const PostDesktop = ({
const virtuosoFooter = useCallback(() => <RepliesFooter hasMore={hasMore} loadingString={t('loading')} />, [hasMore, t]);
return (
<div className={styles.postDesktop}>
<div className={styles.postDesktop} data-pretext-height={shouldUsePretextFeedHeightEstimate ? feedHeightEstimate : undefined}>
{showReplies || isModQueue ? (
<div className={styles.hrWrapper}>
<hr />
@@ -1098,12 +1191,20 @@ const PostDesktop = ({
{/* Virtuoso infinite scroll for post page view when there's more content to paginate */}
{!isHidden && showAllReplies && !isInPendingPostView && showReplies && hasMore && !!resolvedPost?.replyCount && (
<Virtuoso
defaultItemHeight={defaultReplyItemHeight}
heightEstimates={replyHeightEstimates}
{...replyVirtualizationProps}
increaseViewportBy={{ bottom: 1200, top: 1200 }}
totalCount={filteredReplies.length}
data={filteredReplies}
itemContent={(index, reply) => (
<div className={styles.replyContainer}>
<div
className={styles.replyContainer}
data-pretext-height={replyHeightEstimates?.[index]}
ref={(element) => reportReplyHeightAuditSample(element, replyHeightEstimates?.[index], reply.cid)}
>
<Reply
disableDeferredLayout={Boolean(replyItemSize)}
reply={reply}
roles={roles}
postReplyCount={replyCount}
@@ -1147,9 +1248,10 @@ const PostDesktop = ({
!isInPendingPostView &&
freshRepliesForRender &&
showReplies &&
filteredReplies.map((reply) => (
<div key={reply.cid} className={styles.replyContainer}>
filteredReplies.map((reply, index) => (
<div key={reply.cid} className={styles.replyContainer} {...getPreviewReplyDebugProps(index)}>
<Reply
disableDeferredLayout={feedVirtualizationModeOverride === 'item-size'}
reply={reply}
roles={roles}
postReplyCount={replyCount}
+125 -27
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
@@ -43,6 +43,7 @@ import useRegisterFreshReplies from '../../hooks/use-register-fresh-replies';
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
import useQuotedByMap from '../../hooks/use-quoted-by-map';
import useProgressiveRender from '../../hooks/use-progressive-render';
import useReplyHeightEstimates from '../../hooks/use-reply-height-estimates';
import useFreshReplies from '../../hooks/use-fresh-replies';
import { BOARD_REPLIES_PREVIEW_FETCH_SIZE, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT, REPLIES_PER_PAGE } from '../../lib/constants';
import { isCommentArchived } from '../../lib/utils/comment-moderation-utils';
@@ -52,6 +53,7 @@ import { getThreadTopNavigationState, scrollThreadContainerToTop } from '../../l
import useDeleteFailedPost from '../../hooks/use-delete-failed-post';
import { getThreadPostCountsByAuthor } from '../../lib/utils/author-post-counts';
import { withResolvedCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { getFeedPostHeightEstimate, getReplyHeightEstimates, reportReplyHeightAuditSample } from '../../lib/utils/pretext-height-estimates';
const { addChallenge } = useChallengesStore.getState();
@@ -496,7 +498,8 @@ const Reply = ({
quotedByMap,
directRepliesByParentCid,
postsByAuthorInThread,
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]>; postsByAuthorInThread?: Map<string, number> }) => {
disableDeferredLayout,
}: PostProps & { directRepliesByParentCid?: Map<string, Comment[]>; postsByAuthorInThread?: Map<string, number>; disableDeferredLayout?: boolean }) => {
const accountReply = useSafeAccountComment({
commentIndex: typeof reply?.index === 'number' ? reply.index : undefined,
});
@@ -520,7 +523,7 @@ const Reply = ({
const failedPublishNotice = canDeleteFailedPost ? <FailedPublishNotice isDeleting={isDeletingFailedPost} onDelete={onDeleteFailedPost} /> : undefined;
return (
<div className={styles.replyMobile}>
<div className={`${styles.replyMobile} ${disableDeferredLayout ? styles.pretextVirtualizedReply : ''}`}>
<div className={styles.reply}>
<div
className={`${styles.replyContainer} ${isRouteLinkToReply && styles.highlight}`}
@@ -540,8 +543,11 @@ const Reply = ({
};
const PostMobile = ({
feedVirtualizationModeOverride,
post,
roles,
replyPaginationOverride,
replyVirtualizationModeOverride,
showAllReplies,
showReplies = true,
targetReplyCid,
@@ -565,7 +571,8 @@ const PostMobile = ({
const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true;
const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined;
const linksCount = useCountLinksInReplies(resolvedPost);
const shouldFetchReplies = showReplies && !isModQueue;
const hasReplyPaginationOverride = !!replyPaginationOverride;
const shouldFetchReplies = showReplies && !isModQueue && !hasReplyPaginationOverride;
const shouldUsePreview = shouldFetchReplies && !showAllReplies;
const cachedPreviewRepliesResult = useReplies({
comment: shouldUsePreview ? resolvedPost : undefined,
@@ -601,8 +608,18 @@ const PostMobile = ({
const livePreviewReplies = (previewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies?.length
? (previewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies!
: previewRepliesResult.replies || [];
const previewReplies = hasEnoughCachedPreview ? cachedPreviewReplies : livePreviewReplies;
const repliesResult = showAllReplies ? fullRepliesResult : { ...previewRepliesResult, replies: previewReplies, updatedReplies: previewReplies };
const previewReplies = hasReplyPaginationOverride ? replyPaginationOverride.replies : hasEnoughCachedPreview ? cachedPreviewReplies : livePreviewReplies;
const repliesResult = hasReplyPaginationOverride
? {
hasMore: replyPaginationOverride.hasMore ?? false,
loadMore: replyPaginationOverride.loadMore ?? (() => {}),
replies: replyPaginationOverride.replies,
reset: replyPaginationOverride.reset,
updatedReplies: replyPaginationOverride.replies,
}
: showAllReplies
? fullRepliesResult
: { ...previewRepliesResult, replies: previewReplies, updatedReplies: previewReplies };
const { replies, hasMore, loadMore } = repliesResult;
const updatedReplies = (repliesResult as { updatedReplies?: Comment[] }).updatedReplies;
const repliesForRender = updatedReplies?.length ? updatedReplies : replies || [];
@@ -635,11 +652,11 @@ const PostMobile = ({
const failedPublishNotice = canDeleteFailedPost ? <FailedPublishNotice isDeleting={isDeletingFailedPost} onDelete={onDeleteFailedPost} /> : undefined;
// Author-deleted replies are hidden from thread replies; moderator removals still render their placeholder.
const filteredReplies = filterRepliesForDisplay(freshRepliesForRender);
const postsByAuthorInThread = getThreadPostCountsByAuthor(resolvedPost, filteredReplies);
const previewDisplayReplies = getPreviewDisplayReplies(filteredReplies, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT);
const filteredReplies = useMemo(() => filterRepliesForDisplay(freshRepliesForRender), [freshRepliesForRender]);
const postsByAuthorInThread = useMemo(() => getThreadPostCountsByAuthor(resolvedPost, filteredReplies), [resolvedPost, filteredReplies]);
const previewDisplayReplies = useMemo(() => getPreviewDisplayReplies(filteredReplies, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT), [filteredReplies]);
const directRepliesByParentCid = (() => {
const directRepliesByParentCid = useMemo(() => {
const map = new Map<string, Comment[]>();
for (const reply of filteredReplies) {
const directParentCid = reply?.parentCid;
@@ -652,9 +669,62 @@ const PostMobile = ({
}
}
return map;
})();
}, [filteredReplies]);
const quotedByMap = useQuotedByMap(filteredReplies, communityAddress);
const {
defaultItemHeight: defaultReplyItemHeight,
heightEstimates: replyHeightEstimates,
itemSize: replyItemSize,
metrics,
windowWidth,
} = useReplyHeightEstimates({
directRepliesByParentCid,
enabled: showAllReplies,
isMobile: true,
maxContentChars: showAllReplies ? 2000 : 1000,
mode: replyVirtualizationModeOverride,
quotedByMap,
replies: filteredReplies,
});
const replyVirtualizationProps = replyItemSize ? { itemSize: replyItemSize } : {};
const shouldUseFeedHeightEstimate = !showAllReplies && feedVirtualizationModeOverride !== 'off';
const previewReplyHeightEstimates = useMemo(
() =>
!shouldUseFeedHeightEstimate || previewDisplayReplies.length === 0
? []
: getReplyHeightEstimates({
context: 'preview',
directRepliesByParentCid,
isMobile: true,
maxContentChars: 1000,
metrics,
quotedByMap,
replies: previewDisplayReplies,
windowWidth,
}),
[directRepliesByParentCid, metrics, previewDisplayReplies, quotedByMap, shouldUseFeedHeightEstimate, windowWidth],
);
const getPreviewReplyDebugProps = useCallback(
(index: number) => (import.meta.env.DEV && shouldUseFeedHeightEstimate ? { 'data-pretext-reply-estimate': previewReplyHeightEstimates[index] } : {}),
[previewReplyHeightEstimates, shouldUseFeedHeightEstimate],
);
const feedHeightEstimate = useMemo(
() =>
!shouldUseFeedHeightEstimate
? undefined
: getFeedPostHeightEstimate({
directRepliesByParentCid,
isMobile: true,
metrics,
post: resolvedPost,
previewReplies: previewDisplayReplies,
previewReplyEstimates: previewReplyHeightEstimates,
quotedByMap,
windowWidth,
}),
[directRepliesByParentCid, metrics, previewDisplayReplies, previewReplyHeightEstimates, quotedByMap, resolvedPost, shouldUseFeedHeightEstimate, windowWidth],
);
const visibleReplies = useProgressiveRender(filteredReplies, {
batchSize: 50,
@@ -719,7 +789,7 @@ const PostMobile = ({
</span>
</>
) : (
<div className={styles.postMobile}>
<div className={styles.postMobile} data-pretext-height={shouldUseFeedHeightEstimate ? feedHeightEstimate : undefined}>
{(showReplies || isModQueue) && (
<div className={styles.hrWrapper}>
<hr />
@@ -796,22 +866,49 @@ const PostMobile = ({
{/* Virtuoso infinite scroll for post page view when there's more content to paginate */}
{showAllReplies && !isInPendingPostView && showReplies && hasMore && !!resolvedPost?.replyCount && (
<Virtuoso
defaultItemHeight={defaultReplyItemHeight}
heightEstimates={replyHeightEstimates}
{...replyVirtualizationProps}
increaseViewportBy={{ bottom: 1200, top: 1200 }}
totalCount={filteredReplies.length}
data={filteredReplies}
itemContent={(index, reply) => (
<div className={styles.replyContainer}>
<Reply
postReplyCount={replyCount}
reply={reply}
postsByAuthorInThread={postsByAuthorInThread}
roles={roles}
threadNumber={resolvedPost?.number}
quotedByMap={quotedByMap}
directRepliesByParentCid={directRepliesByParentCid}
/>
</div>
)}
itemContent={(index, reply) => {
const renderableBacklinks = import.meta.env.DEV
? getRenderableMobileBacklinks({
cid: reply.cid,
directRepliesByParentCid,
parentCid: reply.parentCid,
quotedByMap,
})
: undefined;
const backlinkCount = renderableBacklinks
? renderableBacklinks.directReplyBacklinks.length + renderableBacklinks.opBacklinks.length + renderableBacklinks.quotedReplyBacklinks.length
: undefined;
return (
<div
className={styles.replyContainer}
data-pretext-height={replyHeightEstimates?.[index]}
data-pretext-reply-backlink-count={backlinkCount}
data-pretext-reply-content-length={import.meta.env.DEV ? reply.content?.length || 0 : undefined}
data-pretext-reply-has-media={import.meta.env.DEV ? (reply.link ? '1' : '0') : undefined}
data-pretext-reply-number={import.meta.env.DEV ? reply.number : undefined}
data-pretext-reply-title-length={import.meta.env.DEV ? reply.title?.trim().length || 0 : undefined}
ref={(element) => reportReplyHeightAuditSample(element, replyHeightEstimates?.[index], reply.cid)}
>
<Reply
disableDeferredLayout={Boolean(replyItemSize)}
postReplyCount={replyCount}
reply={reply}
postsByAuthorInThread={postsByAuthorInThread}
roles={roles}
threadNumber={resolvedPost?.number}
quotedByMap={quotedByMap}
directRepliesByParentCid={directRepliesByParentCid}
/>
</div>
);
}}
useWindowScroll={true}
components={{ Footer: virtuosoFooter }}
endReached={loadMore}
@@ -843,9 +940,10 @@ const PostMobile = ({
!isInPendingPostView &&
freshRepliesForRender &&
showReplies &&
previewDisplayReplies.map((reply) => (
<div key={reply.cid} className={styles.replyContainer}>
previewDisplayReplies.map((reply, index) => (
<div key={reply.cid} className={styles.replyContainer} {...getPreviewReplyDebugProps(index)}>
<Reply
disableDeferredLayout={feedVirtualizationModeOverride === 'item-size'}
postReplyCount={replyCount}
reply={reply}
postsByAuthorInThread={postsByAuthorInThread}