mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -2,6 +2,7 @@
|
||||
[](https://github.com/bitsocialnet/5chan/releases/latest)
|
||||
[](https://github.com/bitsocialnet/5chan/blob/master/LICENSE)
|
||||
[](http://commitizen.github.io/cz-cli/)
|
||||
[](https://deepwiki.com/bitsocialnet/5chan)
|
||||
|
||||
<img src="https://github.com/plebeius-eth/assets/blob/main/5chan-logo.jpg" width="378" height="123">
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
let requestAnimationFrameMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<HTMLElement>(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<HTMLElement>(`[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;
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
+20
-10
@@ -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<string | null>(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;
|
||||
|
||||
Reference in New Issue
Block a user