2026-01-26 16:31:15 +08:00
import { useEffect , useMemo , useRef , useState , useCallback } from 'react' ;
2025-02-22 22:22:42 +01:00
import { useTranslation } from 'react-i18next' ;
2025-12-27 18:07:23 +01:00
import { Link , useLocation , useNavigationType , useParams } from 'react-router-dom' ;
import { Virtuoso , VirtuosoHandle , StateSnapshot } from 'react-virtuoso' ;
2026-01-26 16:31:15 +08:00
import { Comment , useAuthorAvatar , useEditedComment , useReplies , useAccount , usePublishCommentModeration } from '@plebbit/plebbit-react-hooks' ;
2025-05-12 23:54:44 +02:00
import Plebbit from '@plebbit/plebbit-js' ;
2024-06-11 15:49:26 +02:00
import styles from '../../views/post/post.module.css' ;
2025-02-22 22:22:42 +01:00
import { shouldShowSnow } from '../../lib/snow' ;
2024-12-05 15:37:23 +01:00
import { getHasThumbnail } from '../../lib/utils/media-utils' ;
2024-08-13 18:27:56 +02:00
import { getTextColorForBackground , hashStringToColor } from '../../lib/utils/post-utils' ;
2024-06-27 11:03:58 +02:00
import { getFormattedDate , getFormattedTimeAgo } from '../../lib/utils/time-utils' ;
2026-01-25 16:29:15 +08:00
import { isAllView , isModQueueView , isPendingPostView , isPostPageView , isSubscriptionsView } from '../../lib/utils/view-utils' ;
2026-02-10 14:06:43 +08:00
import { formatUserIDForDisplay } from '../../lib/utils/string-utils' ;
2026-01-25 16:29:15 +08:00
import useModQueueStore from '../../stores/use-mod-queue-store' ;
2026-01-28 16:51:27 +08:00
import { useDirectories } from '../../hooks/use-directories' ;
2025-11-14 14:52:13 +01:00
import { getBoardPath } from '../../lib/utils/route-utils' ;
2024-09-03 10:14:15 +02:00
import useAvatarVisibilityStore from '../../stores/use-avatar-visibility-store' ;
2024-06-28 22:09:11 +02:00
import useAuthorAddressClick from '../../hooks/use-author-address-click' ;
2024-12-05 15:37:23 +01:00
import { useCommentMediaInfo } from '../../hooks/use-comment-media-info' ;
2024-06-11 15:49:26 +02:00
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies' ;
2024-06-14 12:51:43 +02:00
import useHide from '../../hooks/use-hide' ;
2024-06-11 15:49:26 +02:00
import useStateString from '../../hooks/use-state-string' ;
2026-01-19 16:02:45 +01:00
import useScrollToReply from '../../hooks/use-scroll-to-reply' ;
2026-02-09 16:22:18 +08:00
import { useSubplebbitField } from '../../hooks/use-stable-subplebbit' ;
2025-02-22 22:22:42 +01:00
import CommentContent from '../comment-content' ;
2024-06-11 15:49:26 +02:00
import CommentMedia from '../comment-media' ;
import LoadingEllipsis from '../loading-ellipsis' ;
2024-05-28 19:03:43 +02:00
import PostMenuMobile from './post-menu-mobile' ;
2024-06-27 11:03:58 +02:00
import ReplyQuotePreview from '../reply-quote-preview' ;
import Tooltip from '../tooltip' ;
2024-06-11 15:49:26 +02:00
import { PostProps } from '../../views/post/post' ;
2026-02-11 19:01:09 +08:00
import { capitalize , lowerCase } from 'lodash' ;
2025-03-05 12:01:48 +01:00
import useReplyModalStore from '../../stores/use-reply-modal-store' ;
2025-12-05 17:38:17 +01:00
import { selectPostMenuProps } from '../../lib/utils/post-menu-props' ;
2026-01-26 16:31:15 +08:00
import useChallengesStore from '../../stores/use-challenges-store' ;
2026-02-12 17:44:26 +08:00
import useFeedResetStore from '../../stores/use-feed-reset-store' ;
2026-02-10 16:23:06 +08:00
import usePostNumberStore from '../../stores/use-post-number-store' ;
2026-01-26 16:31:15 +08:00
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils' ;
2026-02-12 18:41:21 +08:00
import useQuotedByMap from '../../hooks/use-quoted-by-map' ;
2026-01-26 16:31:15 +08:00
const { addChallenge } = useChallengesStore . getState ();
2024-05-16 23:05:00 +02:00
2025-12-27 18:07:23 +01:00
// Store scroll position for replies virtuoso across navigations
const lastVirtuosoStates : { [ key : string ] : StateSnapshot } = {};
2025-12-28 22:16:38 +01:00
const PostInfoAndMedia = ({ post , postReplyCount = 0 , roles , threadNumber } : PostProps ) => {
2024-05-16 23:05:00 +02:00
const { t } = useTranslation ();
2026-01-28 16:51:27 +08:00
const directories = useDirectories ();
2025-12-24 18:07:14 +01:00
const { author , cid , deleted , link , linkHeight , linkWidth , locked , parentCid , pinned , postCid , reason , removed , state , subplebbitAddress , timestamp , thumbnailUrl } =
post || {};
2026-01-28 16:51:27 +08:00
const boardPath = subplebbitAddress ? getBoardPath ( subplebbitAddress , directories ) : undefined ;
2024-12-05 15:37:23 +01:00
const isReply = parentCid ;
2024-06-27 11:03:58 +02:00
const title = post ? . title ? . trim ();
const { address , shortAddress } = author || {};
const displayName = author ? . displayName ? . trim ();
2025-12-23 19:33:46 +01:00
const authorRole = roles ? .[ address ] ? . role ? . replace ( 'moderator' , 'mod' );
2024-07-12 22:06:14 +02:00
const { imageUrl : avatarImageUrl } = useAuthorAvatar ({ author });
2024-09-03 10:14:15 +02:00
const { hideAvatars } = useAvatarVisibilityStore ();
2024-05-16 23:05:00 +02:00
2024-06-29 11:55:36 +02:00
const params = useParams ();
2024-05-17 15:29:03 +02:00
const location = useLocation ();
2024-10-29 17:40:09 +01:00
const isInAllView = isAllView ( location . pathname );
2024-06-29 11:55:36 +02:00
const isInPostPageView = isPostPageView ( location . pathname , params );
const isInSubscriptionsView = isSubscriptionsView ( location . pathname , params );
2026-01-25 16:29:15 +08:00
const isInModQueueView = isModQueueView ( location . pathname );
const { getAlertThresholdSeconds } = useModQueueStore ();
2026-01-26 16:31:15 +08:00
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 ;
2024-05-17 15:29:03 +02:00
2025-03-03 23:47:33 +01:00
const commentMediaInfo = useCommentMediaInfo ( link , thumbnailUrl , linkWidth , linkHeight );
2024-05-16 23:05:00 +02:00
const hasThumbnail = getHasThumbnail ( commentMediaInfo , link );
2026-01-25 16:29:15 +08:00
// 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 ? Date . now () / 1000 - timestamp : 0 ;
const alertThresholdSeconds = getAlertThresholdSeconds ();
const isOverThreshold = isAwaitingApproval && timeWaiting > alertThresholdSeconds ;
2024-05-16 23:05:00 +02:00
const stateString = useStateString ( post );
2025-12-05 17:38:17 +01:00
const postMenuProps = useMemo (() => selectPostMenuProps ( post ), [ post ]);
2024-05-16 23:05:00 +02:00
2026-02-09 16:22:18 +08:00
const pseudonymityMode = useSubplebbitField ( subplebbitAddress , ( sub ) => sub ? . features ? . pseudonymityMode );
const showUserID = pseudonymityMode !== 'per-reply' ;
2026-02-12 20:00:32 +08:00
const handleUserAddressClick = useAuthorAddressClick ();
const numberOfPostsByAuthor = useMemo (() => {
if ( ! showUserID || deleted || removed || ! shortAddress || ! postCid || typeof document === 'undefined' ) {
return 0 ;
}
return document . querySelectorAll ( `[data-author-address=" ${ shortAddress } "][data-post-cid=" ${ postCid } "]` ). length ;
}, [ showUserID , deleted , removed , shortAddress , postCid ]);
2026-02-10 13:45:55 +08:00
const userID = address && Plebbit . 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
2024-09-19 17:33:00 +02:00
const userIDBackgroundColor = hashStringToColor ( userID );
2024-08-13 18:27:56 +02:00
const userIDTextColor = getTextColorForBackground ( userIDBackgroundColor );
2024-09-18 17:08:42 +02:00
const { hidden } = useHide ( post );
2025-03-05 12:01:48 +01:00
const { openReplyModal } = useReplyModalStore ();
2025-03-02 12:10:16 +01:00
const onReplyModalClick = () => {
deleted
? isReply
? alert ( t ( 'this_reply_was_deleted' ))
: alert ( t ( 'this_thread_was_deleted' ))
: removed
2025-12-27 17:47:17 +01:00
? isReply
? alert ( t ( 'this_reply_was_removed' ))
: alert ( t ( 'this_thread_was_removed' ))
2025-12-28 22:16:38 +01:00
: openReplyModal && openReplyModal ( cid , post ? . number , postCid , threadNumber , subplebbitAddress );
2025-03-02 12:10:16 +01:00
};
2024-05-16 23:05:00 +02:00
return (
<>
< div className = { styles . postInfo }>
2025-12-05 17:38:17 +01:00
< PostMenuMobile postMenu = { postMenuProps } editMenuPost = { post } />
2024-09-21 13:01:23 +02:00
< span className = {( hidden || (( removed || deleted ) && ! reason )) && parentCid ? styles . postDesktopHidden : '' }>
2024-09-18 17:08:42 +02:00
< span className = { styles . nameBlock }>
2025-11-28 13:29:31 +01:00
< span className = { ` ${ styles . name } ${ authorRole && ! ( deleted || removed ) && ( authorRole === 'mod' ? styles.capcodeMod : styles.capcodeAdmin ) } ` }>
2024-08-25 18:15:27 +02:00
{ removed ? (
2026-02-11 19:01:09 +08:00
capitalize ( t ( 'removed' ))
2024-08-25 18:15:27 +02:00
) : deleted ? (
2026-02-11 19:01:09 +08:00
capitalize ( t ( 'deleted' ))
2024-09-18 17:08:42 +02:00
) : displayName ? (
displayName . length <= 20 ? (
displayName
) : (
< Tooltip
children = { displayName . slice ( 0 , 20 ) + '(...)' }
content = { displayName . length < 1000 ? displayName : displayName.slice ( 0 , 1000 ) + `... ${ t ( 'display_name_too_long' ) } ` }
/>
)
2024-08-25 18:15:27 +02:00
) : (
2026-02-11 19:01:09 +08:00
capitalize ( t ( 'anonymous' ))
2025-12-24 18:07:14 +01:00
)}{ ' ' }
2025-11-28 13:29:31 +01:00
{ ! ( deleted || removed ) && authorRole && (
< span className = 'capitalize' >
{ ' ' }
## Board { authorRole }{ ' ' }
< span className = { styles . capcodeIconMobileWrapper }>
< span
className = { ` ${ styles . capcodeIconMobile } ${ authorRole === 'mod' ? styles.capcodeModIcon : styles.capcodeAdminIcon } ` }
title = { authorRole === 'mod' ? t ( 'moderator_of_this_board' ) : t ( 'administrator_of_this_board' )}
/>
</ span >
& nbsp ;
</ span >
)}
2024-05-16 23:05:00 +02:00
</ span >
2025-12-23 19:14:25 +01:00
{ author ? . avatar && ! ( deleted || removed ) && ! hideAvatars && avatarImageUrl ? (
< span className = { styles . authorAvatar }>
< img src = { avatarImageUrl } alt = '' />
</ span >
) : null }
2026-02-09 16:22:18 +08:00
{ showUserID && (
<>
( ID : { '' }
{ removed ? (
2026-02-11 19:01:09 +08:00
lowerCase ( t ( 'removed' ))
2026-02-09 16:22:18 +08:00
) : deleted ? (
2026-02-11 19:01:09 +08:00
lowerCase ( t ( 'deleted' ))
2026-02-09 16:22:18 +08:00
) : (
< Tooltip
children = {
< span
title = { t ( 'highlight_posts' )}
className = { styles . userAddress }
onClick = {() => handleUserAddressClick ( userID , postCid )}
style = {{ backgroundColor : userIDBackgroundColor , color : userIDTextColor }}
>
2026-02-10 14:06:43 +08:00
{ formatUserIDForDisplay ( userID )}
2026-02-09 16:22:18 +08:00
</ span >
}
content = { ` ${ numberOfPostsByAuthor === 1 ? t ( '1_post_by_this_id' ) : t ( 'x_posts_by_this_id' , { number : numberOfPostsByAuthor } )}` }
showTooltip = { isInPostPageView || postReplyCount < 6 }
/>
)}
){ ' ' }
</>
2024-09-18 17:08:42 +02:00
)}
{ pinned && (
< span className = { styles . stickyIconWrapper }>
< img src = 'assets/icons/sticky.gif' alt = '' className = { styles . stickyIcon } title = { t ( 'sticky' )} />
</ span >
)}
{ locked && (
< span className = { ` ${ styles . closedIconWrapper } ${ pinned && styles . addPaddingInBetween } ` }>
< img src = 'assets/icons/closed.gif' alt = '' className = { styles . closedIcon } title = { t ( 'closed' )} />
</ span >
)}
{ title && (
< span className = { styles . subjectWrapper }>
{ title . length <= 30 ? (
< span className = { styles . subject }>{ title }</ span >
) : (
< Tooltip
children = {< span className = { styles . subject }>{ title . slice ( 0 , 30 ) + '(...)' }</ span >}
content = { title . length < 1000 ? title : title.slice ( 0 , 1000 ) + `... ${ t ( 'title_too_long' ) } ` }
/>
)}
</ span >
)}
</ span >
< span className = { styles . dateTimePostNum }>
2025-11-07 18:34:06 +01:00
{ subplebbitAddress && ( isInAllView || isInSubscriptionsView ) && ! isReply && boardPath && (
2024-09-18 17:08:42 +02:00
< div className = { styles . postNumLink }>
{ ' ' }
2025-11-14 14:52:13 +01:00
< Link to = { `/ ${ boardPath } ` }> Board : { boardPath }</ Link >
2024-09-18 17:08:42 +02:00
</ div >
)}
2026-01-25 16:29:15 +08:00
{ isInModQueueView && isOverThreshold ? (
<>
< Tooltip children = {< span >{ getFormattedDate ( timestamp )}</ span >} content = { getFormattedTimeAgo ( timestamp )} /> (
< span className = { styles . alert }>{ getFormattedTimeAgo ( timestamp )}</ span >)
</>
) : (
< Tooltip children = {< span >{ getFormattedDate ( timestamp )}</ span >} content = { getFormattedTimeAgo ( timestamp )} />
)}{ ' ' }
2025-12-23 19:14:25 +01:00
{ cid ? (
< span className = { styles . postNumLink }>
< Link
to = { boardPath ? `/ ${ boardPath } /thread/ ${ cid } ` : `/thread/ ${ cid } ` }
className = { styles . linkToPost }
title = { t ( 'link_to_post' )}
onClick = {( e ) => ! cid && e . preventDefault ()}
>
2025-12-24 18:07:14 +01:00
No .
2025-12-23 19:14:25 +01:00
</ Link >
< span className = { styles . replyToPost } title = { t ( 'reply_to_post' )} onMouseDown = { onReplyModalClick }>
2025-12-24 18:07:14 +01:00
{ post ? . number || '?' }
2024-09-18 17:08:42 +02:00
</ span >
2025-12-23 19:14:25 +01:00
</ span >
) : (
<>
2026-01-18 18:13:35 +01:00
< span > No .</ span >
2025-12-23 19:14:25 +01:00
< span className = { styles . pendingCid }>
2026-02-11 19:01:09 +08:00
{ state === 'failed' || stateString === 'Failed' ? capitalize ( t ( 'failed' )) : state === 'pending' ? capitalize ( t ( 'pending' )) : '' }
2025-12-23 19:14:25 +01:00
</ span >
</>
)}
2026-01-26 16:31:15 +08:00
{ shouldShowPendingApprovalButtons && (
< div 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' )} />
) : (
<>
< button className = { `button ${ styles . approveButton } ` } onClick = { handlePendingApprove } disabled = { isPublishingPending }>
{ t ( 'approve' )}
</ button >
< button className = { `button ${ styles . rejectButton } ` } onClick = { handlePendingReject } disabled = { isPublishingPending }>
{ t ( 'reject' )}
</ button >
</>
)}
</ div >
)}
2024-09-18 17:08:42 +02:00
</ span >
2024-05-16 23:05:00 +02:00
</ span >
</ div >
2025-03-03 23:47:33 +01:00
{( hasThumbnail || link ) && ! ( deleted || removed ) && < PostMediaContent key = { cid } post = { post } link = { link } />}
2024-05-16 23:05:00 +02:00
</>
);
};
2025-03-03 23:47:33 +01:00
const PostMediaContent = ({ post , link } : { post : any ; link : string }) => {
2024-11-21 17:23:14 +01:00
const [ showThumbnail , setShowThumbnail ] = useState ( true );
2025-03-03 23:47:33 +01:00
const { thumbnailUrl , linkWidth , linkHeight , spoiler , deleted , removed , parentCid } = post || {};
const commentMediaInfo = useCommentMediaInfo ( link , thumbnailUrl , linkWidth , linkHeight );
2024-11-21 17:23:14 +01:00
const hasThumbnail = getHasThumbnail ( commentMediaInfo , link );
2024-11-29 21:53:53 +01:00
return (
hasThumbnail && (
< CommentMedia
commentMediaInfo = { commentMediaInfo }
2025-03-03 23:47:33 +01:00
deleted = { deleted }
removed = { removed }
linkHeight = { linkHeight }
linkWidth = { linkWidth }
2024-11-29 21:53:53 +01:00
showThumbnail = { showThumbnail }
setShowThumbnail = { setShowThumbnail }
2025-03-03 23:47:33 +01:00
parentCid = { parentCid }
spoiler = { spoiler }
2024-11-29 21:53:53 +01:00
/>
)
);
2024-11-21 17:23:14 +01:00
};
2026-02-11 19:06:29 +08:00
const ReplyBacklinks = ({ post , quotedByMap } : PostProps ) => {
2024-08-31 14:57:11 +02:00
const { cid , parentCid } = post || {};
2026-01-26 15:36:39 +08:00
const { replies } = useReplies ({ comment : post , flat : true , accountComments : { newerThan : Infinity } });
2024-06-04 17:48:53 +02:00
2026-02-11 18:43:39 +08:00
const opBacklinks =
2024-08-31 14:57:11 +02:00
cid &&
2026-02-11 18:43:39 +08:00
! parentCid &&
2026-02-11 18:52:10 +08:00
quotedByMap
? . get ( cid )
? . map (
( reply : Comment ) =>
reply ? . cid && ! ( reply ? . deleted || reply ? . removed ) && < ReplyQuotePreview key = { `op-bl- ${ reply . cid } ` } isBacklinkReply = { true } backlinkReply = { reply } />,
)
2026-02-11 18:43:39 +08:00
. filter ( Boolean );
const replyBacklinks = cid && parentCid && (( replies ? . length || 0 ) > 0 || quotedByMap ? . get ( cid ) ? . length ) && (
<>
{ replies ? . map (
( reply : Comment , index : number ) =>
reply ? . parentCid === cid && reply ? . cid && ! ( reply ? . deleted || reply ? . removed ) && < ReplyQuotePreview key = { index } isBacklinkReply = { true } backlinkReply = { reply } />,
)}
{ quotedByMap
? . get ( cid )
? . map (
2024-12-01 15:08:40 +01:00
( reply : Comment , index : number ) =>
2026-02-11 18:43:39 +08:00
reply ? . parentCid !== cid &&
2024-12-01 15:08:40 +01:00
reply ? . cid &&
2026-02-11 18:43:39 +08:00
! ( reply ? . deleted || reply ? . removed ) && < ReplyQuotePreview key = { `qb- ${ index } ` } isBacklinkReply = { true } backlinkReply = { reply } />,
2024-08-31 14:57:11 +02:00
)}
2026-02-11 18:43:39 +08:00
</>
2024-06-04 17:48:53 +02:00
);
2026-02-11 18:43:39 +08:00
return opBacklinks ? . length > 0 || replyBacklinks ? (
< div className = { styles . mobileReplyBacklinks }>
{ opBacklinks }
{ replyBacklinks }
</ div >
) : null ;
2024-06-04 17:48:53 +02:00
};
2026-02-09 19:56:07 +08:00
const Reply = ({ postReplyCount , reply , roles , threadNumber , quotedByMap } : PostProps ) => {
2024-06-21 22:02:56 +02:00
let post = reply ;
// handle pending mod or author edit
const { editedComment } = useEditedComment ({ comment : reply });
if ( editedComment ) {
post = editedComment ;
}
2024-09-21 12:59:16 +02:00
const { author , cid , deleted , postCid , reason , removed , subplebbitAddress } = post || {};
2026-01-28 16:51:27 +08:00
const directories = useDirectories ();
const boardPath = subplebbitAddress ? getBoardPath ( subplebbitAddress , directories ) : undefined ;
2025-11-07 18:34:06 +01:00
const location = useLocation ();
const route = boardPath ? `/ ${ boardPath } /thread/ ${ cid } ` : `/thread/ ${ cid } ` ;
const isRouteLinkToReply = cid ? location . pathname . startsWith ( route ) : false ;
2024-06-14 12:51:43 +02:00
const { hidden } = useHide ({ cid });
return (
< div className = { styles . replyMobile }>
< div className = { styles . reply }>
2024-06-28 22:09:11 +02:00
< div
2024-09-18 17:08:42 +02:00
className = { ` ${ styles . replyContainer } ${ isRouteLinkToReply && styles . highlight } ` }
2024-06-29 11:55:36 +02:00
data-cid = { cid }
2024-07-01 10:26:38 +02:00
data-author-address = { author ? . shortAddress }
data-post-cid = { postCid }
2024-06-28 22:09:11 +02:00
>
2025-12-28 22:16:38 +01:00
< PostInfoAndMedia post = { post } postReplyCount = { postReplyCount } roles = { roles } threadNumber = { threadNumber } />
2025-03-05 21:38:01 +01:00
{ ! hidden && ( ! ( removed || deleted ) || (( removed || deleted ) && reason )) && < CommentContent comment = { post } />}
2026-02-11 18:43:39 +08:00
< ReplyBacklinks post = { post } quotedByMap = { quotedByMap } />
2024-06-14 12:51:43 +02:00
</ div >
</ div >
</ div >
);
};
2026-01-25 16:29:15 +08:00
const PostMobile = ({
post ,
roles ,
showAllReplies ,
showReplies = true ,
targetReplyCid ,
isModQueue ,
modQueueStatus ,
modQueueError ,
isPublishing ,
onApprove ,
onReject ,
} : PostProps ) => {
2024-05-16 23:05:00 +02:00
const { t } = useTranslation ();
2024-09-21 12:50:24 +02:00
const { author , cid , pinned , postCid , replyCount , state , subplebbitAddress } = post || {};
2024-05-17 15:29:03 +02:00
const params = useParams ();
const location = useLocation ();
2025-12-27 18:07:23 +01:00
const navigationType = useNavigationType ();
2024-05-17 15:29:03 +02:00
const isInPendingPostView = isPendingPostView ( location . pathname , params );
const isInPostView = isPostPageView ( location . pathname , params );
2026-01-28 16:51:27 +08:00
const directories = useDirectories ();
const boardPath = subplebbitAddress ? getBoardPath ( subplebbitAddress , directories ) : undefined ;
2024-05-16 23:05:00 +02:00
const linksCount = useCountLinksInReplies ( post );
2026-02-12 17:44:26 +08:00
const repliesResult = useReplies ({ comment : post , accountComments : { newerThan : Infinity } });
const { replies , hasMore , loadMore } = repliesResult ;
const reset = ( repliesResult as { reset ?: () => Promise < void > }). reset ;
const setResetFunction = useFeedResetStore (( s ) => s . setResetFunction );
useEffect (() => {
if (( isInPostView || isInPendingPostView ) && reset ) {
setResetFunction (() => {
reset ();
});
}
}, [ isInPostView , isInPendingPostView , reset , setResetFunction ]);
2026-02-10 16:23:06 +08:00
const registerComments = usePostNumberStore (( s ) => s . registerComments );
const prevCidsRef = useRef < string >( '' );
useEffect (() => {
const all = post ? [ post , ...( replies || [])] : replies || [];
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 , replies , registerComments ]);
2024-05-16 23:05:00 +02:00
2024-06-14 12:51:43 +02:00
const isInPostPageView = isPostPageView ( location . pathname , params );
const { hidden , unhide } = useHide ({ cid });
2025-02-20 13:29:24 +01:00
const stateString = useStateString ( post ) || t ( 'loading_post' );
2024-07-21 12:17:40 +02:00
2025-12-27 18:07:23 +01:00
// Filter out deleted replies with no children for both virtuoso and non-virtuoso rendering
const filteredReplies = useMemo (() => ( replies || []). filter (( reply ) => ! ( reply . deleted && ( reply . replyCount === 0 || ! reply . replyCount ))), [ replies ]);
2026-02-12 18:41:21 +08:00
const quotedByMap = useQuotedByMap ( filteredReplies );
2026-02-09 19:56:07 +08:00
2025-12-27 18:07:23 +01:00
// Virtuoso scroll position management for infinite replies
const virtuosoRef = useRef < VirtuosoHandle | null >( 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 );
return () => window . removeEventListener ( 'scroll' , setLastVirtuosoState );
}, [ virtuosoStateKey , showAllReplies , isInPostPageView ]);
const lastVirtuosoState = navigationType === 'POP' ? lastVirtuosoStates ? .[ virtuosoStateKey ] : undefined ;
2026-01-19 16:02:45 +01:00
const shouldScrollToReply = showAllReplies && showReplies && ! isInPendingPostView && !! targetReplyCid ;
useScrollToReply ({
targetReplyCid ,
replies : filteredReplies ,
hasMore ,
loadMore ,
virtuosoRef ,
enabled : shouldScrollToReply ,
});
2025-12-27 18:07:23 +01:00
// Footer component for Virtuoso showing loading state
const RepliesFooter = () =>
hasMore ? (
< div className = { styles . stateString }>
< LoadingEllipsis string = { t ( 'loading' )} />
</ div >
) : null ;
2024-05-16 23:05:00 +02:00
return (
2024-06-14 12:51:43 +02:00
<>
{ hidden && ! isInPostPageView ? (
<>
< hr className = { styles . unhideButtonHr } />
< span className = { styles . mobileUnhideButton }>
< span className = 'button' onClick = { unhide }>
Show Hidden Thread
</ span >
</ span >
</>
) : (
< div className = { styles . postMobile }>
2026-01-25 16:29:15 +08:00
{( showReplies || isModQueue ) && (
2024-06-14 12:51:43 +02:00
< div className = { styles . hrWrapper }>
< hr />
2024-05-16 23:05:00 +02:00
</ div >
)}
2026-01-25 16:29:15 +08:00
< div className = { showReplies || isModQueue ? styles.thread : styles.quotePreview }>
2024-06-14 12:51:43 +02:00
< div className = { styles . postContainer }>
2024-12-24 17:13:12 +01:00
< div
className = { ` ${ styles . postOp } ${ shouldShowSnow () ? styles . xmasHatWrapper : '' } ` }
data-cid = { cid }
data-author-address = { author ? . shortAddress }
data-post-cid = { postCid }
>
2025-05-22 13:15:40 +02:00
{ shouldShowSnow () && < img src = 'assets/xmashat.gif' className = { styles . xmasHat } alt = '' />}
2025-12-28 22:16:38 +01:00
< PostInfoAndMedia post = { post } postReplyCount = { replyCount } roles = { roles } threadNumber = { post ? . number } />
2025-03-05 21:38:01 +01:00
< CommentContent comment = { post } />
2026-02-11 19:06:29 +08:00
< ReplyBacklinks post = { post } quotedByMap = { quotedByMap } />
2024-05-16 23:05:00 +02:00
</ div >
2026-01-25 16:29:15 +08:00
{ ! isInPostView && ! isInPendingPostView && ( showReplies || isModQueue ) && (
2024-06-14 12:51:43 +02:00
< div className = { styles . postLink }>
< span className = { styles . info }>
{ replyCount > 0 && ` ${ replyCount } Replies` }
{ linksCount > 0 && ` / ${ linksCount } Links` }
</ span >
2026-01-25 16:29:15 +08:00
{ isModQueue ? (
< div className = { styles . modQueueActions }>
{ modQueueStatus === 'approved' ? (
< span className = { styles . modQueueStatusApproved }>{ t ( 'approved' )}</ span >
) : modQueueStatus === 'rejected' ? (
< span className = { styles . modQueueStatusRejected }>{ t ( 'rejected' )}</ span >
) : modQueueStatus === 'failed' ? (
< span className = { styles . modQueueStatusRejected }>
{ t ( 'failed' )}
{ modQueueError ? `: ${ modQueueError } ` : '' }
</ span >
) : isPublishing ? (
< LoadingEllipsis string = { t ( 'publishing' )} />
) : (
<>
< button className = { `button ${ styles . approveButton } ` } onClick = { onApprove } disabled = { isPublishing }>
{ t ( 'approve' )}
</ button >
< button className = { `button ${ styles . rejectButton } ` } onClick = { onReject } disabled = { isPublishing }>
{ t ( 'reject' )}
</ button >
</>
)}
</ div >
) : (
< Link to = { boardPath ? `/ ${ boardPath } /thread/ ${ cid } ` : `/thread/ ${ cid } ` } className = 'button' >
{ t ( 'view_thread' )}
</ Link >
)}
2024-06-14 12:51:43 +02:00
</ div >
)}
</ div >
2026-01-13 17:01:51 +01:00
{ /* Virtuoso infinite scroll for post page view when there's more content to paginate */ }
{ ! ( pinned && ! isInPostView ) && showAllReplies && ! isInPendingPostView && showReplies && hasMore && (
2025-12-27 18:07:23 +01:00
< Virtuoso
increaseViewportBy = {{ bottom : 1200 , top : 1200 }}
totalCount = { filteredReplies . length }
data = { filteredReplies }
itemContent = {( index , reply ) => (
< div className = { styles . replyContainer }>
2026-02-09 19:56:07 +08:00
< Reply postReplyCount = { replyCount } reply = { reply } roles = { roles } threadNumber = { post ? . number } quotedByMap = { quotedByMap } />
2025-12-27 18:07:23 +01:00
</ div >
)}
useWindowScroll = { true }
components = {{ Footer : RepliesFooter }}
endReached = { loadMore }
ref = { virtuosoRef }
restoreStateFrom = { lastVirtuosoState }
initialScrollTop = { lastVirtuosoState ? . scrollTop }
/>
)}
2026-01-13 17:01:51 +01:00
{ /* Non-virtualized rendering for post page view when all replies fit on one page */ }
2025-12-27 18:25:35 +01:00
{ ! ( pinned && ! isInPostView ) &&
showAllReplies &&
! isInPendingPostView &&
showReplies &&
2026-01-13 17:01:51 +01:00
! hasMore &&
2025-12-27 18:25:35 +01:00
filteredReplies . map (( reply , index ) => (
< div key = { index } className = { styles . replyContainer }>
2026-02-09 19:56:07 +08:00
< Reply postReplyCount = { replyCount } reply = { reply } roles = { roles } threadNumber = { post ? . number } quotedByMap = { quotedByMap } />
2025-12-27 18:25:35 +01:00
</ div >
))}
2025-12-27 18:07:23 +01:00
{ /* Non-virtualized rendering for board view (last 5 replies) */ }
2024-06-14 12:51:43 +02:00
{ ! ( pinned && ! isInPostView ) &&
2025-12-27 18:07:23 +01:00
! showAllReplies &&
2024-06-14 12:51:43 +02:00
! isInPendingPostView &&
replies &&
showReplies &&
2025-12-27 18:07:23 +01:00
filteredReplies . slice ( - 5 ). map (( reply , index ) => (
< div key = { index } className = { styles . replyContainer }>
2026-02-09 19:56:07 +08:00
< Reply postReplyCount = { replyCount } reply = { reply } roles = { roles } threadNumber = { post ? . number } quotedByMap = { quotedByMap } />
2025-12-27 18:07:23 +01:00
</ div >
))}
2024-06-14 12:51:43 +02:00
</ div >
2025-12-23 19:14:25 +01:00
{ ! isInPendingPostView && stateString && stateString !== 'Failed' && state !== 'succeeded' && isInPostPageView && ! ( ! showReplies && ! showAllReplies ) ? (
2025-02-23 16:24:31 +01:00
< div className = { styles . stateString }>
< LoadingEllipsis string = { stateString } />
</ div >
) : (
state === 'failed' && < span className = { styles . error }>{ t ( 'failed' )}</ span >
)}
2024-06-14 12:51:43 +02:00
</ div >
)}
</>
2024-05-16 23:05:00 +02:00
);
};
export default PostMobile ;