diff --git a/src/components/comment-media/__tests__/comment-media.test.tsx b/src/components/comment-media/__tests__/comment-media.test.tsx index 32342985..132e7339 100644 --- a/src/components/comment-media/__tests__/comment-media.test.tsx +++ b/src/components/comment-media/__tests__/comment-media.test.tsx @@ -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) => unknown) => (selector ? selector(getState()) : getState()), + }; +}); vi.mock('../../../hooks/use-fetch-gif-first-frame', () => ({ default: () => ({ diff --git a/src/components/comment-media/comment-media.tsx b/src/components/comment-media/comment-media.tsx index de7cc811..8012b6b4 100644 --- a/src/components/comment-media/comment-media.tsx +++ b/src/components/comment-media/comment-media.tsx @@ -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 + ); +}); diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index c8b85bf4..661db90f 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -94,6 +94,117 @@ const useShowOmittedReplies = create((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 ( + + {pendingStatus === 'approved' ? ( + {t('approved')} + ) : pendingStatus === 'rejected' ? ( + {t('rejected')} + ) : pendingStatus === 'failed' ? ( + + {t('failed')} + {pendingErrorMessage ? `: ${pendingErrorMessage}` : ''} + + ) : isPublishingPending ? ( + + ) : ( + <> + + [ + + ] + + + [ + + ] + + + )} + + ); +}; + 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 = ({ )} )} - {shouldShowPendingApprovalButtons && ( - - {pendingStatus === 'approved' ? ( - {t('approved')} - ) : pendingStatus === 'rejected' ? ( - {t('rejected')} - ) : pendingStatus === 'failed' ? ( - - {t('failed')} - {pendingErrorMessage ? `: ${pendingErrorMessage}` : ''} - - ) : isPublishingPending ? ( - - ) : ( - <> - - [ - - ] - - - [ - - ] - - - )} - - )} + {shouldShowPendingApprovalButtons && communityAddress && cid && } {!(removed || deleted || purged) && !isModQueue && } {cid && parentCid && } diff --git a/src/hooks/use-comment-media-info.ts b/src/hooks/use-comment-media-info.ts index 0646aa5e..94a9da76 100644 --- a/src/hooks/use-comment-media-info.ts +++ b/src/hooks/use-comment-media-info.ts @@ -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]); }; diff --git a/src/hooks/use-fetch-gif-first-frame.ts b/src/hooks/use-fetch-gif-first-frame.ts index 8e369645..ab236307 100644 --- a/src/hooks/use-fetch-gif-first-frame.ts +++ b/src/hooks/use-fetch-gif-first-frame.ts @@ -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 => { return await gifFrameDb.getItem(url); }; @@ -79,20 +83,20 @@ const parseGif = async (buf: ArrayBuffer): Promise => { }; const useFetchGifFirstFrame = (url: string | undefined) => { - const [gifFirstFrame, setGifFirstFrame] = useState({ frameUrl: null, status: 'idle' }); + const [gifFirstFrame, setGifFirstFrame] = useState(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)); } }; diff --git a/src/lib/utils/pretext-height-estimates.ts b/src/lib/utils/pretext-height-estimates.ts index 0b4aa0d7..c12b2ed1 100644 --- a/src/lib/utils/pretext-height-estimates.ts +++ b/src/lib/utils/pretext-height-estimates.ts @@ -139,6 +139,8 @@ const paragraphFloatHeightCache = new WeakMap(); const catalogPostHeightEstimateCache = new Map(); const catalogRowHeightEstimateCache = new Map(); +const feedPostHeightEstimateCache = new Map(); +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 => { diff --git a/src/views/board/__tests__/board.test.tsx b/src/views/board/__tests__/board.test.tsx index a5aaf657..4e6f8795 100644 --- a/src/views/board/__tests__/board.test.tsx +++ b/src/views/board/__tests__/board.test.tsx @@ -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 = []; diff --git a/src/views/board/board.tsx b/src/views/board/board.tsx index 38e2337d..cbb17934 100644 --- a/src/views/board/board.tsx +++ b/src/views/board/board.tsx @@ -402,7 +402,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i const virtuosoRef = useRef(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(