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'; import { Comment, useEditedComment, useReplies, useAccount } from '@bitsocialnet/bitsocial-react-hooks'; import getShortAddress from '../../lib/get-short-address'; import styles from '../../views/post/post.module.css'; import { CommentMediaInfo, getDisplayMediaInfoType, getHasThumbnail, getMediaDimensions } from '../../lib/utils/media-utils'; import { hashStringToColor, getTextColorForBackground } from '../../lib/utils/post-utils'; import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils'; import { approvePendingCommentModeration, isPendingApprovalRejected, rejectPendingCommentModeration } from '../../lib/utils/pending-approval-moderation'; import { isValidURL } from '../../lib/utils/url-utils'; import { isAllView, isModQueueView, isModView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; import { formatUserIDForDisplay, truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils'; import useModQueueStore from '../../stores/use-mod-queue-store'; import { findDirectoryByAddress, useDirectories } from '../../hooks/use-directories'; import { getBoardPath } from '../../lib/utils/route-utils'; import useAuthorAddressClick from '../../hooks/use-author-address-click'; import { useCommentMediaInfo } from '../../hooks/use-comment-media-info'; import useCountLinksInReplies from '../../hooks/use-count-links-in-replies'; import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame'; import useHide from '../../hooks/use-hide'; import useStateString from '../../hooks/use-state-string'; import useScrollToReply from '../../hooks/use-scroll-to-reply'; import useSafeAccountComment from '../../hooks/use-safe-account-comment'; import { useCurrentTime } from '../../hooks/use-current-time'; import { useBoardPseudonymityMode } from '../../hooks/use-board-pseudonymity-mode'; import CommentContent from '../comment-content'; import CommentMedia from '../comment-media'; import EditMenu from '../edit-menu/edit-menu'; import FailedPublishNotice from '../failed-publish-notice'; import { canEmbed } from '../embed'; import LoadingEllipsis from '../loading-ellipsis'; import PostMenuDesktop from './post-menu-desktop'; import ReplyQuotePreview from '../reply-quote-preview'; import Tooltip from '../tooltip'; import { PostProps } from '../../views/post/post'; import { create } from 'zustand'; import capitalize from 'lodash/capitalize'; import lowerCase from 'lodash/lowerCase'; import { shouldShowSnow } from '../../lib/snow'; import useReplyModalStore from '../../stores/use-reply-modal-store'; import { selectPostMenuProps } from '../../lib/utils/post-menu-props'; 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'; import useProgressiveRender from '../../hooks/use-progressive-render'; 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 { computeOmittedCount, filterRepliesForDisplay, getPreviewDisplayReplies, getTotalReplyCount, hasEnoughPreviewReplies, } from '../../lib/utils/replies-preview-utils'; import { isCommentArchived } from '../../lib/utils/comment-moderation-utils'; import { getThreadTopNavigationState, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils'; 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(); const RepliesFooter = ({ hasMore, loadingString }: { hasMore: boolean; loadingString: string }) => hasMore ? (
) : null; // Store scroll position for replies virtuoso across navigations const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; interface ShowOmittedRepliesState { showOmittedReplies: Record; setShowOmittedReplies: (cid: string, showOmittedReplies: boolean) => void; } const useShowOmittedReplies = create((set) => ({ showOmittedReplies: {}, setShowOmittedReplies: (cid, showOmittedReplies) => set((state) => ({ showOmittedReplies: { ...state.showOmittedReplies, [cid]: showOmittedReplies, }, })), })); 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, roles, isHidden, threadNumber, isModQueue, modQueueStatus, modQueueError, isPublishing, onApprove, onReject, quotedByMap, directRepliesByParentCid, postsByAuthorInThread, }: PostProps & { directRepliesByParentCid?: Map; postsByAuthorInThread?: Map }) => { const { t } = useTranslation(); const { author, cid, deleted, locked, pinned, parentCid, postCid, reason, removed, state, communityAddress, timestamp } = post || {}; const archived = isCommentArchived(post); const purged = post?.commentModeration?.purged; const title = post?.title?.trim(); const { address, shortAddress } = author || {}; const displayName = author?.displayName?.trim(); const authorRole = roles?.[address]?.role?.replace('moderator', 'mod'); const hasFailedState = state === 'failed'; const isReply = parentCid; const { showOmittedReplies } = useShowOmittedReplies(); const directories = useDirectories(); const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined; const postMenuProps = selectPostMenuProps(post); const params = useParams(); const location = useLocation(); const isInPostPageView = isPostPageView(location.pathname, params); const isInModQueueView = isModQueueView(location.pathname); const { getAlertThresholdSeconds } = useModQueueStore(); const currentTime = useCurrentTime(); const account = useAccount(); const accountAddress = account?.author?.address; // Check if user is mod of this board const accountRole = roles?.[accountAddress]?.role; const isAccountMod = accountRole === 'admin' || accountRole === 'owner' || accountRole === 'moderator'; // Check if post is pending approval and user is mod (for post page view) const pendingApproval = post?.pendingApproval; const shouldShowPendingApprovalButtons = isInPostPageView && !isInModQueueView && pendingApproval && isAccountMod && communityAddress; // Check if post is awaiting approval and over threshold (for mod queue view) const approved = post?.approved; const alreadyApproved = approved === true; const alreadyRejected = isPendingApprovalRejected(post); const isAwaitingApproval = isInModQueueView && !alreadyApproved && !alreadyRejected; const timeWaiting = timestamp ? currentTime - timestamp : 0; const alertThresholdSeconds = getAlertThresholdSeconds(); const isOverThreshold = isAwaitingApproval && timeWaiting > alertThresholdSeconds; const userID = address ? getShortAddress(address) : shortAddress; const userIDBackgroundColor = hashStringToColor(userID); const userIDTextColor = getTextColorForBackground(userIDBackgroundColor); const pseudonymityMode = useBoardPseudonymityMode(communityAddress); const showUserID = pseudonymityMode === 'per-post'; const handleUserAddressClick = useAuthorAddressClick(); const numberOfPostsByAuthor = (() => { if (!showUserID || deleted || removed || purged || !shortAddress || !postCid) { return 0; } return Math.max(postsByAuthorInThread?.get(shortAddress) ?? 0, 1); })(); const { hidden } = useHide(post); const { openReplyModal } = useReplyModalStore(); const onReplyModalClick = () => { deleted ? isReply ? alert(t('this_reply_was_deleted')) : alert(t('this_thread_was_deleted')) : removed || purged ? isReply ? alert(t('this_reply_was_removed')) : alert(t('this_thread_was_removed')) : archived && !isReply ? alert(t('thread_archived')) : openReplyModal && openReplyModal(cid, post?.number, postCid, threadNumber, communityAddress); }; const threadRoute = cid ? (boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`) : undefined; const threadTopNavigationState = !isReply ? getThreadTopNavigationState(cid) : undefined; const onLinkToPostClick = (e: React.MouseEvent) => { if (!cid || !threadRoute) { e.preventDefault(); return; } if (!isInPostPageView || isReply) return; e.preventDefault(); scrollThreadContainerToTop(cid); }; return (
{isHidden ? parentCid && : } {title && (title.length <= 75 ? ( {title} ) : ( {title.slice(0, 75) + '(...)'} ))} {deleted ? ( capitalize(t('deleted')) ) : removed ? ( capitalize(t('removed')) ) : purged ? ( capitalize(t('purged')) ) : displayName ? ( displayName.length <= 20 ? ( displayName ) : ( {displayName.slice(0, 20) + '(...)'} ) ) : ( capitalize(t('anonymous')) )} {!(deleted || removed || purged) && authorRole && ( {' '} ## Board {authorRole}{' '} )}{' '} {showUserID && ( <> (ID:{' '} {deleted ? ( t('deleted') ) : removed ? ( t('removed') ) : purged ? ( t('purged') ) : !cid && pseudonymityMode ? ( {hasFailedState ? capitalize(t('failed')) : capitalize(t('pending'))} ) : ( handleUserAddressClick(userID, postCid)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleUserAddressClick(userID, postCid); } }} style={{ backgroundColor: userIDBackgroundColor, color: userIDTextColor }} > {formatUserIDForDisplay(userID)} )} ){' '} )} {isInModQueueView && isOverThreshold ? ( <> {getFormattedDate(timestamp)} {' '} ({getFormattedTimeAgo(timestamp)}) ) : ( {getFormattedDate(timestamp)} )}{' '} {cid ? ( No. { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onReplyModalClick(); } }} > {post?.number || '?'} ) : ( <> No. {hasFailedState ? capitalize(t('failed')) : capitalize(t('pending'))} )} {pinned && ( )} {locked && ( )} {archived && ( )} {!isInPostPageView && !isReply && !isHidden && !isModQueue && ( [ !cid && e.preventDefault()}> {capitalize(t('reply'))} ] )} {isModQueue && ( {capitalize(t('type'))}: {capitalize(t(isReply ? 'reply' : 'post'))} {modQueueStatus === 'approved' ? ( {t('approved')} ) : modQueueStatus === 'rejected' ? ( {t('rejected')} ) : modQueueStatus === 'failed' ? ( {t('failed')} {modQueueError ? `: ${modQueueError}` : ''} ) : isPublishing ? ( ) : ( <> {isReply && boardPath && (post?.threadCid || post?.parentCid) && ( [ {t('view_thread')} ] )} [ ] [ ] )} )} {shouldShowPendingApprovalButtons && communityAddress && cid && } {!(removed || deleted || purged) && !isModQueue && } {cid && parentCid && } {cid && !parentCid && }
); }; const ReplyBacklinks = ({ post, quotedByMap, directRepliesByParentCid, }: { post: Comment; quotedByMap?: Map; directRepliesByParentCid?: Map; }) => { const { cid, parentCid } = post || {}; if (!cid || !parentCid) { return null; } const directReplies = directRepliesByParentCid?.get(cid) || []; return ( <> {directReplies.map( (reply: Comment) => reply?.parentCid === cid && reply?.cid && reply?.number && !(reply?.deleted || reply?.removed) && , )} {quotedByMap ?.get(cid) ?.map( (reply: Comment) => reply?.parentCid !== cid && reply?.cid && reply?.number && !(reply?.deleted || reply?.removed) && , )} ); }; const OpBacklinks = ({ cid, quotedByMap }: { cid: string; quotedByMap?: Map }) => ( <> {quotedByMap ?.get(cid) ?.map( (reply: Comment) => reply?.cid && reply?.number && !(reply?.deleted || reply?.removed) && , )} ); interface PostMediaProps { commentMediaInfo: CommentMediaInfo | undefined; hasThumbnail: boolean; spoiler: boolean; deleted: boolean; purged: boolean; removed: boolean; linkHeight: number; linkWidth: number; parentCid: string; communityAddress?: string; isInAllView: boolean; isInSubscriptionsView: boolean; isInModView: boolean; } const PostMedia = ({ commentMediaInfo, hasThumbnail, spoiler, deleted, purged, removed, linkHeight, linkWidth, parentCid, communityAddress, isInAllView, isInSubscriptionsView, isInModView, }: PostMediaProps) => { const { t } = useTranslation(); const { url } = commentMediaInfo || {}; let type = commentMediaInfo?.type; const { status: gifFrameStatus } = useFetchGifFirstFrame(type === 'gif' ? url : undefined); const directories = useDirectories(); if (type === 'gif' && gifFrameStatus === 'ready') { type = 'animated gif'; } else if (type === 'gif' && gifFrameStatus === 'failed') { type = 'static gif'; } const embedUrl = url && new URL(url); const [showThumbnail, setShowThumbnail] = useState(true); const mediaDimensions = getMediaDimensions(commentMediaInfo); const directoryEntry = findDirectoryByAddress(directories, communityAddress); const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true; const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined; const displayBoardPath = boardPath && communityAddress && boardPath !== communityAddress ? boardPath : communityAddress && (communityAddress.endsWith('.eth') || communityAddress.endsWith('.sol')) ? communityAddress : communityAddress ? getShortAddress(communityAddress) : undefined; return (
{communityAddress && (isInAllView || isInSubscriptionsView || isInModView) && boardPath && !parentCid && ( <> {t('board')}: {displayBoardPath}{' '} )} {requirePostLinkIsMedia ? t('file') : t('link')}:{' '} {(() => { if (spoiler) return capitalize(t('spoiler')); if (requirePostLinkIsMedia && url) { try { const filename = new URL(url).pathname.split('/').pop(); if (filename && /\.\w+$/.test(filename)) return truncateWithEllipsisInMiddle(filename); } catch {} } return truncateWithEllipsisInMiddle(url ?? ''); })()} {' '} ({type && lowerCase(getDisplayMediaInfoType(type, t))} {mediaDimensions && `, ${mediaDimensions}`}) {!showThumbnail && (type === 'iframe' || type === 'video' || type === 'audio') && ( -[ setShowThumbnail(true)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setShowThumbnail(true); } }} > {t('close')} ] )} {showThumbnail && !hasThumbnail && embedUrl && canEmbed(embedUrl) && ( -[ setShowThumbnail(false)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setShowThumbnail(false); } }} > {t('open')} ] )}
{(hasThumbnail || (!hasThumbnail && !showThumbnail) || spoiler || Boolean(commentMediaInfo?.url)) && (
)}
); }; const Reply = ({ postReplyCount, reply, roles, threadNumber, quotedByMap, directRepliesByParentCid, postsByAuthorInThread, disableDeferredLayout, }: PostProps & { directRepliesByParentCid?: Map; postsByAuthorInThread?: Map; disableDeferredLayout?: boolean }) => { const accountReply = useSafeAccountComment({ commentIndex: typeof reply?.index === 'number' ? reply.index : undefined, }); const hasReplyIndex = typeof reply?.index === 'number'; let post = hasReplyIndex && accountReply?.index === reply.index ? accountReply : reply; // handle pending mod or author edit const { editedComment } = useEditedComment({ comment: post }); if (editedComment) { post = editedComment; } post = withResolvedCommentCommunityAddress(post); const { author, cid, deleted, link, linkHeight, linkWidth, postCid, reason, removed, spoiler, communityAddress, thumbnailUrl, parentCid } = post || {}; const purged = post?.commentModeration?.purged; const directories = useDirectories(); const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined; const location = useLocation(); const route = boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`; const isRouteLinkToReply = cid ? location.pathname.startsWith(route) : false; const { hidden } = useHide({ cid }); const isInAllView = isAllView(location.pathname); const params = useParams(); const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); const isInModView = isModView(location.pathname); const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight); const hasThumbnail = getHasThumbnail(commentMediaInfo, link); const { canDeleteFailedPost, canRetryFailedPost, isDeletingFailedPost, isRetryingFailedPost, onDeleteFailedPost, onRetryFailedPost } = useDeleteFailedPost(post); const failedPublishNotice = canDeleteFailedPost ? ( ) : undefined; return (
{'>>'}
{link && !hidden && !(deleted || removed || purged) && isValidURL(link) && ( )} {!hidden && (!(removed || deleted || purged) || ((removed || deleted) && reason) || purged) && ( )}
); }; const PostDesktop = ({ feedVirtualizationModeOverride, post, roles, replyPaginationOverride, replyVirtualizationModeOverride, showAllReplies, showReplies = true, targetReplyCid, isModQueue, modQueueStatus, modQueueError, isPublishing, onApprove, onReject, }: PostProps) => { const { t } = useTranslation(); const resolvedPost = withResolvedCommentCommunityAddress(post); const { author, cid, content, deleted, link, linkHeight, linkWidth, pinned, postCid, removed, spoiler, state, communityAddress, thumbnailUrl, parentCid } = resolvedPost || {}; const purged = resolvedPost?.commentModeration?.purged; const params = useParams(); const location = useLocation(); const navigationType = useNavigationType(); const isInPendingPostView = isPendingPostView(location.pathname, params); const isInPostPageView = isPostPageView(location.pathname, params); const isInAllView = isAllView(location.pathname); const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); const isInModView = isModView(location.pathname); const isMultiboardView = isInAllView || isInSubscriptionsView || isInModView; const directories = useDirectories(); const boardPath = communityAddress ? getBoardPath(communityAddress, directories) : undefined; const displayBoardPath = boardPath && communityAddress ? boardPath !== communityAddress ? boardPath : communityAddress.endsWith('.eth') || communityAddress.endsWith('.sol') ? communityAddress : getShortAddress(communityAddress) : undefined; const { hidden, unhide, hide } = useHide({ cid }); const isHidden = hidden && !isInPostPageView; const { showOmittedReplies, setShowOmittedReplies } = useShowOmittedReplies(); const hasReplyPaginationOverride = !!replyPaginationOverride; const shouldUsePreview = showReplies && !isModQueue && !showAllReplies && !hasReplyPaginationOverride; const shouldFetchFull = showReplies && !isModQueue && !hasReplyPaginationOverride && (showAllReplies || showOmittedReplies[cid]); const cachedPreviewRepliesResult = useReplies({ comment: shouldUsePreview ? resolvedPost : undefined, onlyIfCached: true, sortType: 'new', flat: true, repliesPerPage: BOARD_REPLIES_PREVIEW_FETCH_SIZE, accountComments: { newerThan: Infinity, append: true }, }); const cachedPreviewReplies = (cachedPreviewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies?.length ? (cachedPreviewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies! : cachedPreviewRepliesResult.replies || []; const hasEnoughCachedPreview = hasEnoughPreviewReplies({ replyCount: resolvedPost?.replyCount, loadedCount: cachedPreviewReplies.length, visibleCount: BOARD_REPLIES_PREVIEW_VISIBLE_COUNT, }); const previewRepliesResult = useReplies({ comment: shouldUsePreview && !showOmittedReplies[cid] && !hasEnoughCachedPreview ? resolvedPost : undefined, sortType: 'new', flat: true, repliesPerPage: BOARD_REPLIES_PREVIEW_FETCH_SIZE, accountComments: { newerThan: Infinity, append: true }, }); const fullRepliesResult = useReplies({ comment: shouldFetchFull ? resolvedPost : undefined, sortType: 'old', flat: true, repliesPerPage: REPLIES_PER_PAGE, accountComments: { newerThan: Infinity, append: true }, }); const livePreviewReplies = (previewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies?.length ? (previewRepliesResult as { updatedReplies?: Comment[] }).updatedReplies! : previewRepliesResult.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 = replyPaginationOverride?.hasMore ?? fullRepliesResult.hasMore; const loadMore = replyPaginationOverride?.loadMore ?? fullRepliesResult.loadMore; const reset = replyPaginationOverride?.reset ?? (fullRepliesResult as { reset?: () => Promise }).reset; const fullIsFetching = shouldFetchFull && !hasReplyPaginationOverride && fullReplies.length === 0 && fullRepliesResult.hasMore; const repliesForRender = showAllReplies ? fullReplies : showOmittedReplies[cid] ? fullReplies.length ? fullReplies : previewReplies : getPreviewDisplayReplies(previewReplies, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT); const freshRepliesForRender = useFreshReplies(repliesForRender); useRegisterFreshReplies(resolvedPost, freshRepliesForRender); const setResetFunction = useFeedResetStore((s) => s.setResetFunction); const repliesResetRequestId = useThreadLiveUpdatesStore((state) => state.repliesResetRequestId); const lastHandledRepliesResetRequestIdRef = useRef(repliesResetRequestId); useEffect(() => { if ((isInPostPageView || isInPendingPostView) && reset) { setResetFunction(() => { reset(); }); } }, [isInPostPageView, isInPendingPostView, reset, setResetFunction]); useEffect(() => { if (!reset || repliesResetRequestId === 0 || repliesResetRequestId === lastHandledRepliesResetRequestIdRef.current) return; lastHandledRepliesResetRequestIdRef.current = repliesResetRequestId; void reset(); }, [repliesResetRequestId, reset]); const visiblelinksCount = useCountLinksInReplies(resolvedPost, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT); const totalLinksCount = useCountLinksInReplies(resolvedPost); const replyCount = freshRepliesForRender.length; const totalReplyCount = getTotalReplyCount({ replyCount: resolvedPost?.replyCount, fullLoadedCount: fullReplies.length, previewLoadedCount: Math.max(cachedPreviewReplies.length, livePreviewReplies.length), }); const repliesCount = computeOmittedCount({ totalReplyCount, visibleCount: BOARD_REPLIES_PREVIEW_VISIBLE_COUNT, }); const linksCount = totalLinksCount - visiblelinksCount; const stateString = useStateString(resolvedPost) || t('downloading_board'); const hasFailedState = state === 'failed'; const { canDeleteFailedPost, canRetryFailedPost, isDeletingFailedPost, isRetryingFailedPost, onDeleteFailedPost, onRetryFailedPost } = useDeleteFailedPost(resolvedPost); const failedPublishNotice = canDeleteFailedPost ? ( ) : undefined; const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight); const hasThumbnail = getHasThumbnail(commentMediaInfo, link); // Author-deleted replies are hidden from thread replies; moderator removals still render their placeholder. const filteredReplies = useMemo(() => filterRepliesForDisplay(freshRepliesForRender), [freshRepliesForRender]); const postsByAuthorInThread = useMemo(() => getThreadPostCountsByAuthor(resolvedPost, filteredReplies), [resolvedPost, filteredReplies]); const directRepliesByParentCid = useMemo(() => { const map = new Map(); for (const reply of filteredReplies) { const directParentCid = reply?.parentCid; if (!directParentCid || !reply?.cid) { continue; } const existingReplies = map.get(directParentCid); if (existingReplies) { existingReplies.push(reply); } else { map.set(directParentCid, [reply]); } } 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, intervalMs: 100, resetKey: cid, disabled: hasMore || !!targetReplyCid || !showAllReplies, }); // Virtuoso scroll position management for infinite replies const virtuosoRef = useRef(null); const virtuosoStateKey = `replies-desktop-${cid}`; useEffect(() => { if (!showAllReplies || !isInPostPageView) return; const currentKey = virtuosoStateKey; const setLastVirtuosoState = () => { virtuosoRef.current?.getState((snapshot: StateSnapshot) => { if (snapshot?.ranges?.length) { lastVirtuosoStates[currentKey] = snapshot; } }); }; window.addEventListener('scroll', setLastVirtuosoState, { passive: true }); return () => window.removeEventListener('scroll', setLastVirtuosoState); }, [virtuosoStateKey, showAllReplies, isInPostPageView]); const lastVirtuosoState = navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined; const shouldScrollToReply = showAllReplies && showReplies && !isInPendingPostView && !!targetReplyCid; useScrollToReply({ targetReplyCid, replies: filteredReplies, hasMore, loadMore, virtuosoRef, enabled: shouldScrollToReply, }); const virtuosoFooter = useCallback(() => , [hasMore, t]); return (
{showReplies || isModQueue ? (

) : (
)}
{!isInPostPageView && showReplies && ( { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); (hidden ? unhide : hide)(); } }} /> )}
{shouldShowSnow() && hasThumbnail && } {!link && !parentCid && communityAddress && isMultiboardView && boardPath && (
{t('board')}: {displayBoardPath}
)} {link && !isHidden && !(deleted || removed || purged) && isValidURL(link) && ( )} {!isHidden && !content && !(deleted || removed || purged) &&
} {!isHidden && }
{!isHidden && !isInPendingPostView && showReplies && repliesCount > 0 && !isInPostPageView && ( setShowOmittedReplies(cid, !showOmittedReplies[cid])} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setShowOmittedReplies(cid, !showOmittedReplies[cid]); } }} /> {showOmittedReplies[cid] ? ( t('showing_all_replies') ) : linksCount > 0 ? ( }} values={{ repliesCount, linksCount }} /> ) : ( }} values={{ repliesCount }} /> )} )} {/* Virtuoso infinite scroll for post page view when there's more content to paginate */} {!isHidden && showAllReplies && !isInPendingPostView && showReplies && hasMore && !!resolvedPost?.replyCount && ( (
reportReplyHeightAuditSample(element, replyHeightEstimates?.[index], reply.cid)} >
)} useWindowScroll={true} components={{ Footer: virtuosoFooter }} endReached={loadMore} ref={virtuosoRef} restoreStateFrom={lastVirtuosoState} initialScrollTop={lastVirtuosoState?.scrollTop} /> )} {/* Non-virtualized rendering for post page view when all replies fit on one page */} {!isHidden && showAllReplies && !isInPendingPostView && showReplies && !hasMore && visibleReplies.map((reply) => (
))} {/* Non-virtualized rendering for board view (preview replies when collapsed, full when expanded) */} {!isHidden && !showAllReplies && !isInPendingPostView && freshRepliesForRender && showReplies && filteredReplies.map((reply, index) => (
))} {!isHidden && !showAllReplies && showOmittedReplies[cid] && fullIsFetching && showReplies && filteredReplies.length > 0 && (
)}
{!isInPendingPostView && stateString && !hasFailedState && state !== 'succeeded' && !(resolvedPost?.timestamp && !resolvedPost?.updatedAt) && isInPostPageView && !(!showReplies && !showAllReplies) ? (

) : ( hasFailedState && {t('failed')} )}
); }; export default PostDesktop;