fix(post): stop thread navigation from forcing OP alignment (#1052)

* Update README.md

* fix(post): stop thread navigation from forcing OP alignment

Route-driven thread opens now use the normal top-of-page behavior, while explicit OP permalink intents carry a scrollThreadContainerCid state and resolve against the visible thread container only. The old spacer injection path is removed.

* fix(post): address PR review follow-ups
This commit is contained in:
Tommaso Casaburi
2026-03-11 18:43:36 +08:00
committed by GitHub
parent 3401ec147f
commit 6c03253cb4
9 changed files with 297 additions and 199 deletions
+8 -13
View File
@@ -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<HTMLAnchorElement>) => {
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 = ({
<span className={styles.postNum}>
{cid ? (
<span className={styles.postNumLink}>
<Link to={threadRoute || '#'} className={styles.linkToPost} title={t('link_to_post')} onClick={onLinkToPostClick}>
<Link to={threadRoute || '#'} state={threadTopNavigationState} className={styles.linkToPost} title={t('link_to_post')} onClick={onLinkToPostClick}>
No.
</Link>
<span
+8 -13
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { 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, usePublishCommentModeration, useAccountComment } from '@bitsocialnet/bitsocial-react-hooks';
import getShortAddress from '../../lib/get-short-address';
@@ -42,7 +42,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 { filterRepliesForDisplay, getPreviewDisplayReplies } 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();
@@ -79,7 +79,6 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
const params = useParams();
const location = useLocation();
const navigate = useNavigate();
const isInAllView = isAllView(location.pathname);
const isInPostPageView = isPostPageView(location.pathname, params);
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
@@ -247,6 +246,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
};
const threadRoute = cid ? (boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`) : undefined;
const threadTopNavigationState = !isReply ? getThreadTopNavigationState(cid) : undefined;
const onLinkToPostClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
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 ? (
<span className={styles.postNumLink}>
<Link to={threadRoute || '#'} className={styles.linkToPost} title={t('link_to_post')} onClick={onLinkToPostClick}>
<Link to={threadRoute || '#'} state={threadTopNavigationState} className={styles.linkToPost} title={t('link_to_post')} onClick={onLinkToPostClick}>
No.
</Link>
<span
@@ -149,18 +149,19 @@ const appendReplyElement = ({
return element;
};
const appendPostInfoAnchor = (cid: string) => {
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' });
@@ -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<HTMLElement>(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(
<div className={styles.replyQuotePreview} ref={refs.setFloating} style={floatingStyles}>
<div className={styles.replyQuotePreview} data-thread-scroll-preview='true' ref={refs.setFloating} style={floatingStyles}>
<Post post={backlinkReply} showReplies={false} />
</div>,
document.body,
@@ -253,7 +243,7 @@ const DesktopQuotePreview = ({
{showTrailingBreak && <br />}
{shouldShowQuotelinkPreview &&
createPortal(
<div className={styles.replyQuotePreview} ref={refs.setFloating} style={floatingStyles}>
<div className={styles.replyQuotePreview} data-thread-scroll-preview='true' ref={refs.setFloating} style={floatingStyles}>
<Post post={quotelinkReply} showReplies={false} />
</div>,
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(
<div className={styles.replyQuotePreview} ref={refs.setFloating} style={floatingStyles}>
<div className={styles.replyQuotePreview} data-thread-scroll-preview='true' ref={refs.setFloating} style={floatingStyles}>
<Post post={backlinkReply} showReplies={false} />
</div>,
document.body,
@@ -411,7 +399,7 @@ const MobileQuotePreview = ({
{showTrailingBreak && <br />}
{shouldShowQuotelinkPreview &&
createPortal(
<div className={styles.replyQuotePreview} ref={refs.setFloating} style={floatingStyles}>
<div className={styles.replyQuotePreview} data-thread-scroll-preview='true' ref={refs.setFloating} style={floatingStyles}>
<Post post={quotelinkReply} showReplies={false} />
</div>,
document.body,