diff --git a/README.md b/README.md index c477711c..cc8508ca 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ [![Release](https://img.shields.io/github/v/release/bitsocialnet/5chan)](https://github.com/bitsocialnet/5chan/releases/latest) [![License](https://img.shields.io/badge/license-GPL--2.0--only-red.svg)](https://github.com/bitsocialnet/5chan/blob/master/LICENSE) [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bitsocialnet/5chan) diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index 1cc57c50..1b161913 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { Trans, useTranslation } from 'react-i18next'; -import { Link, useLocation, useNavigate, useNavigationType, useParams } from 'react-router-dom'; +import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom'; import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso'; import { Comment, deleteComment, useEditedComment, useReplies, useAccount, useAccountComment } from '@bitsocialnet/bitsocial-react-hooks'; import getShortAddress from '../../lib/get-short-address'; @@ -48,7 +48,7 @@ import useQuotedByMap from '../../hooks/use-quoted-by-map'; import useProgressiveRender from '../../hooks/use-progressive-render'; import { BOARD_REPLIES_PREVIEW_FETCH_SIZE, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT, REPLIES_PER_PAGE } from '../../lib/constants'; import { computeOmittedCount, filterRepliesForDisplay, getPreviewDisplayReplies, getTotalReplyCount } from '../../lib/utils/replies-preview-utils'; -import { openThreadAtTop } from '../../lib/utils/thread-scroll-utils'; +import { getThreadTopNavigationState, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils'; const { addChallenge } = useChallengesStore.getState(); @@ -110,7 +110,6 @@ const PostInfo = ({ const params = useParams(); const location = useLocation(); - const navigate = useNavigate(); const isInPostPageView = isPostPageView(location.pathname, params); const isInModQueueView = isModQueueView(location.pathname); const { getAlertThresholdSeconds } = useModQueueStore(); @@ -268,6 +267,7 @@ const PostInfo = ({ }; const threadRoute = cid ? (boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`) : undefined; + const threadTopNavigationState = !isReply ? getThreadTopNavigationState(cid) : undefined; const onLinkToPostClick = (e: React.MouseEvent) => { if (!cid || !threadRoute) { @@ -275,15 +275,10 @@ const PostInfo = ({ return; } - if (isInPostPageView && !isReply) { - e.preventDefault(); - openThreadAtTop({ - cid, - currentPathname: location.pathname, - navigate, - threadRoute, - }); - } + if (!isInPostPageView || isReply) return; + + e.preventDefault(); + scrollThreadContainerToTop(cid); }; return ( @@ -383,7 +378,7 @@ const PostInfo = ({ {cid ? ( - + No. ) => { if (!cid || !threadRoute) { @@ -254,15 +254,10 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos return; } - if (isInPostPageView && !isReply) { - e.preventDefault(); - openThreadAtTop({ - cid, - currentPathname: location.pathname, - navigate, - threadRoute, - }); - } + if (!isInPostPageView || isReply) return; + + e.preventDefault(); + scrollThreadContainerToTop(cid); }; return ( @@ -383,7 +378,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos )}{' '} {cid ? ( - + No. { +const appendThreadContainer = ({ cid, top = 240, parent = document.body }: { cid: string; top?: number; parent?: HTMLElement }) => { const element = document.createElement('div'); - element.dataset.postInfoCid = cid; - element.scrollIntoView = vi.fn(); + element.dataset.threadContainerCid = cid; element.getBoundingClientRect = () => ({ - bottom: 100, + bottom: top + 100, + height: 100, left: 0, right: 100, - top: 0, + top, + width: 100, }) as DOMRect; - document.body.appendChild(element); + parent.appendChild(element); return element; }; @@ -178,6 +179,16 @@ describe('ReplyQuotePreview', () => { testState.navigateMock.mockReset(); testState.quoteAvailability = 'available'; testState.updateMock.mockReset(); + Object.defineProperty(window, 'scrollTo', { + configurable: true, + value: vi.fn(), + writable: true, + }); + Object.defineProperty(window, 'scrollY', { + configurable: true, + value: 0, + writable: true, + }); container = document.createElement('div'); document.body.appendChild(container); @@ -188,7 +199,9 @@ describe('ReplyQuotePreview', () => { act(() => root.unmount()); container.remove(); document.querySelectorAll('[data-cid]').forEach((node) => node.remove()); - document.querySelectorAll('[data-post-info-cid]').forEach((node) => node.remove()); + document.querySelectorAll('[data-thread-container-cid]').forEach((node) => { + node.remove(); + }); document.querySelectorAll(`.${styles.replyQuotePreview}`).forEach((node) => node.remove()); document.querySelectorAll('.scroll-highlight').forEach((node) => node.remove()); }); @@ -221,9 +234,9 @@ describe('ReplyQuotePreview', () => { expect(testState.navigateMock).not.toHaveBeenCalled(); }); - it('scrolls to the OP post info for quotes on the current desktop thread page', async () => { - const threadCard = appendReplyElement({ cid: 'thread-cid', isThreadCard: true }); - const postInfo = appendPostInfoAnchor('thread-cid'); + it('scrolls to the OP thread container for quotes on the current desktop thread page', async () => { + appendReplyElement({ cid: 'thread-cid', isThreadCard: true }); + appendThreadContainer({ cid: 'thread-cid', top: 180 }); await renderPreview({ isOP: true, @@ -242,14 +255,74 @@ describe('ReplyQuotePreview', () => { link?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); - expect(postInfo.scrollIntoView as any).toHaveBeenCalledWith({ behavior: 'auto', block: 'start' }); - expect(threadCard.scrollIntoView as any).not.toHaveBeenCalled(); + expect(window.scrollTo).toHaveBeenCalledWith({ + behavior: 'auto', + left: 0, + top: 180, + }); expect(testState.navigateMock).not.toHaveBeenCalled(); }); + it('scrolls to the OP thread container on all-thread routes too', async () => { + testState.locationPath = '/all/thread/thread-cid'; + appendThreadContainer({ cid: 'thread-cid', top: 140 }); + + await renderPreview({ + isOP: true, + isQuotelinkReply: true, + quotelinkReply: { + cid: 'thread-cid', + number: 1, + subplebbitAddress: 'music-posting.eth', + }, + }); + + const link = queryAnchorByText('>>1 (OP)'); + expect(link).toBeTruthy(); + + await act(async () => { + link?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(window.scrollTo).toHaveBeenCalledWith({ + behavior: 'auto', + left: 0, + top: 140, + }); + expect(testState.navigateMock).not.toHaveBeenCalled(); + }); + + it('navigates OP quotelinks with thread-top state when the target thread differs', async () => { + testState.locationPath = '/mu/thread/reply-cid'; + + await renderPreview({ + isOP: true, + isQuotelinkReply: true, + quotelinkReply: { + cid: 'thread-cid', + number: 1, + subplebbitAddress: 'music-posting.eth', + }, + }); + + const link = queryAnchorByText('>>1 (OP)'); + expect(link).toBeTruthy(); + + await act(async () => { + link?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(testState.navigateMock).toHaveBeenCalledWith('/mu/thread/thread-cid', { + state: { + scrollThreadContainerCid: 'thread-cid', + }, + }); + }); + it('navigates to the reply route when only a floating preview copy matches the CID', async () => { const previewWrapper = document.createElement('div'); previewWrapper.className = styles.replyQuotePreview; + previewWrapper.dataset.threadScrollPreview = 'true'; document.body.appendChild(previewWrapper); const previewTarget = appendReplyElement({ cid: 'reply-cid', parent: previewWrapper }); @@ -364,6 +437,36 @@ describe('ReplyQuotePreview', () => { expect(testState.navigateMock).toHaveBeenCalledWith('/mu/thread/reply-cid'); }); + it('scrolls mobile OP quotelinks on all-thread routes too', async () => { + testState.isMobile = true; + testState.locationPath = '/all/thread/thread-cid'; + appendThreadContainer({ cid: 'thread-cid', top: 90 }); + + await renderPreview({ + isOP: true, + isQuotelinkReply: true, + quotelinkReply: { + cid: 'thread-cid', + number: 1, + subplebbitAddress: 'music-posting.eth', + }, + }); + + const hashLink = queryAnchorByText(' #'); + expect(hashLink).toBeTruthy(); + + await act(async () => { + hashLink?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(window.scrollTo).toHaveBeenCalledWith({ + behavior: 'auto', + left: 0, + top: 90, + }); + expect(testState.navigateMock).not.toHaveBeenCalled(); + }); + it('navigates mobile reply hash links even when the reply is already on the current thread page', async () => { testState.isMobile = true; const target = appendReplyElement({ cid: 'reply-cid' }); diff --git a/src/components/reply-quote-preview/reply-quote-preview.tsx b/src/components/reply-quote-preview/reply-quote-preview.tsx index c84adb13..1a6f7e2c 100644 --- a/src/components/reply-quote-preview/reply-quote-preview.tsx +++ b/src/components/reply-quote-preview/reply-quote-preview.tsx @@ -6,6 +6,7 @@ import { useFloating, offset, shift, size, autoUpdate, Placement } from '@floati import { useDirectories } from '../../hooks/use-directories'; import { getBoardPath } from '../../lib/utils/route-utils'; import { formatQuoteNumber, getQuoteTargetAvailability, shouldShowFloatingQuotePreview } from '../../lib/utils/quote-link-utils'; +import { findPreferredScrollTarget, getThreadTopNavigationState, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils'; import useIsMobile from '../../hooks/use-is-mobile'; import styles from '../../views/post/post.module.css'; import { Post } from '../../views/post'; @@ -67,15 +68,7 @@ const handleQuoteHover = (cid: string, onElementOutOfView: () => void) => { } }; -const getInPageScrollTarget = (selector: string) => - Array.from(document.querySelectorAll(selector)).find((element) => !element.closest(`.${styles.replyQuotePreview}`)); - -const scrollToThreadPostInfoTop = (threadCid: string) => { - const postInfo = getInPageScrollTarget(`[data-post-info-cid="${threadCid}"]`); - if (!postInfo) return false; - postInfo.scrollIntoView({ behavior: 'auto', block: 'start' }); - return true; -}; +const getInPageScrollTarget = (selector: string) => findPreferredScrollTarget(selector, '[data-thread-scroll-preview="true"]'); const scrollToReplyOnPage = (cid: string) => { const el = getInPageScrollTarget(`[data-cid="${cid}"][data-post-cid]`); @@ -149,11 +142,8 @@ const DesktopQuotePreview = ({ const boardPath = getBoardPath(subplebbitAddress, directories); const threadRoute = `/${boardPath}/thread/${cid}`; if (isOpQuote) { - if (location.pathname === threadRoute) { - scrollToThreadPostInfoTop(cid); - } else { - navigate(threadRoute); - } + if (isOnThreadPage && scrollThreadContainerToTop(cid)) return; + navigate(threadRoute, { state: getThreadTopNavigationState(cid) }); return; } if (isOnThreadPage && scrollToReplyOnPage(cid)) return; @@ -199,7 +189,7 @@ const DesktopQuotePreview = ({ {hoveredCid === backlinkReply?.cid && outOfViewCid === backlinkReply?.cid && createPortal( -
+
, document.body, @@ -253,7 +243,7 @@ const DesktopQuotePreview = ({ {showTrailingBreak &&
} {shouldShowQuotelinkPreview && createPortal( -
+
, document.body, @@ -295,6 +285,7 @@ const MobileQuotePreview = ({ const navigate = useNavigate(); const location = useLocation(); + const isOnThreadPage = location.pathname.includes('/thread/'); const handleClick = (e: React.MouseEvent, cid: string | undefined, subplebbitAddress: string | undefined, isOpQuote = false) => { e.preventDefault(); @@ -302,11 +293,8 @@ const MobileQuotePreview = ({ const boardPath = getBoardPath(subplebbitAddress, directories); const threadRoute = `/${boardPath}/thread/${cid}`; if (isOpQuote) { - if (location.pathname === threadRoute) { - scrollToThreadPostInfoTop(cid); - } else { - navigate(threadRoute); - } + if (isOnThreadPage && scrollThreadContainerToTop(cid)) return; + navigate(threadRoute, { state: getThreadTopNavigationState(cid) }); return; } navigate(threadRoute); @@ -356,7 +344,7 @@ const MobileQuotePreview = ({ {hoveredCid === backlinkReply?.cid && outOfViewCid === backlinkReply?.cid && createPortal( -
+
, document.body, @@ -411,7 +399,7 @@ const MobileQuotePreview = ({ {showTrailingBreak &&
} {shouldShowQuotelinkPreview && createPortal( -
+
, document.body, diff --git a/src/lib/utils/__tests__/thread-scroll-utils.test.ts b/src/lib/utils/__tests__/thread-scroll-utils.test.ts index df4ff238..42cc9b3f 100644 --- a/src/lib/utils/__tests__/thread-scroll-utils.test.ts +++ b/src/lib/utils/__tests__/thread-scroll-utils.test.ts @@ -1,9 +1,12 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { clearThreadScrollSpacer, openThreadAtTop } from '../thread-scroll-utils'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getRequestedThreadTopCid, getThreadTopNavigationState, scrollThreadContainerToTop } from '../thread-scroll-utils'; -const appendThreadContainer = (cid: string, top = 240) => { +const appendThreadContainer = ({ cid, top = 240, hidden = false, parent = document.body }: { cid: string; top?: number; hidden?: boolean; parent?: HTMLElement }) => { const element = document.createElement('div'); element.dataset.threadContainerCid = cid; + if (hidden) { + element.style.display = 'none'; + } element.getBoundingClientRect = () => ({ bottom: top + 100, @@ -13,110 +16,87 @@ const appendThreadContainer = (cid: string, top = 240) => { top, width: 100, }) as DOMRect; - document.body.appendChild(element); + parent.appendChild(element); return element; }; describe('thread-scroll-utils', () => { let scrollToMock: ReturnType; - let requestAnimationFrameMock: ReturnType; beforeEach(() => { vi.clearAllMocks(); scrollToMock = vi.fn(); - requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => { - callback(0); - return 1; - }); Object.defineProperty(window, 'scrollTo', { configurable: true, value: scrollToMock, writable: true, }); - Object.defineProperty(window, 'requestAnimationFrame', { - configurable: true, - value: requestAnimationFrameMock, - writable: true, - }); Object.defineProperty(window, 'scrollY', { configurable: true, - value: 0, + value: 32, writable: true, }); - Object.defineProperty(window, 'innerHeight', { - configurable: true, - value: 100, - writable: true, - }); - Object.defineProperty(document.documentElement, 'scrollHeight', { - configurable: true, - value: 200, - writable: true, - }); - }); - afterEach(() => { - clearThreadScrollSpacer(); document.body.innerHTML = ''; }); - it('scrolls the current thread without pushing duplicate history entries', () => { - appendThreadContainer('thread-cid'); - const navigateMock = vi.fn(); + it('scrolls the matching thread container with a plain window scroll', () => { + appendThreadContainer({ cid: 'thread-cid', top: 240 }); - expect( - openThreadAtTop({ - cid: 'thread-cid', - currentPathname: '/mu/thread/thread-cid', - navigate: navigateMock, - threadRoute: '/mu/thread/thread-cid', - }), - ).toBe(true); + expect(scrollThreadContainerToTop('thread-cid')).toBe(true); - expect(navigateMock).not.toHaveBeenCalled(); - expect(requestAnimationFrameMock).toHaveBeenCalledOnce(); - expect(scrollToMock).toHaveBeenCalledTimes(2); - expect(scrollToMock).toHaveBeenNthCalledWith(1, { + expect(scrollToMock).toHaveBeenCalledOnce(); + expect(scrollToMock).toHaveBeenCalledWith({ behavior: 'auto', left: 0, - top: 240, + top: 272, }); }); - it('navigates to the OP thread before scrolling when the route differs', () => { - appendThreadContainer('thread-cid'); - const navigateMock = vi.fn(); + it('ignores preview copies when resolving the scroll target', () => { + const previewWrapper = document.createElement('div'); + previewWrapper.dataset.threadScrollPreview = 'true'; + document.body.appendChild(previewWrapper); + appendThreadContainer({ cid: 'thread-cid', top: 12, parent: previewWrapper }); + appendThreadContainer({ cid: 'thread-cid', top: 180 }); - expect( - openThreadAtTop({ - cid: 'thread-cid', - currentPathname: '/mu/thread/reply-cid', - navigate: navigateMock, - threadRoute: '/mu/thread/thread-cid', - }), - ).toBe(true); + expect(scrollThreadContainerToTop('thread-cid')).toBe(true); - expect(navigateMock).toHaveBeenCalledWith('/mu/thread/thread-cid'); - expect(requestAnimationFrameMock).toHaveBeenCalledOnce(); - expect(scrollToMock).toHaveBeenCalledTimes(2); + expect(scrollToMock).toHaveBeenCalledWith({ + behavior: 'auto', + left: 0, + top: 212, + }); }); - it('returns false when the permalink cannot resolve a thread target', () => { - const navigateMock = vi.fn(); + it('prefers the visible thread container when cached duplicates are hidden', () => { + appendThreadContainer({ cid: 'thread-cid', top: 12, hidden: true }); + appendThreadContainer({ cid: 'thread-cid', top: 180 }); - expect( - openThreadAtTop({ - cid: undefined, - currentPathname: '/mu/thread/thread-cid', - navigate: navigateMock, - threadRoute: '/mu/thread/thread-cid', - }), - ).toBe(false); + expect(scrollThreadContainerToTop('thread-cid')).toBe(true); - expect(navigateMock).not.toHaveBeenCalled(); - expect(requestAnimationFrameMock).not.toHaveBeenCalled(); + expect(scrollToMock).toHaveBeenCalledWith({ + behavior: 'auto', + left: 0, + top: 212, + }); + }); + + it('returns false when no thread container exists', () => { + expect(scrollThreadContainerToTop('missing-cid')).toBe(false); expect(scrollToMock).not.toHaveBeenCalled(); }); + + it('serializes and reads thread-top navigation state', () => { + expect(getThreadTopNavigationState()).toBeUndefined(); + expect(getThreadTopNavigationState('thread-cid')).toEqual({ + scrollThreadContainerCid: 'thread-cid', + }); + expect(getRequestedThreadTopCid(getThreadTopNavigationState())).toBeUndefined(); + expect(getRequestedThreadTopCid({ scrollThreadContainerCid: 'thread-cid' })).toBe('thread-cid'); + expect(getRequestedThreadTopCid({ scrollThreadContainerCid: 42 })).toBeUndefined(); + expect(getRequestedThreadTopCid(null)).toBeUndefined(); + }); }); diff --git a/src/lib/utils/thread-scroll-utils.ts b/src/lib/utils/thread-scroll-utils.ts index c04a23ea..b048d020 100644 --- a/src/lib/utils/thread-scroll-utils.ts +++ b/src/lib/utils/thread-scroll-utils.ts @@ -1,43 +1,42 @@ -const THREAD_SCROLL_SPACER_ID = 'thread-scroll-spacer'; +const THREAD_SCROLL_PREVIEW_SELECTOR = '[data-thread-scroll-preview="true"]'; -const setThreadScrollSpacerHeight = (height: number) => { - const existingSpacer = document.getElementById(THREAD_SCROLL_SPACER_ID); - if (height <= 0) { - existingSpacer?.remove(); - return; - } - - const spacer = - existingSpacer || - Object.assign(document.createElement('div'), { - id: THREAD_SCROLL_SPACER_ID, - }); - - spacer.setAttribute('aria-hidden', 'true'); - spacer.style.height = `${Math.ceil(height)}px`; - spacer.style.pointerEvents = 'none'; - spacer.style.opacity = '0'; - - if (!existingSpacer) { - document.body.appendChild(spacer); - } +type ThreadTopNavigationState = { + scrollThreadContainerCid?: string; }; -export const clearThreadScrollSpacer = () => { - document.getElementById(THREAD_SCROLL_SPACER_ID)?.remove(); +export const getThreadTopNavigationState = (cid?: string): ThreadTopNavigationState | undefined => + cid + ? { + scrollThreadContainerCid: cid, + } + : undefined; + +export const getRequestedThreadTopCid = (state: unknown) => { + if (!state || typeof state !== 'object') return undefined; + + const cid = (state as ThreadTopNavigationState).scrollThreadContainerCid; + return typeof cid === 'string' ? cid : undefined; +}; + +const isVisibleScrollTarget = (element: HTMLElement) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + + return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0' && rect.width > 0 && rect.height > 0; +}; + +export const findPreferredScrollTarget = (selector: string, excludedAncestorSelector = THREAD_SCROLL_PREVIEW_SELECTOR) => { + const candidates = Array.from(document.querySelectorAll(selector)).filter((element) => !element.closest(excludedAncestorSelector)); + return candidates.find(isVisibleScrollTarget) ?? candidates[0]; }; export const scrollThreadContainerToTop = (cid?: string) => { if (!cid) return false; - const threadContainer = document.querySelector(`[data-thread-container-cid="${cid}"]`); + const threadContainer = findPreferredScrollTarget(`[data-thread-container-cid="${cid}"]`); if (!threadContainer) return false; const desiredTop = window.scrollY + threadContainer.getBoundingClientRect().top; - const maxScrollTop = document.documentElement.scrollHeight - window.innerHeight; - const extraSpace = Math.max(0, desiredTop - maxScrollTop); - - setThreadScrollSpacerHeight(extraSpace); window.scrollTo({ top: desiredTop, left: 0, @@ -46,28 +45,3 @@ export const scrollThreadContainerToTop = (cid?: string) => { return true; }; - -export const openThreadAtTop = ({ - cid, - currentPathname, - navigate, - threadRoute, -}: { - cid?: string; - currentPathname?: string; - navigate: (route: string) => void; - threadRoute?: string; -}) => { - if (!cid || !threadRoute) return false; - - if (currentPathname !== threadRoute) { - navigate(threadRoute); - } - - scrollThreadContainerToTop(cid); - window.requestAnimationFrame(() => { - scrollThreadContainerToTop(cid); - }); - - return true; -}; diff --git a/src/views/post/__tests__/post.test.tsx b/src/views/post/__tests__/post.test.tsx index 9566be33..8345dc1e 100644 --- a/src/views/post/__tests__/post.test.tsx +++ b/src/views/post/__tests__/post.test.tsx @@ -160,12 +160,12 @@ const flushEffects = async (count = 5) => { } }; -const renderPostPage = async (initialEntry: string) => { +const renderPostPage = async (initialEntry: string | { pathname: string; state?: unknown }) => { await act(async () => { root.render( createElement( MemoryRouter, - { initialEntries: [initialEntry] }, + { initialEntries: [initialEntry as any] }, createElement( Routes, {}, @@ -202,11 +202,41 @@ describe('Post', () => { value: vi.fn(), writable: true, }); + Object.defineProperty(window, 'scrollY', { + configurable: true, + value: 0, + writable: true, + }); Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn(), writable: true, }); + Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', { + configurable: true, + value: function () { + if ((this as HTMLElement).dataset.threadContainerCid) { + return { + bottom: 220, + height: 100, + left: 0, + right: 100, + top: 120, + width: 100, + } as DOMRect; + } + + return { + bottom: 0, + height: 0, + left: 0, + right: 0, + top: 0, + width: 0, + } as DOMRect; + }, + writable: true, + }); document.title = 'before'; container = document.createElement('div'); @@ -261,12 +291,34 @@ describe('Post', () => { expect(container.querySelector('[data-testid="thread-footer-first-row"]')?.textContent).toBe('cached-cid:42:music-posting.eth:false'); expect(container.querySelector('[data-testid="thread-footer-mobile"]')?.textContent).toBe('cached-cid:42:music-posting.eth:false'); expect(document.title).toBe('/mu/ - Cached thread... - 5chan'); + expect(window.scrollTo).toHaveBeenCalledWith(0, 0); + expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled(); + }); + + it('only aligns the OP container when navigation explicitly requests it', async () => { + testState.commentsByCid = { + 'thread-cid': { + cid: 'thread-cid', + number: 8, + replyCount: 0, + subplebbitAddress: 'music-posting.eth', + title: 'Thread title', + }, + }; + + await renderPostPage({ + pathname: '/mu/thread/thread-cid', + state: { + scrollThreadContainerCid: 'thread-cid', + }, + }); + expect(window.scrollTo).toHaveBeenCalledWith({ behavior: 'auto', left: 0, - top: 0, + top: 120, }); - expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled(); + expect(window.scrollTo).not.toHaveBeenCalledWith(0, 0); }); it('redirects thread routes whose fetched comment belongs to a different board', async () => { diff --git a/src/views/post/post.tsx b/src/views/post/post.tsx index 98bffbc9..b9364003 100644 --- a/src/views/post/post.tsx +++ b/src/views/post/post.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useMemo } from 'react'; +import { memo, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Comment, Role, useComment, useEditedComment, useSubplebbit } from '@bitsocialnet/bitsocial-react-hooks'; import useSubplebbitsPagesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/subplebbits-pages'; @@ -13,7 +13,7 @@ import ErrorDisplay from '../../components/error-display/error-display'; import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFooterMobile } from '../../components/footer'; import PostDesktop from '../../components/post-desktop'; import PostMobile from '../../components/post-mobile'; -import { clearThreadScrollSpacer, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils'; +import { getRequestedThreadTopCid, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils'; import styles from './post.module.css'; // useComment may not return cached feed data immediately due to its updatedAt comparison logic. @@ -134,6 +134,7 @@ const PostPage = () => { const isInAllView = isAllView(location.pathname); const comment = useCommentWithFeedCache({ commentCid }); + const consumedThreadTopScrollRef = useRef(null); const navigate = useNavigate(); useEffect(() => { @@ -154,23 +155,32 @@ const PostPage = () => { } else { post = comment; } + const requestedThreadTopCid = getRequestedThreadTopCid(location.state); const { error } = post || {}; - useEffect(() => () => clearThreadScrollSpacer(), []); - + // These two effects split normal opens from explicit OP-top intents: + // the first keeps ordinary thread visits on `window.scrollTo(0, 0)`, while the + // second consumes `requestedThreadTopCid` once per `location.key` via + // `consumedThreadTopScrollRef` so `scrollThreadContainerToTop(commentCid)` only + // replays for deliberate OP-link clicks and never for route-driven thread opens. useEffect(() => { - if (!commentCid || post?.cid === commentCid) return; - clearThreadScrollSpacer(); - }, [commentCid, post?.cid]); + if (!comment?.cid || comment.parentCid) return; + if (requestedThreadTopCid === comment.cid) return; + window.scrollTo(0, 0); + }, [comment?.cid, comment?.parentCid, requestedThreadTopCid]); useEffect(() => { if (!commentCid || post?.cid !== commentCid) return; + if (requestedThreadTopCid !== commentCid) return; + + const consumedKey = `${location.key}:${commentCid}`; + if (consumedThreadTopScrollRef.current === consumedKey) return; + if (scrollThreadContainerToTop(commentCid)) { - return; + consumedThreadTopScrollRef.current = consumedKey; } - window.scrollTo(0, 0); - }, [commentCid, post?.cid]); + }, [commentCid, location.key, post?.cid, requestedThreadTopCid]); useEffect(() => { const boardIdentifier = params.boardIdentifier;