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 { 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<string, boolean>;
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<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 (
<div className={styles.postDesktop}>
{showReplies ? (
@@ -461,19 +498,37 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr
)}
</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 &&
!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) => (
<div key={index} className={styles.replyContainer}>
<Reply reply={reply} roles={roles} postReplyCount={replyCount} />
</div>
))}
(showOmittedReplies[cid] ? filteredReplies : filteredReplies.slice(-5)).map((reply, index) => (
<div key={index} className={styles.replyContainer}>
<Reply reply={reply} roles={roles} postReplyCount={replyCount} />
</div>
))}
</div>
{!isInPendingPostView && stateString && stateString !== 'Failed' && state !== 'succeeded' && isInPostPageView && !(!showReplies && !showAllReplies) ? (
<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 { 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<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 (
<>
{hidden && !isInPostPageView ? (
@@ -337,18 +374,36 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true }: PostPro
</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) &&
!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) => (
<div key={index} className={styles.replyContainer}>
<Reply postReplyCount={replyCount} reply={reply} roles={roles} />
</div>
))}
filteredReplies.slice(-5).map((reply, index) => (
<div key={index} className={styles.replyContainer}>
<Reply postReplyCount={replyCount} reply={reply} roles={roles} />
</div>
))}
</div>
{!isInPendingPostView && stateString && stateString !== 'Failed' && state !== 'succeeded' && isInPostPageView && !(!showReplies && !showAllReplies) ? (
<div className={styles.stateString}>
-2
View File
@@ -87,8 +87,6 @@ const PostPage = () => {
return (
<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 && (
<div className={styles.error}>
<ErrorDisplay error={error} />