fix(replies): reply permalink didn't auto-scroll to deep replies

This commit is contained in:
plebeius
2026-01-19 16:02:45 +01:00
parent c2bbe2dd0b
commit b8e3a0202a
5 changed files with 330 additions and 7 deletions
+12 -1
View File
@@ -19,6 +19,7 @@ import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame'; import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
import useHide from '../../hooks/use-hide'; import useHide from '../../hooks/use-hide';
import useStateString from '../../hooks/use-state-string'; import useStateString from '../../hooks/use-state-string';
import useScrollToReply from '../../hooks/use-scroll-to-reply';
import CommentContent from '../comment-content'; import CommentContent from '../comment-content';
import CommentMedia from '../comment-media'; import CommentMedia from '../comment-media';
import EditMenu from '../edit-menu/edit-menu'; import EditMenu from '../edit-menu/edit-menu';
@@ -375,7 +376,7 @@ const Reply = ({ postReplyCount, reply, roles, threadNumber }: PostProps) => {
); );
}; };
const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostProps) => { const PostDesktop = ({ post, roles, showAllReplies, showReplies = true, targetReplyCid }: PostProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
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();
@@ -429,6 +430,16 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr
const lastVirtuosoState = navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined; const lastVirtuosoState = navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined;
const shouldScrollToReply = showAllReplies && showReplies && !isInPendingPostView && !!targetReplyCid;
useScrollToReply({
targetReplyCid,
replies: filteredReplies,
hasMore,
loadMore,
virtuosoRef,
enabled: shouldScrollToReply,
});
// Footer component for Virtuoso showing loading state // Footer component for Virtuoso showing loading state
const RepliesFooter = () => const RepliesFooter = () =>
hasMore ? ( hasMore ? (
+12 -1
View File
@@ -18,6 +18,7 @@ import { useCommentMediaInfo } from '../../hooks/use-comment-media-info';
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies'; import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
import useHide from '../../hooks/use-hide'; import useHide from '../../hooks/use-hide';
import useStateString from '../../hooks/use-state-string'; import useStateString from '../../hooks/use-state-string';
import useScrollToReply from '../../hooks/use-scroll-to-reply';
import CommentContent from '../comment-content'; import CommentContent from '../comment-content';
import CommentMedia from '../comment-media'; import CommentMedia from '../comment-media';
import LoadingEllipsis from '../loading-ellipsis'; import LoadingEllipsis from '../loading-ellipsis';
@@ -282,7 +283,7 @@ const Reply = ({ postReplyCount, reply, roles, threadNumber }: PostProps) => {
); );
}; };
const PostMobile = ({ post, roles, showAllReplies, showReplies = true }: PostProps) => { const PostMobile = ({ post, roles, showAllReplies, showReplies = true, targetReplyCid }: PostProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { author, cid, pinned, postCid, replyCount, state, subplebbitAddress } = post || {}; const { author, cid, pinned, postCid, replyCount, state, subplebbitAddress } = post || {};
const params = useParams(); const params = useParams();
@@ -324,6 +325,16 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true }: PostPro
const lastVirtuosoState = navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined; const lastVirtuosoState = navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined;
const shouldScrollToReply = showAllReplies && showReplies && !isInPendingPostView && !!targetReplyCid;
useScrollToReply({
targetReplyCid,
replies: filteredReplies,
hasMore,
loadMore,
virtuosoRef,
enabled: shouldScrollToReply,
});
// Footer component for Virtuoso showing loading state // Footer component for Virtuoso showing loading state
const RepliesFooter = () => const RepliesFooter = () =>
hasMore ? ( hasMore ? (
+132
View File
@@ -0,0 +1,132 @@
/* @vitest-environment jsdom */
import { act, useMemo } from 'react';
import { describe, it, vi, beforeEach, afterEach, expect } from 'vitest';
import { createRoot, Root } from 'react-dom/client';
import { VirtuosoHandle } from 'react-virtuoso';
import useScrollToReply from './use-scroll-to-reply';
const TestHarness = ({
targetReplyCid,
replies,
hasMore,
loadMore,
virtuosoRef,
renderTargetElement = false,
}: {
targetReplyCid?: string;
replies: Array<{ cid?: string | null }>;
hasMore: boolean;
loadMore: () => void;
virtuosoRef: React.RefObject<VirtuosoHandle | null>;
renderTargetElement?: boolean;
}) => {
const memoizedReplies = useMemo(() => replies, [replies]);
useScrollToReply({
targetReplyCid,
replies: memoizedReplies,
hasMore,
loadMore,
virtuosoRef,
enabled: true,
});
return renderTargetElement ? <div data-cid={targetReplyCid} /> : null;
};
describe('useScrollToReply', () => {
const originalScrollIntoView = Element.prototype.scrollIntoView;
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
Element.prototype.scrollIntoView = vi.fn();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
vi.useFakeTimers();
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
Element.prototype.scrollIntoView = originalScrollIntoView;
vi.useRealTimers();
vi.restoreAllMocks();
});
it('scrolls to target reply index when present', async () => {
const scrollToIndex = vi.fn();
const virtuosoRef = { current: { scrollToIndex } as unknown as VirtuosoHandle };
const replies = [{ cid: 'a' }, { cid: 'b' }, { cid: 'c' }];
await act(() => {
root.render(<TestHarness targetReplyCid='b' replies={replies} hasMore={true} loadMore={vi.fn()} virtuosoRef={virtuosoRef} />);
});
await act(() => Promise.resolve());
act(() => {
vi.advanceTimersByTime(400);
});
expect(scrollToIndex).toHaveBeenCalledWith({
index: 1,
align: 'center',
behavior: 'smooth',
});
});
it('loads more when target reply is not yet present', async () => {
const loadMore = vi.fn();
const virtuosoRef = { current: { scrollToIndex: vi.fn() } as unknown as VirtuosoHandle };
let intervalCallback: (() => void) | null = null;
let timeoutCallback: (() => void) | null = null;
vi.spyOn(window, 'setInterval').mockImplementation((callback) => {
intervalCallback = callback as () => void;
return 1;
});
vi.spyOn(window, 'setTimeout').mockImplementation((callback) => {
timeoutCallback = callback as () => void;
return 1;
});
await act(() => {
root.render(<TestHarness targetReplyCid='missing' replies={[{ cid: 'a' }]} hasMore={true} loadMore={loadMore} virtuosoRef={virtuosoRef} />);
});
await act(() => Promise.resolve());
act(() => {
intervalCallback?.();
});
act(() => {
timeoutCallback?.();
});
expect(loadMore).toHaveBeenCalled();
});
it('scrolls into view when virtualization is not active', async () => {
const loadMore = vi.fn();
const virtuosoRef = { current: { scrollToIndex: vi.fn() } as unknown as VirtuosoHandle };
await act(() => {
root.render(
<TestHarness targetReplyCid='target' replies={[{ cid: 'target' }]} hasMore={false} loadMore={loadMore} virtuosoRef={virtuosoRef} renderTargetElement={true} />,
);
});
await act(() => Promise.resolve());
act(() => {
vi.advanceTimersByTime(400);
});
expect(Element.prototype.scrollIntoView).toHaveBeenCalled();
});
});
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useRef } from 'react';
import { VirtuosoHandle } from 'react-virtuoso';
type ReplyItem = {
cid?: string | null;
};
interface UseScrollToReplyParams {
targetReplyCid?: string;
replies: ReplyItem[];
hasMore: boolean;
loadMore: () => void;
virtuosoRef: React.RefObject<VirtuosoHandle | null>;
enabled?: boolean;
}
const DEFAULT_MAX_LOAD_ATTEMPTS = 500;
const MAX_LOAD_DURATION_MS = 120000;
const LOAD_MORE_THROTTLE_MS = 300;
const AUTO_SCROLL_INTERVAL_MS = 350;
const useScrollToReply = ({ targetReplyCid, replies, hasMore, loadMore, virtuosoRef, enabled = true }: UseScrollToReplyParams) => {
const hasScrolledRef = useRef(false);
const loadAttemptsRef = useRef(0);
const lastRepliesLengthRef = useRef(replies.length);
const loadMoreTimeoutRef = useRef<number | null>(null);
const lastLoadAtRef = useRef(0);
const loadStartAtRef = useRef(0);
const lastScrollIndexRef = useRef(-1);
const intervalRef = useRef<number | null>(null);
const latestTargetRef = useRef(targetReplyCid);
const latestRepliesRef = useRef(replies);
const latestHasMoreRef = useRef(hasMore);
const latestLoadMoreRef = useRef(loadMore);
// Only reset when the target changes, NOT when replies.length changes
// (replies.length changing is expected as we load more pages)
useEffect(() => {
hasScrolledRef.current = false;
loadAttemptsRef.current = 0;
lastRepliesLengthRef.current = replies.length;
lastLoadAtRef.current = 0;
loadStartAtRef.current = Date.now();
lastScrollIndexRef.current = -1;
if (intervalRef.current) {
window.clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (loadMoreTimeoutRef.current) {
window.clearTimeout(loadMoreTimeoutRef.current);
loadMoreTimeoutRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [targetReplyCid]);
useEffect(() => {
latestTargetRef.current = targetReplyCid;
}, [targetReplyCid]);
useEffect(() => {
latestRepliesRef.current = replies;
}, [replies]);
useEffect(() => {
latestHasMoreRef.current = hasMore;
}, [hasMore]);
useEffect(() => {
latestLoadMoreRef.current = loadMore;
}, [loadMore]);
useEffect(() => {
if (replies.length !== lastRepliesLengthRef.current) {
lastRepliesLengthRef.current = replies.length;
}
}, [replies.length]);
useEffect(() => {
if (!enabled || !targetReplyCid || intervalRef.current) return;
intervalRef.current = window.setInterval(() => {
const latestTarget = latestTargetRef.current;
const latestReplies = latestRepliesRef.current;
const latestHasMore = latestHasMoreRef.current;
const latestLoadMore = latestLoadMoreRef.current;
if (!enabled || !latestTarget || hasScrolledRef.current) {
if (intervalRef.current) {
window.clearInterval(intervalRef.current);
intervalRef.current = null;
}
return;
}
if (!latestHasMore) {
const element = document.querySelector(`[data-cid="${latestTarget}"]`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
hasScrolledRef.current = true;
} else {
// Reply not found after loading all pages
console.warn(`[scroll-to-reply] Could not find reply with CID "${latestTarget}" in the feed.`);
hasScrolledRef.current = true; // Stop trying
}
if (intervalRef.current) {
window.clearInterval(intervalRef.current);
intervalRef.current = null;
}
return;
}
// Iteratively load pages until the target reply appears in the list.
const targetIndex = latestReplies.findIndex((reply) => reply?.cid === latestTarget);
if (targetIndex >= 0) {
hasScrolledRef.current = true;
if (loadMoreTimeoutRef.current) {
window.clearTimeout(loadMoreTimeoutRef.current);
loadMoreTimeoutRef.current = null;
}
if (intervalRef.current) {
window.clearInterval(intervalRef.current);
intervalRef.current = null;
}
virtuosoRef.current?.scrollToIndex({ index: targetIndex, align: 'center', behavior: 'smooth' });
return;
}
// Smoothly scroll to the latest loaded replies while loading more pages
const lastIndex = latestReplies.length - 1;
if (lastIndex >= 0 && lastIndex !== lastScrollIndexRef.current) {
lastScrollIndexRef.current = lastIndex;
virtuosoRef.current?.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'smooth' });
}
const loadDuration = Date.now() - loadStartAtRef.current;
if (!latestHasMore || loadAttemptsRef.current >= DEFAULT_MAX_LOAD_ATTEMPTS || loadDuration >= MAX_LOAD_DURATION_MS) return;
const now = Date.now();
if (now - lastLoadAtRef.current < LOAD_MORE_THROTTLE_MS) return;
lastLoadAtRef.current = now;
loadAttemptsRef.current += 1;
if (loadMoreTimeoutRef.current) {
window.clearTimeout(loadMoreTimeoutRef.current);
}
loadMoreTimeoutRef.current = window.setTimeout(() => {
latestLoadMore();
}, LOAD_MORE_THROTTLE_MS);
}, AUTO_SCROLL_INTERVAL_MS);
return () => {
if (intervalRef.current) {
window.clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (loadMoreTimeoutRef.current) {
window.clearTimeout(loadMoreTimeoutRef.current);
loadMoreTimeoutRef.current = null;
}
};
}, [enabled, targetReplyCid, virtuosoRef]);
};
export default useScrollToReply;
+9 -5
View File
@@ -23,10 +23,11 @@ export interface PostProps {
roles?: Role[]; roles?: Role[];
showAllReplies?: boolean; showAllReplies?: boolean;
showReplies?: boolean; showReplies?: boolean;
targetReplyCid?: string;
threadNumber?: number; threadNumber?: number;
} }
export const Post = ({ post, showAllReplies = false, showReplies = true }: PostProps) => { export const Post = ({ post, showAllReplies = false, showReplies = true, targetReplyCid }: PostProps) => {
// Only subscribe to roles field to avoid rerenders from updatingState changes // Only subscribe to roles field to avoid rerenders from updatingState changes
const roles = useSubplebbitField(post?.subplebbitAddress, (subplebbit) => subplebbit?.roles); const roles = useSubplebbitField(post?.subplebbitAddress, (subplebbit) => subplebbit?.roles);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -43,9 +44,9 @@ export const Post = ({ post, showAllReplies = false, showReplies = true }: PostP
<div className={styles.thread}> <div className={styles.thread}>
<div className={styles.postContainer}> <div className={styles.postContainer}>
{isMobile ? ( {isMobile ? (
<PostMobile post={comment} roles={roles} showAllReplies={showAllReplies} showReplies={showReplies} /> <PostMobile post={comment} roles={roles} showAllReplies={showAllReplies} showReplies={showReplies} targetReplyCid={targetReplyCid} />
) : ( ) : (
<PostDesktop post={comment} roles={roles} showAllReplies={showAllReplies} showReplies={showReplies} /> <PostDesktop post={comment} roles={roles} showAllReplies={showAllReplies} showReplies={showReplies} targetReplyCid={targetReplyCid} />
)} )}
</div> </div>
</div> </div>
@@ -85,8 +86,9 @@ const PostPage = () => {
const { error } = post || {}; const { error } = post || {};
useEffect(() => { useEffect(() => {
if (!comment?.cid || comment.parentCid) return;
window.scrollTo(0, 0); window.scrollTo(0, 0);
}, []); }, [comment?.cid, comment?.parentCid]);
useEffect(() => { useEffect(() => {
const boardIdentifier = params.boardIdentifier; const boardIdentifier = params.boardIdentifier;
@@ -110,6 +112,8 @@ const PostPage = () => {
const shouldShowPostError = post?.error && post?.replyCount > 0 && post?.replies?.length === 0; const shouldShowPostError = post?.error && post?.replyCount > 0 && post?.replies?.length === 0;
const shouldShowSubplebbitError = subplebbitError?.message && !post?.cid; const shouldShowSubplebbitError = subplebbitError?.message && !post?.cid;
const targetReplyCid = comment?.parentCid ? comment?.cid : undefined;
return ( return (
<div className={styles.content}> <div className={styles.content}>
{shouldShowPostError && ( {shouldShowPostError && (
@@ -117,7 +121,7 @@ const PostPage = () => {
<ErrorDisplay error={error} /> <ErrorDisplay error={error} />
</div> </div>
)} )}
<Post post={post} showAllReplies={true} /> <Post post={post} showAllReplies={true} targetReplyCid={targetReplyCid} />
{shouldShowSubplebbitError && ( {shouldShowSubplebbitError && (
<div className={styles.error}> <div className={styles.error}>
<ErrorDisplay error={subplebbitError} /> <ErrorDisplay error={subplebbitError} />