feat: add infinite scroll for replies

This commit is contained in:
plebeius
2025-12-27 18:07:23 +01:00
parent 6e9202a604
commit 882b7b4a99
3 changed files with 132 additions and 24 deletions
+66 -11
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next'; 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 { Comment, useAuthorAvatar, useEditedComment, useReplies } from '@plebbit/plebbit-react-hooks';
import Plebbit from '@plebbit/plebbit-js'; import Plebbit from '@plebbit/plebbit-js';
import styles from '../../views/post/post.module.css'; 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 useReplyModalStore from '../../stores/use-reply-modal-store';
import { selectPostMenuProps } from '../../lib/utils/post-menu-props'; import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
// Store scroll position for replies virtuoso across navigations
const lastVirtuosoStates: { [key: string]: StateSnapshot } = {};
interface ShowOmittedRepliesState { interface ShowOmittedRepliesState {
showOmittedReplies: Record<string, boolean>; showOmittedReplies: Record<string, boolean>;
setShowOmittedReplies: (cid: string, showOmittedReplies: boolean) => void; 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 { author, cid, content, deleted, link, linkHeight, linkWidth, pinned, postCid, removed, spoiler, state, subplebbitAddress, thumbnailUrl, parentCid } = post || {};
const params = useParams(); const params = useParams();
const location = useLocation(); const location = useLocation();
const navigationType = useNavigationType();
const isInPendingPostView = isPendingPostView(location.pathname, params); const isInPendingPostView = isPendingPostView(location.pathname, params);
const isInPostPageView = isPostPageView(location.pathname, params); const isInPostPageView = isPostPageView(location.pathname, params);
const isInAllView = isAllView(location.pathname); const isInAllView = isAllView(location.pathname);
@@ -386,7 +391,7 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr
const { hidden, unhide, hide } = useHide({ cid }); const { hidden, unhide, hide } = useHide({ cid });
const isHidden = hidden && !isInPostPageView; const isHidden = hidden && !isInPostPageView;
const { replies } = useReplies({ comment: post }); const { replies, hasMore, loadMore } = useReplies({ comment: post });
const visiblelinksCount = useCountLinksInReplies(post, 5); const visiblelinksCount = useCountLinksInReplies(post, 5);
const totalLinksCount = useCountLinksInReplies(post); const totalLinksCount = useCountLinksInReplies(post);
const replyCount = replies?.length; const replyCount = replies?.length;
@@ -400,6 +405,38 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr
const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight); const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
const hasThumbnail = getHasThumbnail(commentMediaInfo, link); 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<VirtuosoHandle | null>(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 ? (
<div className={styles.stateString}>
<LoadingEllipsis string={t('loading')} />
</div>
) : null;
return ( return (
<div className={styles.postDesktop}> <div className={styles.postDesktop}>
{showReplies ? ( {showReplies ? (
@@ -461,19 +498,37 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr
)} )}
</span> </span>
)} )}
{/* Virtuoso infinite scroll for post page view with all replies */}
{!isHidden && showAllReplies && !isInPendingPostView && showReplies && filteredReplies.length > 0 && (
<Virtuoso
increaseViewportBy={{ bottom: 1200, top: 1200 }}
totalCount={filteredReplies.length}
data={filteredReplies}
itemContent={(index, reply) => (
<div className={styles.replyContainer}>
<Reply reply={reply} roles={roles} postReplyCount={replyCount} />
</div>
)}
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 && {!isHidden &&
!showAllReplies &&
!(pinned && !isInPostPageView && !showOmittedReplies[cid]) && !(pinned && !isInPostPageView && !showOmittedReplies[cid]) &&
!isInPendingPostView && !isInPendingPostView &&
replies && replies &&
showReplies && showReplies &&
(showAllReplies || showOmittedReplies[cid] ? replies : replies.slice(-5)) (showOmittedReplies[cid] ? filteredReplies : filteredReplies.slice(-5)).map((reply, index) => (
// Don't render deleted replies that have no children (replyCount = 0) <div key={index} className={styles.replyContainer}>
.filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount))) <Reply reply={reply} roles={roles} postReplyCount={replyCount} />
.map((reply, index) => ( </div>
<div key={index} className={styles.replyContainer}> ))}
<Reply reply={reply} roles={roles} postReplyCount={replyCount} />
</div>
))}
</div> </div>
{!isInPendingPostView && stateString && stateString !== 'Failed' && state !== 'succeeded' && isInPostPageView && !(!showReplies && !showAllReplies) ? ( {!isInPendingPostView && stateString && stateString !== 'Failed' && state !== 'succeeded' && isInPostPageView && !(!showReplies && !showAllReplies) ? (
<div className={styles.stateString}> <div className={styles.stateString}>
+66 -11
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 { Comment, useAuthorAvatar, useEditedComment, useReplies } from '@plebbit/plebbit-react-hooks';
import Plebbit from '@plebbit/plebbit-js'; import Plebbit from '@plebbit/plebbit-js';
import styles from '../../views/post/post.module.css'; import styles from '../../views/post/post.module.css';
@@ -28,6 +29,9 @@ import _ from 'lodash';
import useReplyModalStore from '../../stores/use-reply-modal-store'; import useReplyModalStore from '../../stores/use-reply-modal-store';
import { selectPostMenuProps } from '../../lib/utils/post-menu-props'; 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 PostInfoAndMedia = ({ post, postReplyCount = 0, roles }: PostProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const defaultSubplebbits = useDefaultSubplebbits(); 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 { author, cid, pinned, postCid, replyCount, state, subplebbitAddress } = post || {};
const params = useParams(); const params = useParams();
const location = useLocation(); const location = useLocation();
const navigationType = useNavigationType();
const isInPendingPostView = isPendingPostView(location.pathname, params); const isInPendingPostView = isPendingPostView(location.pathname, params);
const isInPostView = isPostPageView(location.pathname, params); const isInPostView = isPostPageView(location.pathname, params);
const defaultSubplebbits = useDefaultSubplebbits(); const defaultSubplebbits = useDefaultSubplebbits();
const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, defaultSubplebbits) : undefined; const boardPath = subplebbitAddress ? getBoardPath(subplebbitAddress, defaultSubplebbits) : undefined;
const linksCount = useCountLinksInReplies(post); const linksCount = useCountLinksInReplies(post);
const { replies } = useReplies({ comment: post }); const { replies, hasMore, loadMore } = useReplies({ comment: post });
const isInPostPageView = isPostPageView(location.pathname, params); const isInPostPageView = isPostPageView(location.pathname, params);
const { hidden, unhide } = useHide({ cid }); const { hidden, unhide } = useHide({ cid });
const stateString = useStateString(post) || t('loading_post'); 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<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;
// Footer component for Virtuoso showing loading state
const RepliesFooter = () =>
hasMore ? (
<div className={styles.stateString}>
<LoadingEllipsis string={t('loading')} />
</div>
) : null;
return ( return (
<> <>
{hidden && !isInPostPageView ? ( {hidden && !isInPostPageView ? (
@@ -337,18 +374,36 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true }: PostPro
</div> </div>
)} )}
</div> </div>
{/* Virtuoso infinite scroll for post page view with all replies */}
{!(pinned && !isInPostView) && showAllReplies && !isInPendingPostView && showReplies && filteredReplies.length > 0 && (
<Virtuoso
increaseViewportBy={{ bottom: 1200, top: 1200 }}
totalCount={filteredReplies.length}
data={filteredReplies}
itemContent={(index, reply) => (
<div className={styles.replyContainer}>
<Reply postReplyCount={replyCount} reply={reply} roles={roles} />
</div>
)}
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) && {!(pinned && !isInPostView) &&
!showAllReplies &&
!isInPendingPostView && !isInPendingPostView &&
replies && replies &&
showReplies && showReplies &&
(showAllReplies ? replies : replies.slice(-5)) filteredReplies.slice(-5).map((reply, index) => (
// Don't render deleted replies that have no children (replyCount = 0) <div key={index} className={styles.replyContainer}>
.filter((reply) => !(reply.deleted && (reply.replyCount === 0 || !reply.replyCount))) <Reply postReplyCount={replyCount} reply={reply} roles={roles} />
.map((reply, index) => ( </div>
<div key={index} className={styles.replyContainer}> ))}
<Reply postReplyCount={replyCount} reply={reply} roles={roles} />
</div>
))}
</div> </div>
{!isInPendingPostView && stateString && stateString !== 'Failed' && state !== 'succeeded' && isInPostPageView && !(!showReplies && !showAllReplies) ? ( {!isInPendingPostView && stateString && stateString !== 'Failed' && state !== 'succeeded' && isInPostPageView && !(!showReplies && !showAllReplies) ? (
<div className={styles.stateString}> <div className={styles.stateString}>
-2
View File
@@ -87,8 +87,6 @@ const PostPage = () => {
return ( return (
<div className={styles.content}> <div className={styles.content}>
{/* TODO: remove this replyCount error once api supports scrolling replies pages */}
{replyCount > 60 && <span className={styles.error}>Error: this thread has too many replies, some of them cannot be displayed right now.</span>}
{shouldShowErrorToUser && ( {shouldShowErrorToUser && (
<div className={styles.error}> <div className={styles.error}>
<ErrorDisplay error={error} /> <ErrorDisplay error={error} />