import { useEffect, useRef, useState, useCallback } from 'react'; import { 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, usePublishCommentModeration, useAccountComment } from '@bitsocialhq/bitsocial-react-hooks'; import getShortAddress from '../../lib/get-short-address'; import styles from '../../views/post/post.module.css'; import { shouldShowSnow } from '../../lib/snow'; import { getHasThumbnail } from '../../lib/utils/media-utils'; import { getTextColorForBackground, hashStringToColor } from '../../lib/utils/post-utils'; import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils'; import { isAllView, isModQueueView, isModView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; import { formatUserIDForDisplay } from '../../lib/utils/string-utils'; import useModQueueStore from '../../stores/use-mod-queue-store'; import { 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 useHide from '../../hooks/use-hide'; import useStateString from '../../hooks/use-state-string'; import useScrollToReply from '../../hooks/use-scroll-to-reply'; import { useCurrentTime } from '../../hooks/use-current-time'; import { useSubplebbitField } from '../../hooks/use-stable-subplebbit'; import CommentContent from '../comment-content'; import CommentMedia from '../comment-media'; import LoadingEllipsis from '../loading-ellipsis'; import PostMenuMobile from './post-menu-mobile'; import ReplyQuotePreview from '../reply-quote-preview'; import Tooltip from '../tooltip'; import { PostProps } from '../../views/post/post'; import capitalize from 'lodash/capitalize'; import lowerCase from 'lodash/lowerCase'; 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 usePostNumberStore from '../../stores/use-post-number-store'; import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils'; import useQuotedByMap from '../../hooks/use-quoted-by-map'; import useProgressiveRender from '../../hooks/use-progressive-render'; import { BOARD_REPLIES_PREVIEW_FETCH_SIZE, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT, REPLIES_PER_PAGE } from '../../lib/constants'; import { getPreviewDisplayReplies } from '../../lib/utils/replies-preview-utils'; 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 } = {}; const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: PostProps) => { const { t } = useTranslation(); const directories = useDirectories(); const { author, cid, deleted, link, linkHeight, linkWidth, locked, parentCid, pinned, postCid, reason, removed, state, subplebbitAddress, timestamp, thumbnailUrl } = post || {}; const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, directories) : undefined; const displayBoardPath = boardPath && subplebbitAddress ? boardPath !== subplebbitAddress ? boardPath : subplebbitAddress.endsWith('.eth') || subplebbitAddress.endsWith('.sol') ? subplebbitAddress : getShortAddress(subplebbitAddress) : undefined; const isReply = parentCid; const title = post?.title?.trim(); const { address, shortAddress } = author || {}; const displayName = author?.displayName?.trim(); const authorRole = roles?.[address]?.role?.replace('moderator', 'mod'); const params = useParams(); const location = useLocation(); const isInAllView = isAllView(location.pathname); const isInPostPageView = isPostPageView(location.pathname, params); const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); const isInModView = isModView(location.pathname); 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 && subplebbitAddress; // Moderation actions for pending approval posts const { publishCommentModeration: approvePending, state: approvePendingState, error: approvePendingError, } = usePublishCommentModeration({ commentCid: cid, subplebbitAddress: shouldShowPendingApprovalButtons ? subplebbitAddress : undefined, commentModeration: { approved: true }, 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, subplebbitAddress: shouldShowPendingApprovalButtons ? subplebbitAddress : undefined, commentModeration: { removed: true }, 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; const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight); const hasThumbnail = getHasThumbnail(commentMediaInfo, link); // Check if post is awaiting approval and over threshold (for mod queue view) const approved = post?.approved; const alreadyApproved = approved === true; const alreadyRejected = removed === true; const isAwaitingApproval = isInModQueueView && !alreadyApproved && !alreadyRejected; const timeWaiting = timestamp ? currentTime - timestamp : 0; const alertThresholdSeconds = getAlertThresholdSeconds(); const isOverThreshold = isAwaitingApproval && timeWaiting > alertThresholdSeconds; const hasFailedState = state === 'failed'; const postMenuProps = selectPostMenuProps(post); const pseudonymityMode = useSubplebbitField(subplebbitAddress, (sub) => sub?.features?.pseudonymityMode); const showUserID = pseudonymityMode === 'per-post'; const handleUserAddressClick = useAuthorAddressClick(); const numberOfPostsByAuthor = (() => { if (!showUserID || deleted || removed || !shortAddress || !postCid || typeof document === 'undefined') { return 0; } const domCount = document.querySelectorAll(`[data-author-address="${shortAddress}"][data-post-cid="${postCid}"]`).length; return Math.max(domCount, 1); })(); const userID = address && getShortAddress(address); // shortened to 8 chars for display; users can verify the full user ID via "Copy user ID" in the post menu to guard against spoofing const userIDBackgroundColor = hashStringToColor(userID); const userIDTextColor = getTextColorForBackground(userIDBackgroundColor); const { hidden } = useHide(post); const { openReplyModal } = useReplyModalStore(); const onReplyModalClick = () => { deleted ? isReply ? alert(t('this_reply_was_deleted')) : alert(t('this_thread_was_deleted')) : removed ? isReply ? alert(t('this_reply_was_removed')) : alert(t('this_thread_was_removed')) : openReplyModal && openReplyModal(cid, post?.number, postCid, threadNumber, subplebbitAddress); }; return ( <>
{removed ? ( capitalize(t('removed')) ) : deleted ? ( capitalize(t('deleted')) ) : displayName ? ( displayName.length <= 20 ? ( displayName ) : ( {displayName.slice(0, 20) + '(...)'} ) ) : ( capitalize(t('anonymous')) )}{' '} {!(deleted || removed) && authorRole && ( {' '} ## Board {authorRole}{' '}   )} {showUserID && ( <> (ID: {''} {removed ? ( lowerCase(t('removed')) ) : deleted ? ( lowerCase(t('deleted')) ) : !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)} )} ){' '} )} {pinned && ( )} {locked && ( )} {title && ( {title.length <= 30 ? ( {title} ) : ( {title.slice(0, 30) + '(...)'} )} )} {subplebbitAddress && (isInAllView || isInSubscriptionsView || isInModView) && !isReply && boardPath && displayBoardPath && (
{' '} Board: {displayBoardPath}
)} {isInModQueueView && isOverThreshold ? ( <> {getFormattedDate(timestamp)} {' '} ({getFormattedTimeAgo(timestamp)}) ) : ( {getFormattedDate(timestamp)} )}{' '} {cid ? ( !cid && e.preventDefault()} > No. { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onReplyModalClick(); } }} > {post?.number || '?'} ) : ( <> No. {hasFailedState ? capitalize(t('failed')) : capitalize(t('pending'))} )} {shouldShowPendingApprovalButtons && (
{pendingStatus === 'approved' ? ( {t('approved')} ) : pendingStatus === 'rejected' ? ( {t('rejected')} ) : pendingStatus === 'failed' ? ( {t('failed')} {pendingErrorMessage ? `: ${pendingErrorMessage}` : ''} ) : isPublishingPending ? ( ) : ( <> )}
)}
{(hasThumbnail || link) && !(deleted || removed) && } ); }; const PostMediaContent = ({ post, link }: { post: any; link: string }) => { const [showThumbnail, setShowThumbnail] = useState(true); const { thumbnailUrl, linkWidth, linkHeight, spoiler, deleted, removed, parentCid } = post || {}; const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight); return ( commentMediaInfo && ( ) ); }; interface ReplyBacklinksProps extends PostProps { directRepliesByParentCid?: Map; } const ReplyBacklinks = ({ post, quotedByMap, directRepliesByParentCid }: ReplyBacklinksProps) => { const { cid, parentCid } = post || {}; const directReplies = directRepliesByParentCid?.get(cid) || []; const opBacklinks = cid && !parentCid && quotedByMap ?.get(cid) ?.map( (reply: Comment) => reply?.cid && reply?.number && !(reply?.deleted || reply?.removed) && , ) .filter(Boolean); const replyBacklinks = cid && parentCid && (directReplies.length > 0 || quotedByMap?.get(cid)?.length) && ( <> {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) && , )} ); return opBacklinks?.length > 0 || replyBacklinks ? (
{opBacklinks} {replyBacklinks}
) : null; }; const Reply = ({ postReplyCount, reply, roles, threadNumber, quotedByMap, directRepliesByParentCid, }: PostProps & { directRepliesByParentCid?: Map }) => { const accountReply = useAccountComment({ 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; } const { author, cid, deleted, postCid, reason, removed, subplebbitAddress } = post || {}; const directories = useDirectories(); const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, 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 }); return (
{!hidden && (!(removed || deleted) || ((removed || deleted) && reason)) && }
); }; const PostMobile = ({ post, roles, showAllReplies, showReplies = true, targetReplyCid, isModQueue, modQueueStatus, modQueueError, isPublishing, onApprove, onReject, }: PostProps) => { const { t } = useTranslation(); const { author, cid, pinned, postCid, replyCount, state, subplebbitAddress } = post || {}; const params = useParams(); const location = useLocation(); const navigationType = useNavigationType(); const isInPendingPostView = isPendingPostView(location.pathname, params); const isInPostView = isPostPageView(location.pathname, params); const directories = useDirectories(); const directoryEntry = directories?.find((c) => c.address === subplebbitAddress); const requirePostLinkIsMedia = directoryEntry?.features?.requirePostLinkIsMedia === true; const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, directories) : undefined; const linksCount = useCountLinksInReplies(post); const shouldFetchReplies = showReplies && !isModQueue; const previewRepliesResult = useReplies({ comment: shouldFetchReplies && !showAllReplies ? post : undefined, sortType: 'new', flat: true, repliesPerPage: BOARD_REPLIES_PREVIEW_FETCH_SIZE, accountComments: { newerThan: Infinity, append: true }, }); const fullRepliesResult = useReplies({ comment: shouldFetchReplies && showAllReplies ? post : undefined, sortType: 'old', flat: true, repliesPerPage: REPLIES_PER_PAGE, accountComments: { newerThan: Infinity, append: true }, }); const repliesResult = showAllReplies ? fullRepliesResult : previewRepliesResult; const { replies, hasMore, loadMore } = repliesResult; const updatedReplies = (repliesResult as { updatedReplies?: Comment[] }).updatedReplies; const repliesForRender = updatedReplies?.length ? updatedReplies : replies || []; const reset = (repliesResult as { reset?: () => Promise }).reset; const setResetFunction = useFeedResetStore((s) => s.setResetFunction); useEffect(() => { if ((isInPostView || isInPendingPostView) && reset) { setResetFunction(() => { reset(); }); } }, [isInPostView, isInPendingPostView, reset, setResetFunction]); const registerComments = usePostNumberStore((s) => s.registerComments); const prevCidsRef = useRef(''); useEffect(() => { const all = post ? [post, ...repliesForRender] : repliesForRender; if (!all.length) return; const cidsKey = all .map((c) => c?.cid) .filter(Boolean) .sort() .join(','); if (cidsKey === prevCidsRef.current) return; prevCidsRef.current = cidsKey; registerComments(all); }, [post, repliesForRender, registerComments]); const isInPostPageView = isPostPageView(location.pathname, params); const { hidden, unhide } = useHide({ cid }); const stateString = useStateString(post) || t('loading_post'); const hasFailedState = state === 'failed'; // Filter out deleted replies with no children for both virtuoso and non-virtuoso rendering const filteredReplies = repliesForRender.filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount))); const previewDisplayReplies = getPreviewDisplayReplies(filteredReplies, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT); const directRepliesByParentCid = (() => { 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; })(); const quotedByMap = useQuotedByMap(filteredReplies, subplebbitAddress); 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-mobile-${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 ( <> {hidden && !isInPostPageView ? ( <>
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); unhide(); } }} > Show Hidden Thread ) : (
{(showReplies || isModQueue) && (

)}
{shouldShowSnow() && }
{!isInPostView && !isInPendingPostView && (showReplies || isModQueue) && (
{replyCount > 0 && `${replyCount} Replies`} {linksCount > 0 && ` / ${linksCount} ${requirePostLinkIsMedia ? 'Images' : 'Links'}`} {isModQueue ? (
{modQueueStatus === 'approved' ? ( {t('approved')} ) : modQueueStatus === 'rejected' ? ( {t('rejected')} ) : modQueueStatus === 'failed' ? ( {t('failed')} {modQueueError ? `: ${modQueueError}` : ''} ) : isPublishing ? ( ) : ( <> )}
) : ( {t('view_thread')} )}
)}
{/* Virtuoso infinite scroll for post page view when there's more content to paginate */} {showAllReplies && !isInPendingPostView && showReplies && hasMore && ( (
)} 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 */} {showAllReplies && !isInPendingPostView && showReplies && !hasMore && visibleReplies.map((reply) => (
))} {/* Non-virtualized rendering for board view (last 5 replies) */} {!showAllReplies && !isInPendingPostView && repliesForRender && showReplies && previewDisplayReplies.map((reply) => (
))}
{!isInPendingPostView && stateString && !hasFailedState && state !== 'succeeded' && isInPostPageView && !(!showReplies && !showAllReplies) ? (
) : ( hasFailedState && {t('failed')} )}
)} ); }; export default PostMobile;