mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Merge branch 'claude/wizardly-jemison-9f409f'
This commit is contained in:
@@ -34,12 +34,15 @@ vi.mock('../../../lib/utils/url-utils', () => ({
|
|||||||
getHostname: () => testState.hostname,
|
getHostname: () => testState.hostname,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../stores/use-expanded-media-store', () => ({
|
vi.mock('../../../stores/use-expanded-media-store', () => {
|
||||||
default: () => ({
|
const getState = () => ({
|
||||||
fitExpandedImagesToScreen: testState.fitExpandedImagesToScreen,
|
fitExpandedImagesToScreen: testState.fitExpandedImagesToScreen,
|
||||||
unmuteExpandedVideoSound: testState.unmuteExpandedVideoSound,
|
unmuteExpandedVideoSound: testState.unmuteExpandedVideoSound,
|
||||||
}),
|
});
|
||||||
}));
|
return {
|
||||||
|
default: (selector?: (s: ReturnType<typeof getState>) => unknown) => (selector ? selector(getState()) : getState()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../../hooks/use-fetch-gif-first-frame', () => ({
|
vi.mock('../../../hooks/use-fetch-gif-first-frame', () => ({
|
||||||
default: () => ({
|
default: () => ({
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { memo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CommentMediaInfo, getDisplayMediaInfoType, getHasThumbnail, getMediaDimensions } from '../../lib/utils/media-utils';
|
import { CommentMediaInfo, getDisplayMediaInfoType, getHasThumbnail, getMediaDimensions } from '../../lib/utils/media-utils';
|
||||||
import { getHostname } from '../../lib/utils/url-utils';
|
import { getHostname } from '../../lib/utils/url-utils';
|
||||||
@@ -202,7 +202,8 @@ const Media = ({ commentMediaInfo, disableToggle, isReply, setShowThumbnail }: M
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { thumbnail, type, url } = commentMediaInfo || {};
|
const { thumbnail, type, url } = commentMediaInfo || {};
|
||||||
const isMobile = useIsMobile();
|
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} ${
|
const mediaClass = `${isMobile ? styles.mediaMobile : isReply ? styles.mediaDesktopReply : styles.mediaDesktopOp} ${
|
||||||
fitExpandedImagesToScreen ? styles.fitToScreen : ''
|
fitExpandedImagesToScreen ? styles.fitToScreen : ''
|
||||||
}`;
|
}`;
|
||||||
@@ -299,7 +300,7 @@ const Image = ({ commentMediaInfo, disableToggle = false, displayHeight, display
|
|||||||
const isReply = parentCid;
|
const isReply = parentCid;
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [isImageExpanded, setIsImageExpanded] = useState(initialExpanded);
|
const [isImageExpanded, setIsImageExpanded] = useState(initialExpanded);
|
||||||
const { fitExpandedImagesToScreen } = useExpandedMediaStore();
|
const fitExpandedImagesToScreen = useExpandedMediaStore((s) => s.fitExpandedImagesToScreen);
|
||||||
const mediaDimensions = getMediaDimensions(commentMediaInfo);
|
const mediaDimensions = getMediaDimensions(commentMediaInfo);
|
||||||
const mediaClass = `${isMobile ? styles.mediaMobile : isReply ? styles.mediaDesktopReply : styles.mediaDesktopOp} ${
|
const mediaClass = `${isMobile ? styles.mediaMobile : isReply ? styles.mediaDesktopReply : styles.mediaDesktopOp} ${
|
||||||
fitExpandedImagesToScreen ? styles.fitToScreen : ''
|
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 = ({
|
const PostInfo = ({
|
||||||
post,
|
post,
|
||||||
postReplyCount = 0,
|
postReplyCount = 0,
|
||||||
@@ -142,86 +253,6 @@ const PostInfo = ({
|
|||||||
const pendingApproval = post?.pendingApproval;
|
const pendingApproval = post?.pendingApproval;
|
||||||
const shouldShowPendingApprovalButtons = isInPostPageView && !isInModQueueView && pendingApproval && isAccountMod && communityAddress;
|
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)
|
// Check if post is awaiting approval and over threshold (for mod queue view)
|
||||||
const approved = post?.approved;
|
const approved = post?.approved;
|
||||||
const alreadyApproved = approved === true;
|
const alreadyApproved = approved === true;
|
||||||
@@ -473,39 +504,7 @@ const PostInfo = ({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{shouldShowPendingApprovalButtons && (
|
{shouldShowPendingApprovalButtons && communityAddress && cid && <PendingModerationActions cid={cid} communityAddress={communityAddress} post={post} />}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
{!(removed || deleted || purged) && !isModQueue && <PostMenuDesktop postMenu={postMenuProps} />}
|
{!(removed || deleted || purged) && !isModQueue && <PostMenuDesktop postMenu={postMenuProps} />}
|
||||||
{cid && parentCid && <ReplyBacklinks post={post} quotedByMap={quotedByMap} directRepliesByParentCid={directRepliesByParentCid} />}
|
{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 { useLocation, useParams } from 'react-router-dom';
|
||||||
import { getCommentMediaInfo, fetchWebpageThumbnailIfNeeded, CommentMediaInfo } from '../lib/utils/media-utils';
|
import { getCommentMediaInfo, fetchWebpageThumbnailIfNeeded, CommentMediaInfo } from '../lib/utils/media-utils';
|
||||||
import { isPendingPostView, isPostPageView } from '../lib/utils/view-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]);
|
}, [link, thumbnailUrl, linkWidth, linkHeight, isInPostPageView, isInPendingPostView]);
|
||||||
|
|
||||||
const mediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
return useMemo(() => {
|
||||||
|
const mediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
||||||
// Return media info with cached thumbnail dimensions if available
|
if (thumbnailDimensions && mediaInfo) {
|
||||||
if (thumbnailDimensions && mediaInfo) {
|
return {
|
||||||
return {
|
...mediaInfo,
|
||||||
...mediaInfo,
|
thumbnailWidth: thumbnailDimensions.width,
|
||||||
thumbnailWidth: thumbnailDimensions.width,
|
thumbnailHeight: thumbnailDimensions.height,
|
||||||
thumbnailHeight: thumbnailDimensions.height,
|
};
|
||||||
};
|
}
|
||||||
}
|
return mediaInfo;
|
||||||
|
}, [link, thumbnailUrl, linkWidth, linkHeight, thumbnailDimensions]);
|
||||||
return mediaInfo;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ interface GifFirstFrameState {
|
|||||||
status: GifFirstFrameStatus;
|
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> => {
|
const getCachedGifFrame = async (url: string): Promise<string | null> => {
|
||||||
return await gifFrameDb.getItem(url);
|
return await gifFrameDb.getItem(url);
|
||||||
};
|
};
|
||||||
@@ -79,20 +83,20 @@ const parseGif = async (buf: ArrayBuffer): Promise<Blob> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const useFetchGifFirstFrame = (url: string | undefined) => {
|
const useFetchGifFirstFrame = (url: string | undefined) => {
|
||||||
const [gifFirstFrame, setGifFirstFrame] = useState<GifFirstFrameState>({ frameUrl: null, status: 'idle' });
|
const [gifFirstFrame, setGifFirstFrame] = useState<GifFirstFrameState>(IDLE_STATE);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!url) {
|
if (!url) {
|
||||||
setGifFirstFrame({ frameUrl: null, status: 'idle' });
|
setGifFirstFrame((prev) => (prev.status === 'idle' ? prev : IDLE_STATE));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let isActive = true;
|
let isActive = true;
|
||||||
setGifFirstFrame({ frameUrl: null, status: 'loading' });
|
setGifFirstFrame((prev) => (prev.status === 'loading' ? prev : LOADING_STATE));
|
||||||
|
|
||||||
const fetchFrame = async () => {
|
const fetchFrame = async () => {
|
||||||
if (failedUrls.has(url)) {
|
if (failedUrls.has(url)) {
|
||||||
if (isActive) setGifFirstFrame({ frameUrl: null, status: 'failed' });
|
if (isActive) setGifFirstFrame((prev) => (prev.status === 'failed' ? prev : FAILED_STATE));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +123,7 @@ const useFetchGifFirstFrame = (url: string | undefined) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
failedUrls.add(url);
|
failedUrls.add(url);
|
||||||
console.error('Failed to load GIF frame:', error);
|
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 nestedPretextElementCache = new WeakMap<HTMLElement, HTMLElement | null>();
|
||||||
const catalogPostHeightEstimateCache = new Map<string, number>();
|
const catalogPostHeightEstimateCache = new Map<string, number>();
|
||||||
const catalogRowHeightEstimateCache = 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;
|
let pretextSupport: boolean | undefined;
|
||||||
|
|
||||||
@@ -646,6 +648,30 @@ export const getFeedPostHeightEstimate = ({
|
|||||||
const previewRepliesHeight = previewHeights.reduce((sum, value) => sum + value, 0);
|
const previewRepliesHeight = previewHeights.reduce((sum, value) => sum + value, 0);
|
||||||
const previewReplyCount = previewReplies.length;
|
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) {
|
if (isMobile) {
|
||||||
const fontSizePx = metrics.mobileContentFontSizePx;
|
const fontSizePx = metrics.mobileContentFontSizePx;
|
||||||
const font = `${fontSizePx}px ${metrics.bodyFontFamily}`;
|
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.
|
// Mobile board cards render preview replies more compactly than the thread-reply estimator assumes.
|
||||||
const mobileCalibration = getMobileFeedCardCalibration(previewReplyCount);
|
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;
|
const fontSizePx = metrics.bodyFontSizePx;
|
||||||
@@ -698,7 +732,15 @@ export const getFeedPostHeightEstimate = ({
|
|||||||
(showSummary ? DESKTOP_FEED_CARD_SUMMARY_HEIGHT : 0) +
|
(showSummary ? DESKTOP_FEED_CARD_SUMMARY_HEIGHT : 0) +
|
||||||
previewRepliesHeight;
|
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 => {
|
export const getCatalogPostHeightEstimate = ({ imageSize, metrics, post, showOPComment }: CatalogPostHeightEstimateOptions): number => {
|
||||||
|
|||||||
@@ -432,6 +432,27 @@ describe('Board', () => {
|
|||||||
expect(testState.lastVirtuosoMinOverscanItemCount).toEqual({ top: 8, bottom: 4 });
|
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 () => {
|
it('canonicalizes multiboard paths and shows the subscriptions empty state', async () => {
|
||||||
testState.account = { subscriptions: [] };
|
testState.account = { subscriptions: [] };
|
||||||
testState.filteredDirectoryAddresses = [];
|
testState.filteredDirectoryAddresses = [];
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
|
|||||||
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
||||||
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${BOARD_SORT_TYPE}` : `${location.pathname}-${BOARD_SORT_TYPE}`;
|
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${BOARD_SORT_TYPE}` : `${location.pathname}-${BOARD_SORT_TYPE}`;
|
||||||
const navigationType = useNavigationType();
|
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 boardMinOverscanItemCount = isMultiboardView && isMobile ? { bottom: 4, top: 8 } : undefined;
|
||||||
|
|
||||||
const boardItemContent = useCallback(
|
const boardItemContent = useCallback(
|
||||||
|
|||||||
Reference in New Issue
Block a user