fix(post): align op permalinks to the outer thread container (#1050)

* fix(post): align op permalinks to the outer thread container

* perf(scroll-to-reply): stop interval when max attempts/duration reached
This commit is contained in:
Tommaso Casaburi
2026-03-10 18:08:20 +08:00
committed by GitHub
parent 4cb5c4a833
commit 6ed3708322
13 changed files with 382 additions and 151 deletions
@@ -3,6 +3,7 @@ import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ReplyQuotePreview from '../reply-quote-preview';
import styles from '../../../views/post/post.module.css';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
@@ -122,11 +123,13 @@ const appendReplyElement = ({
inViewport = true,
isThreadCard = false,
withHighlight = false,
parent = document.body,
}: {
cid: string;
inViewport?: boolean;
isThreadCard?: boolean;
withHighlight?: boolean;
parent?: HTMLElement;
}) => {
const element = document.createElement('div');
element.dataset.cid = cid;
@@ -142,6 +145,21 @@ const appendReplyElement = ({
right: 100,
top: inViewport ? 0 : -500,
}) as DOMRect;
parent.appendChild(element);
return element;
};
const appendPostInfoAnchor = (cid: string) => {
const element = document.createElement('div');
element.dataset.postInfoCid = cid;
element.scrollIntoView = vi.fn();
element.getBoundingClientRect = () =>
({
bottom: 100,
left: 0,
right: 100,
top: 0,
}) as DOMRect;
document.body.appendChild(element);
return element;
};
@@ -170,6 +188,8 @@ 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(`.${styles.replyQuotePreview}`).forEach((node) => node.remove());
document.querySelectorAll('.scroll-highlight').forEach((node) => node.remove());
});
@@ -201,8 +221,9 @@ describe('ReplyQuotePreview', () => {
expect(testState.navigateMock).not.toHaveBeenCalled();
});
it('scrolls to the thread card top for OP quotes on the current desktop thread page', async () => {
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');
await renderPreview({
isOP: true,
@@ -221,10 +242,37 @@ describe('ReplyQuotePreview', () => {
link?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(threadCard.scrollIntoView as any).toHaveBeenCalledWith({ behavior: 'auto', block: 'start' });
expect(postInfo.scrollIntoView as any).toHaveBeenCalledWith({ behavior: 'auto', block: 'start' });
expect(threadCard.scrollIntoView as any).not.toHaveBeenCalled();
expect(testState.navigateMock).not.toHaveBeenCalled();
});
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;
document.body.appendChild(previewWrapper);
const previewTarget = appendReplyElement({ cid: 'reply-cid', parent: previewWrapper });
await renderPreview({
backlinkReply: {
cid: 'reply-cid',
number: 7,
subplebbitAddress: 'music-posting.eth',
},
isBacklinkReply: true,
});
const link = queryAnchorByText('>>7');
expect(link).toBeTruthy();
await act(async () => {
link?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(previewTarget.scrollIntoView as any).not.toHaveBeenCalled();
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/thread/reply-cid');
});
it('handles desktop hover highlights and floating previews for quotelinks', async () => {
const inView = appendReplyElement({ cid: 'reply-cid', inViewport: true });
@@ -316,6 +364,30 @@ describe('ReplyQuotePreview', () => {
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/thread/reply-cid');
});
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' });
await renderPreview({
backlinkReply: {
cid: 'reply-cid',
number: 5,
subplebbitAddress: 'music-posting.eth',
},
isBacklinkReply: true,
});
const hashLink = queryAnchorByText(' #');
expect(hashLink).toBeTruthy();
await act(async () => {
hashLink?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(target.scrollIntoView as any).not.toHaveBeenCalled();
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/thread/reply-cid');
});
it('renders unresolved mobile quotelinks as plain text without a hash link', async () => {
testState.isMobile = true;
testState.quoteAvailability = 'unresolved';
@@ -67,15 +67,18 @@ const handleQuoteHover = (cid: string, onElementOutOfView: () => void) => {
}
};
const scrollToThreadCardTop = (threadCid: string) => {
const threadCard = document.querySelector<HTMLElement>(`[data-cid="${threadCid}"][data-post-cid="${threadCid}"]`);
if (!threadCard) return false;
threadCard.scrollIntoView({ behavior: 'auto', block: 'start' });
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 scrollToReplyOnPage = (cid: string) => {
const el = document.querySelector<HTMLElement>(`[data-cid="${cid}"][data-post-cid]`);
const el = getInPageScrollTarget(`[data-cid="${cid}"][data-post-cid]`);
if (!el) return false;
document.querySelectorAll('.scroll-highlight').forEach((prev) => prev.classList.remove('scroll-highlight'));
el.scrollIntoView({ behavior: 'auto', block: 'center' });
@@ -147,7 +150,7 @@ const DesktopQuotePreview = ({
const threadRoute = `/${boardPath}/thread/${cid}`;
if (isOpQuote) {
if (location.pathname === threadRoute) {
scrollToThreadCardTop(cid);
scrollToThreadPostInfoTop(cid);
} else {
navigate(threadRoute);
}
@@ -292,7 +295,6 @@ 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();
@@ -301,13 +303,12 @@ const MobileQuotePreview = ({
const threadRoute = `/${boardPath}/thread/${cid}`;
if (isOpQuote) {
if (location.pathname === threadRoute) {
scrollToThreadCardTop(cid);
scrollToThreadPostInfoTop(cid);
} else {
navigate(threadRoute);
}
return;
}
if (isOnThreadPage && scrollToReplyOnPage(cid)) return;
navigate(threadRoute);
}
};