diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index fa552464..1f780380 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -19,6 +19,7 @@ import useCountLinksInReplies from '../../hooks/use-count-links-in-replies'; import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame'; import useHide from '../../hooks/use-hide'; import useStateString from '../../hooks/use-state-string'; +import useScrollToReply from '../../hooks/use-scroll-to-reply'; import CommentContent from '../comment-content'; import CommentMedia from '../comment-media'; 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 { author, cid, content, deleted, link, linkHeight, linkWidth, pinned, postCid, removed, spoiler, state, subplebbitAddress, thumbnailUrl, parentCid } = post || {}; const params = useParams(); @@ -429,6 +430,16 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true }: PostPr 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 const RepliesFooter = () => hasMore ? ( diff --git a/src/components/post-mobile/post-mobile.tsx b/src/components/post-mobile/post-mobile.tsx index 14fbe79a..c1664f1a 100644 --- a/src/components/post-mobile/post-mobile.tsx +++ b/src/components/post-mobile/post-mobile.tsx @@ -18,6 +18,7 @@ import { useCommentMediaInfo } from '../../hooks/use-comment-media-info'; import useCountLinksInReplies from '../../hooks/use-count-links-in-replies'; import useHide from '../../hooks/use-hide'; import useStateString from '../../hooks/use-state-string'; +import useScrollToReply from '../../hooks/use-scroll-to-reply'; import CommentContent from '../comment-content'; import CommentMedia from '../comment-media'; 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 { author, cid, pinned, postCid, replyCount, state, subplebbitAddress } = post || {}; const params = useParams(); @@ -324,6 +325,16 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true }: PostPro 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 const RepliesFooter = () => hasMore ? ( diff --git a/src/hooks/use-scroll-to-reply.test.tsx b/src/hooks/use-scroll-to-reply.test.tsx new file mode 100644 index 00000000..2ae78df0 --- /dev/null +++ b/src/hooks/use-scroll-to-reply.test.tsx @@ -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; + renderTargetElement?: boolean; +}) => { + const memoizedReplies = useMemo(() => replies, [replies]); + useScrollToReply({ + targetReplyCid, + replies: memoizedReplies, + hasMore, + loadMore, + virtuosoRef, + enabled: true, + }); + + return renderTargetElement ?
: 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(); + }); + + 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(); + }); + + 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( + , + ); + }); + + await act(() => Promise.resolve()); + + act(() => { + vi.advanceTimersByTime(400); + }); + + expect(Element.prototype.scrollIntoView).toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/use-scroll-to-reply.ts b/src/hooks/use-scroll-to-reply.ts new file mode 100644 index 00000000..6b170d5b --- /dev/null +++ b/src/hooks/use-scroll-to-reply.ts @@ -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; + 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(null); + const lastLoadAtRef = useRef(0); + const loadStartAtRef = useRef(0); + const lastScrollIndexRef = useRef(-1); + const intervalRef = useRef(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; diff --git a/src/views/post/post.tsx b/src/views/post/post.tsx index a4edb3e4..71333b19 100644 --- a/src/views/post/post.tsx +++ b/src/views/post/post.tsx @@ -23,10 +23,11 @@ export interface PostProps { roles?: Role[]; showAllReplies?: boolean; showReplies?: boolean; + targetReplyCid?: string; 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 const roles = useSubplebbitField(post?.subplebbitAddress, (subplebbit) => subplebbit?.roles); const isMobile = useIsMobile(); @@ -43,9 +44,9 @@ export const Post = ({ post, showAllReplies = false, showReplies = true }: PostP
{isMobile ? ( - + ) : ( - + )}
@@ -85,8 +86,9 @@ const PostPage = () => { const { error } = post || {}; useEffect(() => { + if (!comment?.cid || comment.parentCid) return; window.scrollTo(0, 0); - }, []); + }, [comment?.cid, comment?.parentCid]); useEffect(() => { const boardIdentifier = params.boardIdentifier; @@ -110,6 +112,8 @@ const PostPage = () => { const shouldShowPostError = post?.error && post?.replyCount > 0 && post?.replies?.length === 0; const shouldShowSubplebbitError = subplebbitError?.message && !post?.cid; + const targetReplyCid = comment?.parentCid ? comment?.cid : undefined; + return (
{shouldShowPostError && ( @@ -117,7 +121,7 @@ const PostPage = () => {
)} - + {shouldShowSubplebbitError && (