mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
|
||||
import { Link, useLocation, useNavigate, 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,6 +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 { scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
|
||||
|
||||
const { addChallenge } = useChallengesStore.getState();
|
||||
|
||||
@@ -109,6 +110,7 @@ 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();
|
||||
@@ -265,8 +267,26 @@ const PostInfo = ({
|
||||
});
|
||||
};
|
||||
|
||||
const threadRoute = cid ? (boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`) : undefined;
|
||||
|
||||
const onLinkToPostClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (!cid || !threadRoute) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInPostPageView && !isReply) {
|
||||
e.preventDefault();
|
||||
navigate(threadRoute);
|
||||
scrollThreadContainerToTop(cid);
|
||||
window.requestAnimationFrame(() => {
|
||||
scrollThreadContainerToTop(cid);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.postInfo}>
|
||||
<div className={styles.postInfo} data-post-info-cid={cid}>
|
||||
{isHidden ? parentCid && <span className={styles.hiddenReplyEditMenuSpacer} /> : <EditMenu post={post} />}
|
||||
<span className={(hidden || ((removed || deleted || purged) && !reason)) && parentCid ? styles.postDesktopHidden : ''}>
|
||||
{title &&
|
||||
@@ -362,12 +382,7 @@ const PostInfo = ({
|
||||
<span className={styles.postNum}>
|
||||
{cid ? (
|
||||
<span className={styles.postNumLink}>
|
||||
<Link
|
||||
to={boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`}
|
||||
className={styles.linkToPost}
|
||||
title={t('link_to_post')}
|
||||
onClick={(e) => !cid && e.preventDefault()}
|
||||
>
|
||||
<Link to={threadRoute || '#'} className={styles.linkToPost} title={t('link_to_post')} onClick={onLinkToPostClick}>
|
||||
No.
|
||||
</Link>
|
||||
<span
|
||||
@@ -980,6 +995,7 @@ const PostDesktop = ({
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
data-thread-container-cid={cid}
|
||||
data-cid={cid}
|
||||
data-author-address={author?.shortAddress}
|
||||
data-post-cid={postCid}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
|
||||
import { Link, useLocation, useNavigate, 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,6 +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 { scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
|
||||
|
||||
const { addChallenge } = useChallengesStore.getState();
|
||||
|
||||
@@ -78,6 +79,7 @@ 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);
|
||||
@@ -244,9 +246,27 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
|
||||
});
|
||||
};
|
||||
|
||||
const threadRoute = cid ? (boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`) : undefined;
|
||||
|
||||
const onLinkToPostClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (!cid || !threadRoute) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInPostPageView && !isReply) {
|
||||
e.preventDefault();
|
||||
navigate(threadRoute);
|
||||
scrollThreadContainerToTop(cid);
|
||||
window.requestAnimationFrame(() => {
|
||||
scrollThreadContainerToTop(cid);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.postInfo}>
|
||||
<div className={styles.postInfo} data-post-info-cid={cid}>
|
||||
<PostMenuMobile postMenu={postMenuProps} editMenuPost={post} />
|
||||
<span className={(hidden || ((removed || deleted || purged) && !reason)) && parentCid ? styles.postDesktopHidden : ''}>
|
||||
<span className={styles.nameBlock}>
|
||||
@@ -362,12 +382,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
|
||||
)}{' '}
|
||||
{cid ? (
|
||||
<span className={styles.postNumLink}>
|
||||
<Link
|
||||
to={boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`}
|
||||
className={styles.linkToPost}
|
||||
title={t('link_to_post')}
|
||||
onClick={(e) => !cid && e.preventDefault()}
|
||||
>
|
||||
<Link to={threadRoute || '#'} className={styles.linkToPost} title={t('link_to_post')} onClick={onLinkToPostClick}>
|
||||
No.
|
||||
</Link>
|
||||
<span
|
||||
@@ -722,6 +737,7 @@ const PostMobile = ({
|
||||
<div className={styles.postContainer}>
|
||||
<div
|
||||
className={`${styles.postOp} ${shouldShowSnow() ? styles.xmasHatWrapper : ''}`}
|
||||
data-thread-container-cid={cid}
|
||||
data-cid={cid}
|
||||
data-author-address={author?.shortAddress}
|
||||
data-post-cid={postCid}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user