mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat: add infinite scroll for replies
This commit is contained in:
@@ -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}>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
Reference in New Issue
Block a user