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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user