mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
perf(board): reduce desktop reverse-scroll jank on /all
Apply the asymmetric viewport buffer that already fixed mobile to desktop
multiboard feeds, and trim per-post mount cost so remounts during scroll-up
are cheaper:
- Desktop multiboard buffer goes from {600,600} to {1200,2400} (top-heavy),
matching the mobile pattern from 99c0bbfac so items stay mounted longer
when scrolling back up
- Cache feed post height estimate by CID in pretext-height-estimates so
remounts skip the Pretext text-measurement work
- Memoize CommentMedia and switch its expanded-media-store reads to atomic
selectors so it stops rerendering on unrelated store changes
- Memoize useCommentMediaInfo return so its reference is stable for memo
comparators downstream
- Stop useFetchGifFirstFrame from forcing an extra render on every post
mount by bailing out of equivalent setState calls
- Extract PendingModerationActions out of PostInfo so the two
usePublishCommentModeration calls only run on the post page when
mod-approval is actually pending, not on every feed item
This commit is contained in:
@@ -34,12 +34,15 @@ vi.mock('../../../lib/utils/url-utils', () => ({
|
||||
getHostname: () => testState.hostname,
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-expanded-media-store', () => ({
|
||||
default: () => ({
|
||||
vi.mock('../../../stores/use-expanded-media-store', () => {
|
||||
const getState = () => ({
|
||||
fitExpandedImagesToScreen: testState.fitExpandedImagesToScreen,
|
||||
unmuteExpandedVideoSound: testState.unmuteExpandedVideoSound,
|
||||
}),
|
||||
}));
|
||||
});
|
||||
return {
|
||||
default: (selector?: (s: ReturnType<typeof getState>) => unknown) => (selector ? selector(getState()) : getState()),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../hooks/use-fetch-gif-first-frame', () => ({
|
||||
default: () => ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CommentMediaInfo, getDisplayMediaInfoType, getHasThumbnail, getMediaDimensions } from '../../lib/utils/media-utils';
|
||||
import { getHostname } from '../../lib/utils/url-utils';
|
||||
@@ -202,7 +202,8 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
|
||||
const { t } = useTranslation();
|
||||
const { thumbnail, type, url } = commentMediaInfo || {};
|
||||
const isMobile = useIsMobile();
|
||||
const { fitExpandedImagesToScreen, unmuteExpandedVideoSound } = useExpandedMediaStore();
|
||||
const fitExpandedImagesToScreen = useExpandedMediaStore((s) => s.fitExpandedImagesToScreen);
|
||||
const unmuteExpandedVideoSound = useExpandedMediaStore((s) => s.unmuteExpandedVideoSound);
|
||||
const mediaClass = `${isMobile ? styles.mediaMobile : isReply ? styles.mediaDesktopReply : styles.mediaDesktopOp} ${
|
||||
fitExpandedImagesToScreen ? styles.fitToScreen : ''
|
||||
}`;
|
||||
@@ -299,7 +300,7 @@ const Image = ({ commentMediaInfo, disableToggle = false, displayHeight, display
|
||||
const isReply = parentCid;
|
||||
const isMobile = useIsMobile();
|
||||
const [isImageExpanded, setIsImageExpanded] = useState(initialExpanded);
|
||||
const { fitExpandedImagesToScreen } = useExpandedMediaStore();
|
||||
const fitExpandedImagesToScreen = useExpandedMediaStore((s) => s.fitExpandedImagesToScreen);
|
||||
const mediaDimensions = getMediaDimensions(commentMediaInfo);
|
||||
const mediaClass = `${isMobile ? styles.mediaMobile : isReply ? styles.mediaDesktopReply : styles.mediaDesktopOp} ${
|
||||
fitExpandedImagesToScreen ? styles.fitToScreen : ''
|
||||
@@ -501,4 +502,21 @@ const CommentMedia = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default CommentMedia;
|
||||
export default memo(CommentMedia, (prev, next) => {
|
||||
return (
|
||||
prev.commentMediaInfo === next.commentMediaInfo &&
|
||||
prev.deleted === next.deleted &&
|
||||
prev.disableToggle === next.disableToggle &&
|
||||
prev.isFloatingEmbed === next.isFloatingEmbed &&
|
||||
prev.isOutOfFeed === next.isOutOfFeed &&
|
||||
prev.isReply === next.isReply &&
|
||||
prev.linkHeight === next.linkHeight &&
|
||||
prev.linkWidth === next.linkWidth &&
|
||||
prev.parentCid === next.parentCid &&
|
||||
prev.purged === next.purged &&
|
||||
prev.removed === next.removed &&
|
||||
prev.showThumbnail === next.showThumbnail &&
|
||||
prev.setShowThumbnail === next.setShowThumbnail &&
|
||||
prev.spoiler === next.spoiler
|
||||
);
|
||||
});
|
||||
|
||||
@@ -94,6 +94,117 @@ const useShowOmittedReplies = create<ShowOmittedRepliesState>((set) => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string; communityAddress: string; post: Comment | undefined }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
publishCommentModeration: approvePending,
|
||||
state: approvePendingState,
|
||||
error: approvePendingError,
|
||||
} = usePublishCommentModeration({
|
||||
commentCid: cid,
|
||||
communityAddress,
|
||||
commentModeration: approvePendingCommentModeration,
|
||||
onChallenge: async (...args: any) => {
|
||||
addChallenge([...args, post]);
|
||||
},
|
||||
onChallengeVerification: async (challengeVerification, comment) => {
|
||||
alertChallengeVerificationFailed(challengeVerification, comment);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('Approve failed:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
publishCommentModeration: rejectPending,
|
||||
state: rejectPendingState,
|
||||
error: rejectPendingError,
|
||||
} = usePublishCommentModeration({
|
||||
commentCid: cid,
|
||||
communityAddress,
|
||||
commentModeration: rejectPendingCommentModeration,
|
||||
onChallenge: async (...args: any) => {
|
||||
addChallenge([...args, post]);
|
||||
},
|
||||
onChallengeVerification: async (challengeVerification, comment) => {
|
||||
alertChallengeVerificationFailed(challengeVerification, comment);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('Reject failed:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const [initiatedPendingAction, setInitiatedPendingAction] = useState<'approve' | 'reject' | null>(null);
|
||||
const handlePendingApprove = useCallback(async () => {
|
||||
if (!window.confirm(t('double_confirm'))) return;
|
||||
setInitiatedPendingAction('approve');
|
||||
try {
|
||||
await approvePending();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, [approvePending, t]);
|
||||
|
||||
const handlePendingReject = useCallback(async () => {
|
||||
if (!window.confirm(t('double_confirm'))) return;
|
||||
setInitiatedPendingAction('reject');
|
||||
try {
|
||||
await rejectPending();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, [rejectPending, t]);
|
||||
|
||||
const isApprovingPending =
|
||||
initiatedPendingAction === 'approve' && approvePendingState !== 'initializing' && approvePendingState !== 'succeeded' && approvePendingState !== 'failed';
|
||||
const isRejectingPending =
|
||||
initiatedPendingAction === 'reject' && rejectPendingState !== 'initializing' && rejectPendingState !== 'succeeded' && rejectPendingState !== 'failed';
|
||||
const isPublishingPending = isApprovingPending || isRejectingPending;
|
||||
|
||||
const approvePendingSucceeded = initiatedPendingAction === 'approve' && approvePendingState === 'succeeded';
|
||||
const rejectPendingSucceeded = initiatedPendingAction === 'reject' && rejectPendingState === 'succeeded';
|
||||
const approvePendingFailed = initiatedPendingAction === 'approve' && approvePendingState === 'failed';
|
||||
const rejectPendingFailed = initiatedPendingAction === 'reject' && rejectPendingState === 'failed';
|
||||
|
||||
const pendingStatus = approvePendingSucceeded ? 'approved' : rejectPendingSucceeded ? 'rejected' : approvePendingFailed || rejectPendingFailed ? 'failed' : null;
|
||||
const pendingErrorMessage = approvePendingFailed ? approvePendingError?.message : rejectPendingFailed ? rejectPendingError?.message : undefined;
|
||||
|
||||
return (
|
||||
<span className={styles.modQueueActions}>
|
||||
{pendingStatus === 'approved' ? (
|
||||
<span className={styles.modQueueStatusApproved}>{t('approved')}</span>
|
||||
) : pendingStatus === 'rejected' ? (
|
||||
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
|
||||
) : pendingStatus === 'failed' ? (
|
||||
<span className={styles.modQueueStatusRejected}>
|
||||
{t('failed')}
|
||||
{pendingErrorMessage ? `: ${pendingErrorMessage}` : ''}
|
||||
</span>
|
||||
) : isPublishingPending ? (
|
||||
<LoadingEllipsis string={t('publishing')} />
|
||||
) : (
|
||||
<>
|
||||
<span className={styles.modQueueButtonWrapper}>
|
||||
[
|
||||
<button className={styles.modQueueActionButton} onClick={handlePendingApprove} disabled={isPublishingPending}>
|
||||
{t('approve')}
|
||||
</button>
|
||||
]
|
||||
</span>
|
||||
<span className={styles.modQueueButtonWrapper}>
|
||||
[
|
||||
<button className={styles.modQueueActionButton} onClick={handlePendingReject} disabled={isPublishingPending}>
|
||||
{t('reject')}
|
||||
</button>
|
||||
]
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const PostInfo = ({
|
||||
post,
|
||||
postReplyCount = 0,
|
||||
@@ -142,86 +253,6 @@ const PostInfo = ({
|
||||
const pendingApproval = post?.pendingApproval;
|
||||
const shouldShowPendingApprovalButtons = isInPostPageView && !isInModQueueView && pendingApproval && isAccountMod && communityAddress;
|
||||
|
||||
// Moderation actions for pending approval posts
|
||||
const {
|
||||
publishCommentModeration: approvePending,
|
||||
state: approvePendingState,
|
||||
error: approvePendingError,
|
||||
} = usePublishCommentModeration({
|
||||
commentCid: cid,
|
||||
communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined,
|
||||
commentModeration: approvePendingCommentModeration,
|
||||
onChallenge: async (...args: any) => {
|
||||
addChallenge([...args, post]);
|
||||
},
|
||||
onChallengeVerification: async (challengeVerification, comment) => {
|
||||
alertChallengeVerificationFailed(challengeVerification, comment);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('Approve failed:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
publishCommentModeration: rejectPending,
|
||||
state: rejectPendingState,
|
||||
error: rejectPendingError,
|
||||
} = usePublishCommentModeration({
|
||||
commentCid: cid,
|
||||
communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined,
|
||||
commentModeration: rejectPendingCommentModeration,
|
||||
onChallenge: async (...args: any) => {
|
||||
addChallenge([...args, post]);
|
||||
},
|
||||
onChallengeVerification: async (challengeVerification, comment) => {
|
||||
alertChallengeVerificationFailed(challengeVerification, comment);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
console.error('Reject failed:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const [initiatedPendingAction, setInitiatedPendingAction] = useState<'approve' | 'reject' | null>(null);
|
||||
const handlePendingApprove = useCallback(async () => {
|
||||
const confirm = window.confirm(t('double_confirm'));
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
setInitiatedPendingAction('approve');
|
||||
try {
|
||||
await approvePending();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, [approvePending, t]);
|
||||
|
||||
const handlePendingReject = useCallback(async () => {
|
||||
const confirm = window.confirm(t('double_confirm'));
|
||||
if (!confirm) {
|
||||
return;
|
||||
}
|
||||
setInitiatedPendingAction('reject');
|
||||
try {
|
||||
await rejectPending();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, [rejectPending, t]);
|
||||
|
||||
const isApprovingPending =
|
||||
initiatedPendingAction === 'approve' && approvePendingState !== 'initializing' && approvePendingState !== 'succeeded' && approvePendingState !== 'failed';
|
||||
const isRejectingPending =
|
||||
initiatedPendingAction === 'reject' && rejectPendingState !== 'initializing' && rejectPendingState !== 'succeeded' && rejectPendingState !== 'failed';
|
||||
const isPublishingPending = isApprovingPending || isRejectingPending;
|
||||
|
||||
const approvePendingSucceeded = initiatedPendingAction === 'approve' && approvePendingState === 'succeeded';
|
||||
const rejectPendingSucceeded = initiatedPendingAction === 'reject' && rejectPendingState === 'succeeded';
|
||||
const approvePendingFailed = initiatedPendingAction === 'approve' && approvePendingState === 'failed';
|
||||
const rejectPendingFailed = initiatedPendingAction === 'reject' && rejectPendingState === 'failed';
|
||||
|
||||
const pendingStatus = approvePendingSucceeded ? 'approved' : rejectPendingSucceeded ? 'rejected' : approvePendingFailed || rejectPendingFailed ? 'failed' : null;
|
||||
const pendingErrorMessage = approvePendingFailed ? approvePendingError?.message : rejectPendingFailed ? rejectPendingError?.message : undefined;
|
||||
|
||||
// Check if post is awaiting approval and over threshold (for mod queue view)
|
||||
const approved = post?.approved;
|
||||
const alreadyApproved = approved === true;
|
||||
@@ -473,39 +504,7 @@ const PostInfo = ({
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{shouldShowPendingApprovalButtons && (
|
||||
<span className={styles.modQueueActions}>
|
||||
{pendingStatus === 'approved' ? (
|
||||
<span className={styles.modQueueStatusApproved}>{t('approved')}</span>
|
||||
) : pendingStatus === 'rejected' ? (
|
||||
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
|
||||
) : pendingStatus === 'failed' ? (
|
||||
<span className={styles.modQueueStatusRejected}>
|
||||
{t('failed')}
|
||||
{pendingErrorMessage ? `: ${pendingErrorMessage}` : ''}
|
||||
</span>
|
||||
) : isPublishingPending ? (
|
||||
<LoadingEllipsis string={t('publishing')} />
|
||||
) : (
|
||||
<>
|
||||
<span className={styles.modQueueButtonWrapper}>
|
||||
[
|
||||
<button className={styles.modQueueActionButton} onClick={handlePendingApprove} disabled={isPublishingPending}>
|
||||
{t('approve')}
|
||||
</button>
|
||||
]
|
||||
</span>
|
||||
<span className={styles.modQueueButtonWrapper}>
|
||||
[
|
||||
<button className={styles.modQueueActionButton} onClick={handlePendingReject} disabled={isPublishingPending}>
|
||||
{t('reject')}
|
||||
</button>
|
||||
]
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{shouldShowPendingApprovalButtons && communityAddress && cid && <PendingModerationActions cid={cid} communityAddress={communityAddress} post={post} />}
|
||||
</span>
|
||||
{!(removed || deleted || purged) && !isModQueue && <PostMenuDesktop postMenu={postMenuProps} />}
|
||||
{cid && parentCid && <ReplyBacklinks post={post} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} />}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useMemo, useState, useEffect } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
import { getCommentMediaInfo, fetchWebpageThumbnailIfNeeded, CommentMediaInfo } from '../lib/utils/media-utils';
|
||||
import { isPendingPostView, isPostPageView } from '../lib/utils/view-utils';
|
||||
@@ -65,16 +65,15 @@ export const useCommentMediaInfo = (link: string, thumbnailUrl: string, linkWidt
|
||||
};
|
||||
}, [link, thumbnailUrl, linkWidth, linkHeight, isInPostPageView, isInPendingPostView]);
|
||||
|
||||
const mediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
||||
|
||||
// Return media info with cached thumbnail dimensions if available
|
||||
if (thumbnailDimensions && mediaInfo) {
|
||||
return {
|
||||
...mediaInfo,
|
||||
thumbnailWidth: thumbnailDimensions.width,
|
||||
thumbnailHeight: thumbnailDimensions.height,
|
||||
};
|
||||
}
|
||||
|
||||
return mediaInfo;
|
||||
return useMemo(() => {
|
||||
const mediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
||||
if (thumbnailDimensions && mediaInfo) {
|
||||
return {
|
||||
...mediaInfo,
|
||||
thumbnailWidth: thumbnailDimensions.width,
|
||||
thumbnailHeight: thumbnailDimensions.height,
|
||||
};
|
||||
}
|
||||
return mediaInfo;
|
||||
}, [link, thumbnailUrl, linkWidth, linkHeight, thumbnailDimensions]);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,10 @@ interface GifFirstFrameState {
|
||||
status: GifFirstFrameStatus;
|
||||
}
|
||||
|
||||
const IDLE_STATE: GifFirstFrameState = { frameUrl: null, status: 'idle' };
|
||||
const LOADING_STATE: GifFirstFrameState = { frameUrl: null, status: 'loading' };
|
||||
const FAILED_STATE: GifFirstFrameState = { frameUrl: null, status: 'failed' };
|
||||
|
||||
const getCachedGifFrame = async (url: string): Promise<string | null> => {
|
||||
return await gifFrameDb.getItem(url);
|
||||
};
|
||||
@@ -79,20 +83,20 @@ const parseGif = async (buf: ArrayBuffer): Promise<Blob> => {
|
||||
};
|
||||
|
||||
const useFetchGifFirstFrame = (url: string | undefined) => {
|
||||
const [gifFirstFrame, setGifFirstFrame] = useState<GifFirstFrameState>({ frameUrl: null, status: 'idle' });
|
||||
const [gifFirstFrame, setGifFirstFrame] = useState<GifFirstFrameState>(IDLE_STATE);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) {
|
||||
setGifFirstFrame({ frameUrl: null, status: 'idle' });
|
||||
setGifFirstFrame((prev) => (prev.status === 'idle' ? prev : IDLE_STATE));
|
||||
return;
|
||||
}
|
||||
|
||||
let isActive = true;
|
||||
setGifFirstFrame({ frameUrl: null, status: 'loading' });
|
||||
setGifFirstFrame((prev) => (prev.status === 'loading' ? prev : LOADING_STATE));
|
||||
|
||||
const fetchFrame = async () => {
|
||||
if (failedUrls.has(url)) {
|
||||
if (isActive) setGifFirstFrame({ frameUrl: null, status: 'failed' });
|
||||
if (isActive) setGifFirstFrame((prev) => (prev.status === 'failed' ? prev : FAILED_STATE));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -119,7 +123,7 @@ const useFetchGifFirstFrame = (url: string | undefined) => {
|
||||
} catch (error) {
|
||||
failedUrls.add(url);
|
||||
console.error('Failed to load GIF frame:', error);
|
||||
if (isActive) setGifFirstFrame({ frameUrl: null, status: 'failed' });
|
||||
if (isActive) setGifFirstFrame((prev) => (prev.status === 'failed' ? prev : FAILED_STATE));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -139,6 +139,8 @@ const paragraphFloatHeightCache = new WeakMap<PreparedTextWithSegments, Map<stri
|
||||
const nestedPretextElementCache = new WeakMap<HTMLElement, HTMLElement | null>();
|
||||
const catalogPostHeightEstimateCache = new Map<string, number>();
|
||||
const catalogRowHeightEstimateCache = new Map<string, number>();
|
||||
const feedPostHeightEstimateCache = new Map<string, number>();
|
||||
const FEED_POST_HEIGHT_ESTIMATE_CACHE_LIMIT = 2000;
|
||||
|
||||
let pretextSupport: boolean | undefined;
|
||||
|
||||
@@ -646,6 +648,30 @@ export const getFeedPostHeightEstimate = ({
|
||||
const previewRepliesHeight = previewHeights.reduce((sum, value) => sum + value, 0);
|
||||
const previewReplyCount = previewReplies.length;
|
||||
|
||||
const cacheKey = post.cid
|
||||
? [
|
||||
isMobile ? '1' : '0',
|
||||
windowWidth,
|
||||
showBoardLabel ? '1' : '0',
|
||||
showSummary ? '1' : '0',
|
||||
metrics.bodyFontFamily,
|
||||
metrics.bodyFontSizePx,
|
||||
metrics.mobileContentFontSizePx,
|
||||
metrics.abbrFontSizePx,
|
||||
post.cid,
|
||||
post.updatedAt || 0,
|
||||
previewReplyCount,
|
||||
previewRepliesHeight,
|
||||
].join('\u0000')
|
||||
: undefined;
|
||||
|
||||
if (cacheKey) {
|
||||
const cached = feedPostHeightEstimateCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
const fontSizePx = metrics.mobileContentFontSizePx;
|
||||
const font = `${fontSizePx}px ${metrics.bodyFontFamily}`;
|
||||
@@ -666,7 +692,15 @@ export const getFeedPostHeightEstimate = ({
|
||||
// Mobile board cards render preview replies more compactly than the thread-reply estimator assumes.
|
||||
const mobileCalibration = getMobileFeedCardCalibration(previewReplyCount);
|
||||
|
||||
return clampEstimateHeight(rawEstimate - mobileCalibration);
|
||||
const mobileResult = clampEstimateHeight(rawEstimate - mobileCalibration);
|
||||
if (cacheKey) {
|
||||
if (feedPostHeightEstimateCache.size >= FEED_POST_HEIGHT_ESTIMATE_CACHE_LIMIT) {
|
||||
const firstKey = feedPostHeightEstimateCache.keys().next().value;
|
||||
if (firstKey !== undefined) feedPostHeightEstimateCache.delete(firstKey);
|
||||
}
|
||||
feedPostHeightEstimateCache.set(cacheKey, mobileResult);
|
||||
}
|
||||
return mobileResult;
|
||||
}
|
||||
|
||||
const fontSizePx = metrics.bodyFontSizePx;
|
||||
@@ -698,7 +732,15 @@ export const getFeedPostHeightEstimate = ({
|
||||
(showSummary ? DESKTOP_FEED_CARD_SUMMARY_HEIGHT : 0) +
|
||||
previewRepliesHeight;
|
||||
|
||||
return clampEstimateHeight(rawEstimate + DESKTOP_FEED_CARD_BASE_CALIBRATION);
|
||||
const desktopResult = clampEstimateHeight(rawEstimate + DESKTOP_FEED_CARD_BASE_CALIBRATION);
|
||||
if (cacheKey) {
|
||||
if (feedPostHeightEstimateCache.size >= FEED_POST_HEIGHT_ESTIMATE_CACHE_LIMIT) {
|
||||
const firstKey = feedPostHeightEstimateCache.keys().next().value;
|
||||
if (firstKey !== undefined) feedPostHeightEstimateCache.delete(firstKey);
|
||||
}
|
||||
feedPostHeightEstimateCache.set(cacheKey, desktopResult);
|
||||
}
|
||||
return desktopResult;
|
||||
};
|
||||
|
||||
export const getCatalogPostHeightEstimate = ({ imageSize, metrics, post, showOPComment }: CatalogPostHeightEstimateOptions): number => {
|
||||
|
||||
@@ -432,6 +432,27 @@ describe('Board', () => {
|
||||
expect(testState.lastVirtuosoMinOverscanItemCount).toEqual({ top: 8, bottom: 4 });
|
||||
});
|
||||
|
||||
it('uses an asymmetric reverse-scroll buffer on desktop multiboard feeds', async () => {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
value: 1280,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
testState.feed = [
|
||||
{ cid: 'first-post', communityAddress: 'music-posting.eth' },
|
||||
{ cid: 'second-post', communityAddress: 'music-posting.eth' },
|
||||
];
|
||||
|
||||
await renderBoard({
|
||||
boardProps: { viewType: 'all' },
|
||||
initialEntry: '/all',
|
||||
routePath: '/all/*',
|
||||
});
|
||||
|
||||
expect(testState.lastVirtuosoIncreaseViewportBy).toEqual({ top: 2400, bottom: 1200 });
|
||||
});
|
||||
|
||||
it('canonicalizes multiboard paths and shows the subscriptions empty state', async () => {
|
||||
testState.account = { subscriptions: [] };
|
||||
testState.filteredDirectoryAddresses = [];
|
||||
|
||||
@@ -402,7 +402,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
|
||||
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
||||
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${BOARD_SORT_TYPE}` : `${location.pathname}-${BOARD_SORT_TYPE}`;
|
||||
const navigationType = useNavigationType();
|
||||
const boardViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 1400, top: 2400 } : { bottom: 600, top: 600 }) : { bottom: 1200, top: 1200 };
|
||||
const boardViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 1400, top: 2400 } : { bottom: 1200, top: 2400 }) : { bottom: 1200, top: 1200 };
|
||||
const boardMinOverscanItemCount = isMultiboardView && isMobile ? { bottom: 4, top: 8 } : undefined;
|
||||
|
||||
const boardItemContent = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user