diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index 4086853f..65d3c59d 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -1,6 +1,7 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; -import { Link, useLocation, useParams } from 'react-router-dom'; +import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom'; +import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso'; import { Comment, useAuthorAvatar, useEditedComment, useReplies } from '@plebbit/plebbit-react-hooks'; import Plebbit from '@plebbit/plebbit-js'; import styles from '../../views/post/post.module.css'; @@ -33,6 +34,9 @@ import { shouldShowSnow } from '../../lib/snow'; import useReplyModalStore from '../../stores/use-reply-modal-store'; import { selectPostMenuProps } from '../../lib/utils/post-menu-props'; +// Store scroll position for replies virtuoso across navigations +const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; + interface ShowOmittedRepliesState { showOmittedReplies: Record; setShowOmittedReplies: (cid: string, showOmittedReplies: boolean) => void; @@ -376,6 +380,7 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr const { author, cid, content, deleted, link, linkHeight, linkWidth, pinned, postCid, removed, spoiler, state, subplebbitAddress, thumbnailUrl, parentCid } = post || {}; 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); @@ -386,7 +391,7 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr const { hidden, unhide, hide } = useHide({ cid }); const isHidden = hidden && !isInPostPageView; - const { replies } = useReplies({ comment: post }); + const { replies, hasMore, loadMore } = useReplies({ comment: post }); const visiblelinksCount = useCountLinksInReplies(post, 5); const totalLinksCount = useCountLinksInReplies(post); const replyCount = replies?.length; @@ -400,6 +405,38 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight); const hasThumbnail = getHasThumbnail(commentMediaInfo, link); + // 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]); + + // 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); + return () => window.removeEventListener('scroll', setLastVirtuosoState); + }, [virtuosoStateKey, showAllReplies, isInPostPageView]); + + const lastVirtuosoState = navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined; + + // Footer component for Virtuoso showing loading state + const RepliesFooter = () => + hasMore ? ( +
+ +
+ ) : null; + return (
{showReplies ? ( @@ -461,19 +498,37 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr )} )} + {/* Virtuoso infinite scroll for post page view with all replies */} + {!isHidden && showAllReplies && !isInPendingPostView && showReplies && filteredReplies.length > 0 && ( + ( +
+ +
+ )} + useWindowScroll={true} + components={{ Footer: RepliesFooter }} + endReached={loadMore} + ref={virtuosoRef} + restoreStateFrom={lastVirtuosoState} + initialScrollTop={lastVirtuosoState?.scrollTop} + /> + )} + {/* Non-virtualized rendering for board view (last 5 replies or show omitted) */} {!isHidden && + !showAllReplies && !(pinned && !isInPostPageView && !showOmittedReplies[cid]) && !isInPendingPostView && replies && showReplies && - (showAllReplies || showOmittedReplies[cid] ? replies : replies.slice(-5)) - // Don't render deleted replies that have no children (replyCount = 0) - .filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount))) - .map((reply, index) => ( -
- -
- ))} + (showOmittedReplies[cid] ? filteredReplies : filteredReplies.slice(-5)).map((reply, index) => ( +
+ +
+ ))}
{!isInPendingPostView && stateString && stateString !== 'Failed' && state !== 'succeeded' && isInPostPageView && !(!showReplies && !showAllReplies) ? (
diff --git a/src/components/post-mobile/post-mobile.tsx b/src/components/post-mobile/post-mobile.tsx index 1c47a810..20bca1e9 100644 --- a/src/components/post-mobile/post-mobile.tsx +++ b/src/components/post-mobile/post-mobile.tsx @@ -1,6 +1,7 @@ -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Link, useLocation, useParams } from 'react-router-dom'; +import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom'; +import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso'; import { Comment, useAuthorAvatar, useEditedComment, useReplies } from '@plebbit/plebbit-react-hooks'; import Plebbit from '@plebbit/plebbit-js'; import styles from '../../views/post/post.module.css'; @@ -28,6 +29,9 @@ import _ from 'lodash'; import useReplyModalStore from '../../stores/use-reply-modal-store'; import { selectPostMenuProps } from '../../lib/utils/post-menu-props'; +// Store scroll position for replies virtuoso across navigations +const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; + const PostInfoAndMedia = ({ post, postReplyCount = 0, roles }: PostProps) => { const { t } = useTranslation(); const defaultSubplebbits = useDefaultSubplebbits(); @@ -283,18 +287,51 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true }: PostPro 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 defaultSubplebbits = useDefaultSubplebbits(); const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, defaultSubplebbits) : undefined; const linksCount = useCountLinksInReplies(post); - const { replies } = useReplies({ comment: post }); + const { replies, hasMore, loadMore } = useReplies({ comment: post }); const isInPostPageView = isPostPageView(location.pathname, params); const { hidden, unhide } = useHide({ cid }); const stateString = useStateString(post) || t('loading_post'); + // 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]); + + // 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); + return () => window.removeEventListener('scroll', setLastVirtuosoState); + }, [virtuosoStateKey, showAllReplies, isInPostPageView]); + + const lastVirtuosoState = navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined; + + // Footer component for Virtuoso showing loading state + const RepliesFooter = () => + hasMore ? ( +
+ +
+ ) : null; + return ( <> {hidden && !isInPostPageView ? ( @@ -337,18 +374,36 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true }: PostPro
)} + {/* Virtuoso infinite scroll for post page view with all replies */} + {!(pinned && !isInPostView) && showAllReplies && !isInPendingPostView && showReplies && filteredReplies.length > 0 && ( + ( +
+ +
+ )} + useWindowScroll={true} + components={{ Footer: RepliesFooter }} + endReached={loadMore} + ref={virtuosoRef} + restoreStateFrom={lastVirtuosoState} + initialScrollTop={lastVirtuosoState?.scrollTop} + /> + )} + {/* Non-virtualized rendering for board view (last 5 replies) */} {!(pinned && !isInPostView) && + !showAllReplies && !isInPendingPostView && replies && showReplies && - (showAllReplies ? replies : replies.slice(-5)) - // Don't render deleted replies that have no children (replyCount = 0) - .filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount))) - .map((reply, index) => ( -
- -
- ))} + filteredReplies.slice(-5).map((reply, index) => ( +
+ +
+ ))} {!isInPendingPostView && stateString && stateString !== 'Failed' && state !== 'succeeded' && isInPostPageView && !(!showReplies && !showAllReplies) ? (
diff --git a/src/views/post/post.tsx b/src/views/post/post.tsx index fbeae30f..ffbf20d9 100644 --- a/src/views/post/post.tsx +++ b/src/views/post/post.tsx @@ -87,8 +87,6 @@ const PostPage = () => { return (
- {/* TODO: remove this replyCount error once api supports scrolling replies pages */} - {replyCount > 60 && Error: this thread has too many replies, some of them cannot be displayed right now.} {shouldShowErrorToUser && (