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:
@@ -95,6 +95,7 @@ vi.mock('../lib/snow', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../lib/utils/preload-utils', () => ({
|
vi.mock('../lib/utils/preload-utils', () => ({
|
||||||
|
preloadReplyModal: vi.fn(),
|
||||||
preloadThemeAssets: vi.fn(),
|
preloadThemeAssets: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,7 @@ import { Navigate, Outlet, Route, Routes, useLocation, useParams } from 'react-r
|
|||||||
import { useAccount, useAccountComment, useSubplebbit } from '@bitsocialnet/bitsocial-react-hooks';
|
import { useAccount, useAccountComment, useSubplebbit } from '@bitsocialnet/bitsocial-react-hooks';
|
||||||
import { initSnow, removeSnow } from './lib/snow';
|
import { initSnow, removeSnow } from './lib/snow';
|
||||||
import { isAllView, isCatalogView, isModView, isSubscriptionsView } from './lib/utils/view-utils';
|
import { isAllView, isCatalogView, isModView, isSubscriptionsView } from './lib/utils/view-utils';
|
||||||
import { preloadThemeAssets } from './lib/utils/preload-utils';
|
import { preloadReplyModal, preloadThemeAssets } from './lib/utils/preload-utils';
|
||||||
import useReplyModalStore from './stores/use-reply-modal-store';
|
import useReplyModalStore from './stores/use-reply-modal-store';
|
||||||
import useCreateBoardModalStore from './stores/use-create-board-modal-store';
|
import useCreateBoardModalStore from './stores/use-create-board-modal-store';
|
||||||
import useSpecialThemeStore from './stores/use-special-theme-store';
|
import useSpecialThemeStore from './stores/use-special-theme-store';
|
||||||
@@ -55,6 +55,7 @@ const SettingsModal = lazy(() => import('./components/settings-modal'));
|
|||||||
// Preload all theme assets (buttons, backgrounds) immediately on app load
|
// Preload all theme assets (buttons, backgrounds) immediately on app load
|
||||||
// to prevent visible loading delays when switching themes
|
// to prevent visible loading delays when switching themes
|
||||||
preloadThemeAssets();
|
preloadThemeAssets();
|
||||||
|
preloadReplyModal();
|
||||||
|
|
||||||
const hasModQueueAccessRole = (role?: string): boolean => role === 'admin' || role === 'owner' || role === 'moderator';
|
const hasModQueueAccessRole = (role?: string): boolean => role === 'admin' || role === 'owner' || role === 'moderator';
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
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 { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
|
||||||
import { Comment, deleteComment, useEditedComment, useReplies, useAccount, useAccountComment } from '@bitsocialnet/bitsocial-react-hooks';
|
import { Comment, deleteComment, useEditedComment, useReplies, useAccount, useAccountComment } from '@bitsocialnet/bitsocial-react-hooks';
|
||||||
import getShortAddress from '../../lib/get-short-address';
|
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 useProgressiveRender from '../../hooks/use-progressive-render';
|
||||||
import { BOARD_REPLIES_PREVIEW_FETCH_SIZE, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT, REPLIES_PER_PAGE } from '../../lib/constants';
|
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 { computeOmittedCount, filterRepliesForDisplay, getPreviewDisplayReplies, getTotalReplyCount } from '../../lib/utils/replies-preview-utils';
|
||||||
|
import { scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
|
||||||
|
|
||||||
const { addChallenge } = useChallengesStore.getState();
|
const { addChallenge } = useChallengesStore.getState();
|
||||||
|
|
||||||
@@ -109,6 +110,7 @@ const PostInfo = ({
|
|||||||
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const isInPostPageView = isPostPageView(location.pathname, params);
|
const isInPostPageView = isPostPageView(location.pathname, params);
|
||||||
const isInModQueueView = isModQueueView(location.pathname);
|
const isInModQueueView = isModQueueView(location.pathname);
|
||||||
const { getAlertThresholdSeconds } = useModQueueStore();
|
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 (
|
return (
|
||||||
<div className={styles.postInfo}>
|
<div className={styles.postInfo} data-post-info-cid={cid}>
|
||||||
{isHidden ? parentCid && <span className={styles.hiddenReplyEditMenuSpacer} /> : <EditMenu post={post} />}
|
{isHidden ? parentCid && <span className={styles.hiddenReplyEditMenuSpacer} /> : <EditMenu post={post} />}
|
||||||
<span className={(hidden || ((removed || deleted || purged) && !reason)) && parentCid ? styles.postDesktopHidden : ''}>
|
<span className={(hidden || ((removed || deleted || purged) && !reason)) && parentCid ? styles.postDesktopHidden : ''}>
|
||||||
{title &&
|
{title &&
|
||||||
@@ -362,12 +382,7 @@ const PostInfo = ({
|
|||||||
<span className={styles.postNum}>
|
<span className={styles.postNum}>
|
||||||
{cid ? (
|
{cid ? (
|
||||||
<span className={styles.postNumLink}>
|
<span className={styles.postNumLink}>
|
||||||
<Link
|
<Link to={threadRoute || '#'} className={styles.linkToPost} title={t('link_to_post')} onClick={onLinkToPostClick}>
|
||||||
to={boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`}
|
|
||||||
className={styles.linkToPost}
|
|
||||||
title={t('link_to_post')}
|
|
||||||
onClick={(e) => !cid && e.preventDefault()}
|
|
||||||
>
|
|
||||||
No.
|
No.
|
||||||
</Link>
|
</Link>
|
||||||
<span
|
<span
|
||||||
@@ -980,6 +995,7 @@ const PostDesktop = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
|
data-thread-container-cid={cid}
|
||||||
data-cid={cid}
|
data-cid={cid}
|
||||||
data-author-address={author?.shortAddress}
|
data-author-address={author?.shortAddress}
|
||||||
data-post-cid={postCid}
|
data-post-cid={postCid}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
|
||||||
import { Comment, deleteComment, useEditedComment, useReplies, useAccount, usePublishCommentModeration, useAccountComment } from '@bitsocialnet/bitsocial-react-hooks';
|
import { Comment, deleteComment, useEditedComment, useReplies, useAccount, usePublishCommentModeration, useAccountComment } from '@bitsocialnet/bitsocial-react-hooks';
|
||||||
import getShortAddress from '../../lib/get-short-address';
|
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 useProgressiveRender from '../../hooks/use-progressive-render';
|
||||||
import { BOARD_REPLIES_PREVIEW_FETCH_SIZE, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT, REPLIES_PER_PAGE } from '../../lib/constants';
|
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 { filterRepliesForDisplay, getPreviewDisplayReplies } from '../../lib/utils/replies-preview-utils';
|
||||||
|
import { scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
|
||||||
|
|
||||||
const { addChallenge } = useChallengesStore.getState();
|
const { addChallenge } = useChallengesStore.getState();
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
|
|||||||
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
const isInAllView = isAllView(location.pathname);
|
const isInAllView = isAllView(location.pathname);
|
||||||
const isInPostPageView = isPostPageView(location.pathname, params);
|
const isInPostPageView = isPostPageView(location.pathname, params);
|
||||||
const isInSubscriptionsView = isSubscriptionsView(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={styles.postInfo}>
|
<div className={styles.postInfo} data-post-info-cid={cid}>
|
||||||
<PostMenuMobile postMenu={postMenuProps} editMenuPost={post} />
|
<PostMenuMobile postMenu={postMenuProps} editMenuPost={post} />
|
||||||
<span className={(hidden || ((removed || deleted || purged) && !reason)) && parentCid ? styles.postDesktopHidden : ''}>
|
<span className={(hidden || ((removed || deleted || purged) && !reason)) && parentCid ? styles.postDesktopHidden : ''}>
|
||||||
<span className={styles.nameBlock}>
|
<span className={styles.nameBlock}>
|
||||||
@@ -362,12 +382,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos
|
|||||||
)}{' '}
|
)}{' '}
|
||||||
{cid ? (
|
{cid ? (
|
||||||
<span className={styles.postNumLink}>
|
<span className={styles.postNumLink}>
|
||||||
<Link
|
<Link to={threadRoute || '#'} className={styles.linkToPost} title={t('link_to_post')} onClick={onLinkToPostClick}>
|
||||||
to={boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`}
|
|
||||||
className={styles.linkToPost}
|
|
||||||
title={t('link_to_post')}
|
|
||||||
onClick={(e) => !cid && e.preventDefault()}
|
|
||||||
>
|
|
||||||
No.
|
No.
|
||||||
</Link>
|
</Link>
|
||||||
<span
|
<span
|
||||||
@@ -722,6 +737,7 @@ const PostMobile = ({
|
|||||||
<div className={styles.postContainer}>
|
<div className={styles.postContainer}>
|
||||||
<div
|
<div
|
||||||
className={`${styles.postOp} ${shouldShowSnow() ? styles.xmasHatWrapper : ''}`}
|
className={`${styles.postOp} ${shouldShowSnow() ? styles.xmasHatWrapper : ''}`}
|
||||||
|
data-thread-container-cid={cid}
|
||||||
data-cid={cid}
|
data-cid={cid}
|
||||||
data-author-address={author?.shortAddress}
|
data-author-address={author?.shortAddress}
|
||||||
data-post-cid={postCid}
|
data-post-cid={postCid}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createElement } from 'react';
|
|||||||
import { createRoot, type Root } from 'react-dom/client';
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import ReplyQuotePreview from '../reply-quote-preview';
|
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;
|
(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>;
|
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,
|
inViewport = true,
|
||||||
isThreadCard = false,
|
isThreadCard = false,
|
||||||
withHighlight = false,
|
withHighlight = false,
|
||||||
|
parent = document.body,
|
||||||
}: {
|
}: {
|
||||||
cid: string;
|
cid: string;
|
||||||
inViewport?: boolean;
|
inViewport?: boolean;
|
||||||
isThreadCard?: boolean;
|
isThreadCard?: boolean;
|
||||||
withHighlight?: boolean;
|
withHighlight?: boolean;
|
||||||
|
parent?: HTMLElement;
|
||||||
}) => {
|
}) => {
|
||||||
const element = document.createElement('div');
|
const element = document.createElement('div');
|
||||||
element.dataset.cid = cid;
|
element.dataset.cid = cid;
|
||||||
@@ -142,6 +145,21 @@ const appendReplyElement = ({
|
|||||||
right: 100,
|
right: 100,
|
||||||
top: inViewport ? 0 : -500,
|
top: inViewport ? 0 : -500,
|
||||||
}) as DOMRect;
|
}) 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);
|
document.body.appendChild(element);
|
||||||
return element;
|
return element;
|
||||||
};
|
};
|
||||||
@@ -170,6 +188,8 @@ describe('ReplyQuotePreview', () => {
|
|||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
container.remove();
|
container.remove();
|
||||||
document.querySelectorAll('[data-cid]').forEach((node) => node.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());
|
document.querySelectorAll('.scroll-highlight').forEach((node) => node.remove());
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -201,8 +221,9 @@ describe('ReplyQuotePreview', () => {
|
|||||||
expect(testState.navigateMock).not.toHaveBeenCalled();
|
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 threadCard = appendReplyElement({ cid: 'thread-cid', isThreadCard: true });
|
||||||
|
const postInfo = appendPostInfoAnchor('thread-cid');
|
||||||
|
|
||||||
await renderPreview({
|
await renderPreview({
|
||||||
isOP: true,
|
isOP: true,
|
||||||
@@ -221,10 +242,37 @@ describe('ReplyQuotePreview', () => {
|
|||||||
link?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
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();
|
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 () => {
|
it('handles desktop hover highlights and floating previews for quotelinks', async () => {
|
||||||
const inView = appendReplyElement({ cid: 'reply-cid', inViewport: true });
|
const inView = appendReplyElement({ cid: 'reply-cid', inViewport: true });
|
||||||
|
|
||||||
@@ -316,6 +364,30 @@ describe('ReplyQuotePreview', () => {
|
|||||||
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/thread/reply-cid');
|
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 () => {
|
it('renders unresolved mobile quotelinks as plain text without a hash link', async () => {
|
||||||
testState.isMobile = true;
|
testState.isMobile = true;
|
||||||
testState.quoteAvailability = 'unresolved';
|
testState.quoteAvailability = 'unresolved';
|
||||||
|
|||||||
@@ -67,15 +67,18 @@ const handleQuoteHover = (cid: string, onElementOutOfView: () => void) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToThreadCardTop = (threadCid: string) => {
|
const getInPageScrollTarget = (selector: string) =>
|
||||||
const threadCard = document.querySelector<HTMLElement>(`[data-cid="${threadCid}"][data-post-cid="${threadCid}"]`);
|
Array.from(document.querySelectorAll<HTMLElement>(selector)).find((element) => !element.closest(`.${styles.replyQuotePreview}`));
|
||||||
if (!threadCard) return false;
|
|
||||||
threadCard.scrollIntoView({ behavior: 'auto', block: 'start' });
|
const scrollToThreadPostInfoTop = (threadCid: string) => {
|
||||||
|
const postInfo = getInPageScrollTarget(`[data-post-info-cid="${threadCid}"]`);
|
||||||
|
if (!postInfo) return false;
|
||||||
|
postInfo.scrollIntoView({ behavior: 'auto', block: 'start' });
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToReplyOnPage = (cid: string) => {
|
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;
|
if (!el) return false;
|
||||||
document.querySelectorAll('.scroll-highlight').forEach((prev) => prev.classList.remove('scroll-highlight'));
|
document.querySelectorAll('.scroll-highlight').forEach((prev) => prev.classList.remove('scroll-highlight'));
|
||||||
el.scrollIntoView({ behavior: 'auto', block: 'center' });
|
el.scrollIntoView({ behavior: 'auto', block: 'center' });
|
||||||
@@ -147,7 +150,7 @@ const DesktopQuotePreview = ({
|
|||||||
const threadRoute = `/${boardPath}/thread/${cid}`;
|
const threadRoute = `/${boardPath}/thread/${cid}`;
|
||||||
if (isOpQuote) {
|
if (isOpQuote) {
|
||||||
if (location.pathname === threadRoute) {
|
if (location.pathname === threadRoute) {
|
||||||
scrollToThreadCardTop(cid);
|
scrollToThreadPostInfoTop(cid);
|
||||||
} else {
|
} else {
|
||||||
navigate(threadRoute);
|
navigate(threadRoute);
|
||||||
}
|
}
|
||||||
@@ -292,7 +295,6 @@ const MobileQuotePreview = ({
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const isOnThreadPage = location.pathname.includes('/thread/');
|
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent, cid: string | undefined, subplebbitAddress: string | undefined, isOpQuote = false) => {
|
const handleClick = (e: React.MouseEvent, cid: string | undefined, subplebbitAddress: string | undefined, isOpQuote = false) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -301,13 +303,12 @@ const MobileQuotePreview = ({
|
|||||||
const threadRoute = `/${boardPath}/thread/${cid}`;
|
const threadRoute = `/${boardPath}/thread/${cid}`;
|
||||||
if (isOpQuote) {
|
if (isOpQuote) {
|
||||||
if (location.pathname === threadRoute) {
|
if (location.pathname === threadRoute) {
|
||||||
scrollToThreadCardTop(cid);
|
scrollToThreadPostInfoTop(cid);
|
||||||
} else {
|
} else {
|
||||||
navigate(threadRoute);
|
navigate(threadRoute);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isOnThreadPage && scrollToReplyOnPage(cid)) return;
|
|
||||||
navigate(threadRoute);
|
navigate(threadRoute);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -75,39 +75,69 @@ describe('useScrollToReply', () => {
|
|||||||
targetReplyCid: 'reply-2',
|
targetReplyCid: 'reply-2',
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(350);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(testState.scrollToIndexMock).toHaveBeenCalledWith({
|
expect(testState.scrollToIndexMock).toHaveBeenCalledWith({
|
||||||
align: 'center',
|
align: 'center',
|
||||||
behavior: 'smooth',
|
behavior: 'auto',
|
||||||
index: 1,
|
index: 1,
|
||||||
});
|
});
|
||||||
expect(testState.loadMoreMock).not.toHaveBeenCalled();
|
expect(testState.loadMoreMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('scrolls to the latest loaded reply and schedules loadMore while searching', async () => {
|
it('centers a mounted target immediately even when it is already visible', async () => {
|
||||||
|
const replyElement = document.createElement('div');
|
||||||
|
const scrollIntoViewMock = vi.fn();
|
||||||
|
replyElement.dataset.cid = 'reply-2';
|
||||||
|
replyElement.dataset.postCid = 'post-1';
|
||||||
|
replyElement.scrollIntoView = scrollIntoViewMock;
|
||||||
|
document.body.appendChild(replyElement);
|
||||||
|
|
||||||
|
await renderHook({
|
||||||
|
hasMore: true,
|
||||||
|
replies: [{ cid: 'reply-1' }, { cid: 'reply-2' }],
|
||||||
|
targetReplyCid: 'reply-2',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scrollIntoViewMock).toHaveBeenCalledWith({
|
||||||
|
behavior: 'auto',
|
||||||
|
block: 'center',
|
||||||
|
});
|
||||||
|
expect(testState.scrollToIndexMock).not.toHaveBeenCalled();
|
||||||
|
expect(testState.loadMoreMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrolls a mounted offscreen target into view immediately', async () => {
|
||||||
|
const replyElement = document.createElement('div');
|
||||||
|
const scrollIntoViewMock = vi.fn();
|
||||||
|
replyElement.dataset.cid = 'reply-3';
|
||||||
|
replyElement.dataset.postCid = 'post-1';
|
||||||
|
replyElement.scrollIntoView = scrollIntoViewMock;
|
||||||
|
document.body.appendChild(replyElement);
|
||||||
|
|
||||||
|
await renderHook({
|
||||||
|
hasMore: true,
|
||||||
|
replies: [{ cid: 'reply-1' }, { cid: 'reply-3' }],
|
||||||
|
targetReplyCid: 'reply-3',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scrollIntoViewMock).toHaveBeenCalledWith({
|
||||||
|
behavior: 'auto',
|
||||||
|
block: 'center',
|
||||||
|
});
|
||||||
|
expect(testState.scrollToIndexMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scrolls to the latest loaded reply and triggers loadMore immediately while searching', async () => {
|
||||||
await renderHook({
|
await renderHook({
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
replies: [{ cid: 'reply-1' }, { cid: 'reply-2' }],
|
replies: [{ cid: 'reply-1' }, { cid: 'reply-2' }],
|
||||||
targetReplyCid: 'reply-3',
|
targetReplyCid: 'reply-3',
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(350);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(testState.scrollToIndexMock).toHaveBeenCalledWith({
|
expect(testState.scrollToIndexMock).toHaveBeenCalledWith({
|
||||||
align: 'end',
|
align: 'end',
|
||||||
behavior: 'smooth',
|
behavior: 'auto',
|
||||||
index: 1,
|
index: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(300);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(testState.loadMoreMock).toHaveBeenCalledTimes(1);
|
expect(testState.loadMoreMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,6 +145,7 @@ describe('useScrollToReply', () => {
|
|||||||
const replyElement = document.createElement('div');
|
const replyElement = document.createElement('div');
|
||||||
const scrollIntoViewMock = vi.fn();
|
const scrollIntoViewMock = vi.fn();
|
||||||
replyElement.dataset.cid = 'reply-3';
|
replyElement.dataset.cid = 'reply-3';
|
||||||
|
replyElement.dataset.postCid = 'post-1';
|
||||||
replyElement.scrollIntoView = scrollIntoViewMock;
|
replyElement.scrollIntoView = scrollIntoViewMock;
|
||||||
document.body.appendChild(replyElement);
|
document.body.appendChild(replyElement);
|
||||||
|
|
||||||
@@ -124,12 +155,8 @@ describe('useScrollToReply', () => {
|
|||||||
targetReplyCid: 'reply-3',
|
targetReplyCid: 'reply-3',
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(350);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(scrollIntoViewMock).toHaveBeenCalledWith({
|
expect(scrollIntoViewMock).toHaveBeenCalledWith({
|
||||||
behavior: 'smooth',
|
behavior: 'auto',
|
||||||
block: 'center',
|
block: 'center',
|
||||||
});
|
});
|
||||||
expect(testState.scrollToIndexMock).not.toHaveBeenCalled();
|
expect(testState.scrollToIndexMock).not.toHaveBeenCalled();
|
||||||
@@ -142,10 +169,6 @@ describe('useScrollToReply', () => {
|
|||||||
targetReplyCid: 'reply-404',
|
targetReplyCid: 'reply-404',
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(350);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(warnSpy).toHaveBeenCalledWith('[scroll-to-reply] Could not find reply with CID "reply-404" in the feed.');
|
expect(warnSpy).toHaveBeenCalledWith('[scroll-to-reply] Could not find reply with CID "reply-404" in the feed.');
|
||||||
expect(testState.loadMoreMock).not.toHaveBeenCalled();
|
expect(testState.loadMoreMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,29 +16,25 @@ interface UseScrollToReplyParams {
|
|||||||
|
|
||||||
const DEFAULT_MAX_LOAD_ATTEMPTS = 500;
|
const DEFAULT_MAX_LOAD_ATTEMPTS = 500;
|
||||||
const MAX_LOAD_DURATION_MS = 120000;
|
const MAX_LOAD_DURATION_MS = 120000;
|
||||||
const LOAD_MORE_THROTTLE_MS = 300;
|
const LOAD_MORE_THROTTLE_MS = 150;
|
||||||
const AUTO_SCROLL_INTERVAL_MS = 350;
|
const AUTO_SCROLL_INTERVAL_MS = 100;
|
||||||
|
|
||||||
|
const getMountedReplyElement = (targetReplyCid: string) =>
|
||||||
|
Array.from(document.querySelectorAll<HTMLElement>(`[data-cid="${targetReplyCid}"][data-post-cid]`)).find((element) => !element.closest('[class*="replyQuotePreview"]'));
|
||||||
|
|
||||||
const useScrollToReply = ({ targetReplyCid, replies, hasMore, loadMore, virtuosoRef, enabled = true }: UseScrollToReplyParams) => {
|
const useScrollToReply = ({ targetReplyCid, replies, hasMore, loadMore, virtuosoRef, enabled = true }: UseScrollToReplyParams) => {
|
||||||
const hasScrolledRef = useRef(false);
|
const hasScrolledRef = useRef(false);
|
||||||
const loadAttemptsRef = useRef(0);
|
const loadAttemptsRef = useRef(0);
|
||||||
const lastRepliesLengthRef = useRef(replies.length);
|
|
||||||
const loadMoreTimeoutRef = useRef<number | null>(null);
|
|
||||||
const lastLoadAtRef = useRef(0);
|
const lastLoadAtRef = useRef(0);
|
||||||
const loadStartAtRef = useRef(0);
|
const loadStartAtRef = useRef(0);
|
||||||
const lastScrollIndexRef = useRef(-1);
|
const lastScrollIndexRef = useRef(-1);
|
||||||
const intervalRef = useRef<number | null>(null);
|
const intervalRef = useRef<number | null>(null);
|
||||||
const latestTargetRef = useRef(targetReplyCid);
|
|
||||||
const latestRepliesRef = useRef(replies);
|
|
||||||
const latestHasMoreRef = useRef(hasMore);
|
|
||||||
const latestLoadMoreRef = useRef(loadMore);
|
|
||||||
|
|
||||||
// Only reset when the target changes, NOT when replies.length changes
|
// Only reset when the target changes, NOT when replies.length changes
|
||||||
// (replies.length changing is expected as we load more pages)
|
// (replies.length changing is expected as we load more pages)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hasScrolledRef.current = false;
|
hasScrolledRef.current = false;
|
||||||
loadAttemptsRef.current = 0;
|
loadAttemptsRef.current = 0;
|
||||||
lastRepliesLengthRef.current = replies.length;
|
|
||||||
lastLoadAtRef.current = 0;
|
lastLoadAtRef.current = 0;
|
||||||
loadStartAtRef.current = Date.now();
|
loadStartAtRef.current = Date.now();
|
||||||
lastScrollIndexRef.current = -1;
|
lastScrollIndexRef.current = -1;
|
||||||
@@ -46,107 +42,73 @@ const useScrollToReply = ({ targetReplyCid, replies, hasMore, loadMore, virtuoso
|
|||||||
window.clearInterval(intervalRef.current);
|
window.clearInterval(intervalRef.current);
|
||||||
intervalRef.current = null;
|
intervalRef.current = null;
|
||||||
}
|
}
|
||||||
if (loadMoreTimeoutRef.current) {
|
|
||||||
window.clearTimeout(loadMoreTimeoutRef.current);
|
|
||||||
loadMoreTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [targetReplyCid]);
|
}, [targetReplyCid]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
latestTargetRef.current = targetReplyCid;
|
if (!enabled || !targetReplyCid || hasScrolledRef.current) return;
|
||||||
}, [targetReplyCid]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const attemptScroll = () => {
|
||||||
latestRepliesRef.current = replies;
|
if (hasScrolledRef.current) {
|
||||||
}, [replies]);
|
return true;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
latestHasMoreRef.current = hasMore;
|
|
||||||
}, [hasMore]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
latestLoadMoreRef.current = loadMore;
|
|
||||||
}, [loadMore]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (replies.length !== lastRepliesLengthRef.current) {
|
|
||||||
lastRepliesLengthRef.current = replies.length;
|
|
||||||
}
|
|
||||||
}, [replies.length]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled || !targetReplyCid || intervalRef.current) return;
|
|
||||||
|
|
||||||
intervalRef.current = window.setInterval(() => {
|
|
||||||
const latestTarget = latestTargetRef.current;
|
|
||||||
const latestReplies = latestRepliesRef.current;
|
|
||||||
const latestHasMore = latestHasMoreRef.current;
|
|
||||||
const latestLoadMore = latestLoadMoreRef.current;
|
|
||||||
|
|
||||||
if (!enabled || !latestTarget || hasScrolledRef.current) {
|
|
||||||
if (intervalRef.current) {
|
|
||||||
window.clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!latestHasMore) {
|
const mountedTarget = getMountedReplyElement(targetReplyCid);
|
||||||
const element = document.querySelector(`[data-cid="${latestTarget}"]`);
|
if (mountedTarget) {
|
||||||
if (element) {
|
mountedTarget.scrollIntoView({
|
||||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
behavior: 'auto',
|
||||||
hasScrolledRef.current = true;
|
block: 'center',
|
||||||
} else {
|
});
|
||||||
// Reply not found after loading all pages
|
hasScrolledRef.current = true;
|
||||||
console.warn(`[scroll-to-reply] Could not find reply with CID "${latestTarget}" in the feed.`);
|
return true;
|
||||||
hasScrolledRef.current = true; // Stop trying
|
|
||||||
}
|
|
||||||
if (intervalRef.current) {
|
|
||||||
window.clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iteratively load pages until the target reply appears in the list.
|
const targetIndex = replies.findIndex((reply) => reply?.cid === targetReplyCid);
|
||||||
const targetIndex = latestReplies.findIndex((reply) => reply?.cid === latestTarget);
|
|
||||||
if (targetIndex >= 0) {
|
if (targetIndex >= 0) {
|
||||||
hasScrolledRef.current = true;
|
hasScrolledRef.current = true;
|
||||||
if (loadMoreTimeoutRef.current) {
|
virtuosoRef.current?.scrollToIndex({ index: targetIndex, align: 'center', behavior: 'auto' });
|
||||||
window.clearTimeout(loadMoreTimeoutRef.current);
|
return true;
|
||||||
loadMoreTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
if (intervalRef.current) {
|
|
||||||
window.clearInterval(intervalRef.current);
|
|
||||||
intervalRef.current = null;
|
|
||||||
}
|
|
||||||
virtuosoRef.current?.scrollToIndex({ index: targetIndex, align: 'center', behavior: 'smooth' });
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Smoothly scroll to the latest loaded replies while loading more pages
|
if (!hasMore) {
|
||||||
const lastIndex = latestReplies.length - 1;
|
console.warn(`[scroll-to-reply] Could not find reply with CID "${targetReplyCid}" in the feed.`);
|
||||||
|
hasScrolledRef.current = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the viewport close to the newly loaded tail while the target is still missing.
|
||||||
|
const lastIndex = replies.length - 1;
|
||||||
if (lastIndex >= 0 && lastIndex !== lastScrollIndexRef.current) {
|
if (lastIndex >= 0 && lastIndex !== lastScrollIndexRef.current) {
|
||||||
lastScrollIndexRef.current = lastIndex;
|
lastScrollIndexRef.current = lastIndex;
|
||||||
virtuosoRef.current?.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'smooth' });
|
virtuosoRef.current?.scrollToIndex({ index: lastIndex, align: 'end', behavior: 'auto' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadDuration = Date.now() - loadStartAtRef.current;
|
const loadDuration = Date.now() - loadStartAtRef.current;
|
||||||
if (!latestHasMore || loadAttemptsRef.current >= DEFAULT_MAX_LOAD_ATTEMPTS || loadDuration >= MAX_LOAD_DURATION_MS) return;
|
if (loadAttemptsRef.current >= DEFAULT_MAX_LOAD_ATTEMPTS || loadDuration >= MAX_LOAD_DURATION_MS) {
|
||||||
|
console.warn(`[scroll-to-reply] Gave up scrolling to reply "${targetReplyCid}" after ${loadAttemptsRef.current} attempts / ${loadDuration}ms.`);
|
||||||
|
hasScrolledRef.current = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastLoadAtRef.current < LOAD_MORE_THROTTLE_MS) return;
|
if (now - lastLoadAtRef.current >= LOAD_MORE_THROTTLE_MS) {
|
||||||
|
lastLoadAtRef.current = now;
|
||||||
|
loadAttemptsRef.current += 1;
|
||||||
|
loadMore();
|
||||||
|
}
|
||||||
|
|
||||||
lastLoadAtRef.current = now;
|
return false;
|
||||||
loadAttemptsRef.current += 1;
|
};
|
||||||
|
|
||||||
if (loadMoreTimeoutRef.current) {
|
if (attemptScroll()) {
|
||||||
window.clearTimeout(loadMoreTimeoutRef.current);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
intervalRef.current = window.setInterval(() => {
|
||||||
|
if (attemptScroll() && intervalRef.current) {
|
||||||
|
window.clearInterval(intervalRef.current);
|
||||||
|
intervalRef.current = null;
|
||||||
}
|
}
|
||||||
loadMoreTimeoutRef.current = window.setTimeout(() => {
|
|
||||||
latestLoadMore();
|
|
||||||
}, LOAD_MORE_THROTTLE_MS);
|
|
||||||
}, AUTO_SCROLL_INTERVAL_MS);
|
}, AUTO_SCROLL_INTERVAL_MS);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -154,12 +116,8 @@ const useScrollToReply = ({ targetReplyCid, replies, hasMore, loadMore, virtuoso
|
|||||||
window.clearInterval(intervalRef.current);
|
window.clearInterval(intervalRef.current);
|
||||||
intervalRef.current = null;
|
intervalRef.current = null;
|
||||||
}
|
}
|
||||||
if (loadMoreTimeoutRef.current) {
|
|
||||||
window.clearTimeout(loadMoreTimeoutRef.current);
|
|
||||||
loadMoreTimeoutRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}, [enabled, targetReplyCid, virtuosoRef]);
|
}, [enabled, targetReplyCid, replies, hasMore, loadMore, virtuosoRef]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useScrollToReply;
|
export default useScrollToReply;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { copyToClipboard } from '../clipboard-utils';
|
import { copyToClipboard } from '../clipboard-utils';
|
||||||
import { hashStringToColor, getTextColorForBackground, removeMarkdown } from '../post-utils';
|
import { hashStringToColor, getTextColorForBackground, removeMarkdown } from '../post-utils';
|
||||||
import { preloadThemeAssets } from '../preload-utils';
|
import { preloadReplyModal, preloadThemeAssets } from '../preload-utils';
|
||||||
import { computeOmittedCount, filterRepliesForDisplay, getPreviewDisplayReplies, getTotalReplyCount } from '../replies-preview-utils';
|
import { computeOmittedCount, filterRepliesForDisplay, getPreviewDisplayReplies, getTotalReplyCount } from '../replies-preview-utils';
|
||||||
import { getQuotedCidsFromContent, mergeQuotedCids } from '../reply-quote-utils';
|
import { getQuotedCidsFromContent, mergeQuotedCids } from '../reply-quote-utils';
|
||||||
import { formatUserIDForDisplay, truncateWithEllipsisInMiddle } from '../string-utils';
|
import { formatUserIDForDisplay, truncateWithEllipsisInMiddle } from '../string-utils';
|
||||||
@@ -119,6 +119,38 @@ describe('misc utils', () => {
|
|||||||
expect(loadedSources).toEqual(['/buttons/default.png', '/buttons/hover.png', '/backgrounds/wallpaper.png']);
|
expect(loadedSources).toEqual(['/buttons/default.png', '/buttons/hover.png', '/backgrounds/wallpaper.png']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('schedules reply modal preload with requestIdleCallback when available', () => {
|
||||||
|
const requestIdleCallback = vi.fn();
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'requestIdleCallback', {
|
||||||
|
configurable: true,
|
||||||
|
value: requestIdleCallback,
|
||||||
|
});
|
||||||
|
|
||||||
|
preloadReplyModal();
|
||||||
|
|
||||||
|
expect(requestIdleCallback).toHaveBeenCalledWith(expect.any(Function), { timeout: 1500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to setTimeout for reply modal preload when requestIdleCallback is unavailable', () => {
|
||||||
|
const originalRequestIdleCallback = window.requestIdleCallback;
|
||||||
|
// @ts-expect-error test fallback path
|
||||||
|
delete window.requestIdleCallback;
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const setTimeoutSpy = vi.spyOn(window, 'setTimeout');
|
||||||
|
|
||||||
|
preloadReplyModal();
|
||||||
|
|
||||||
|
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 500);
|
||||||
|
|
||||||
|
if (originalRequestIdleCallback) {
|
||||||
|
Object.defineProperty(window, 'requestIdleCallback', {
|
||||||
|
configurable: true,
|
||||||
|
value: originalRequestIdleCallback,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('builds reply previews, omitted counts, and fallback reply totals', () => {
|
it('builds reply previews, omitted counts, and fallback reply totals', () => {
|
||||||
expect(filterRepliesForDisplay([{ cid: 'visible' }, { cid: 'deleted', deleted: true }, { cid: 'removed', deleted: false }])).toEqual([
|
expect(filterRepliesForDisplay([{ cid: 'visible' }, { cid: 'deleted', deleted: true }, { cid: 'removed', deleted: false }])).toEqual([
|
||||||
{ cid: 'visible' },
|
{ cid: 'visible' },
|
||||||
|
|||||||
@@ -19,3 +19,33 @@ export const preloadThemeAssets = (): void => {
|
|||||||
preloadImages(THEME_BUTTON_IMAGES);
|
preloadImages(THEME_BUTTON_IMAGES);
|
||||||
preloadImages(THEME_BACKGROUND_IMAGES);
|
preloadImages(THEME_BACKGROUND_IMAGES);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scheduleIdlePreload = (callback: () => void): void => {
|
||||||
|
if (typeof globalThis === 'undefined') {
|
||||||
|
callback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestIdle = (
|
||||||
|
globalThis as typeof globalThis & {
|
||||||
|
requestIdleCallback?: (callback: IdleRequestCallback, options?: IdleRequestOptions) => number;
|
||||||
|
}
|
||||||
|
).requestIdleCallback;
|
||||||
|
|
||||||
|
if (typeof requestIdle === 'function') {
|
||||||
|
requestIdle(() => callback(), { timeout: 1500 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.setTimeout(callback, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preloads the reply modal chunk during idle time so the first `No.` click
|
||||||
|
* doesn't have to wait for the lazy import to resolve.
|
||||||
|
*/
|
||||||
|
export const preloadReplyModal = (): void => {
|
||||||
|
scheduleIdlePreload(() => {
|
||||||
|
void import('../../components/reply-modal');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
const THREAD_SCROLL_SPACER_ID = 'thread-scroll-spacer';
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearThreadScrollSpacer = () => {
|
||||||
|
document.getElementById(THREAD_SCROLL_SPACER_ID)?.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const scrollThreadContainerToTop = (cid?: string) => {
|
||||||
|
if (!cid) return false;
|
||||||
|
|
||||||
|
const threadContainer = document.querySelector<HTMLElement>(`[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,
|
||||||
|
behavior: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
@@ -128,12 +128,24 @@ vi.mock('../../../components/footer', () => ({
|
|||||||
|
|
||||||
vi.mock('../../../components/post-desktop', () => ({
|
vi.mock('../../../components/post-desktop', () => ({
|
||||||
default: ({ post, roles, targetReplyCid }: { post?: TestComment; roles?: Record<string, unknown>; targetReplyCid?: string }) =>
|
default: ({ post, roles, targetReplyCid }: { post?: TestComment; roles?: Record<string, unknown>; targetReplyCid?: string }) =>
|
||||||
createElement('div', { 'data-testid': 'post-desktop' }, `${post?.cid || 'missing'}:${targetReplyCid || 'none'}:${Object.keys(roles || {}).length}`),
|
createElement(
|
||||||
|
'div',
|
||||||
|
{ 'data-testid': 'post-desktop' },
|
||||||
|
createElement('div', { 'data-thread-container-cid': post?.cid }),
|
||||||
|
createElement('div', { 'data-post-info-cid': post?.cid }),
|
||||||
|
`${post?.cid || 'missing'}:${targetReplyCid || 'none'}:${Object.keys(roles || {}).length}`,
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../components/post-mobile', () => ({
|
vi.mock('../../../components/post-mobile', () => ({
|
||||||
default: ({ post, roles, targetReplyCid }: { post?: TestComment; roles?: Record<string, unknown>; targetReplyCid?: string }) =>
|
default: ({ post, roles, targetReplyCid }: { post?: TestComment; roles?: Record<string, unknown>; targetReplyCid?: string }) =>
|
||||||
createElement('div', { 'data-testid': 'post-mobile' }, `${post?.cid || 'missing'}:${targetReplyCid || 'none'}:${Object.keys(roles || {}).length}`),
|
createElement(
|
||||||
|
'div',
|
||||||
|
{ 'data-testid': 'post-mobile' },
|
||||||
|
createElement('div', { 'data-thread-container-cid': post?.cid }),
|
||||||
|
createElement('div', { 'data-post-info-cid': post?.cid }),
|
||||||
|
`${post?.cid || 'missing'}:${targetReplyCid || 'none'}:${Object.keys(roles || {}).length}`,
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
@@ -190,6 +202,11 @@ describe('Post', () => {
|
|||||||
value: vi.fn(),
|
value: vi.fn(),
|
||||||
writable: true,
|
writable: true,
|
||||||
});
|
});
|
||||||
|
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn(),
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
document.title = 'before';
|
document.title = 'before';
|
||||||
|
|
||||||
container = document.createElement('div');
|
container = document.createElement('div');
|
||||||
@@ -244,7 +261,12 @@ 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-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(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(document.title).toBe('/mu/ - Cached thread... - 5chan');
|
||||||
expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
|
expect(window.scrollTo).toHaveBeenCalledWith({
|
||||||
|
behavior: 'auto',
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
});
|
||||||
|
expect(HTMLElement.prototype.scrollIntoView).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('redirects thread routes whose fetched comment belongs to a different board', async () => {
|
it('redirects thread routes whose fetched comment belongs to a different board', async () => {
|
||||||
|
|||||||
+13
-2
@@ -13,6 +13,7 @@ import ErrorDisplay from '../../components/error-display/error-display';
|
|||||||
import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFooterMobile } from '../../components/footer';
|
import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFooterMobile } from '../../components/footer';
|
||||||
import PostDesktop from '../../components/post-desktop';
|
import PostDesktop from '../../components/post-desktop';
|
||||||
import PostMobile from '../../components/post-mobile';
|
import PostMobile from '../../components/post-mobile';
|
||||||
|
import { clearThreadScrollSpacer, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
|
||||||
import styles from './post.module.css';
|
import styles from './post.module.css';
|
||||||
|
|
||||||
// useComment may not return cached feed data immediately due to its updatedAt comparison logic.
|
// useComment may not return cached feed data immediately due to its updatedAt comparison logic.
|
||||||
@@ -156,10 +157,20 @@ const PostPage = () => {
|
|||||||
|
|
||||||
const { error } = post || {};
|
const { error } = post || {};
|
||||||
|
|
||||||
|
useEffect(() => () => clearThreadScrollSpacer(), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!comment?.cid || comment.parentCid) return;
|
if (!commentCid || post?.cid === commentCid) return;
|
||||||
|
clearThreadScrollSpacer();
|
||||||
|
}, [commentCid, post?.cid]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!commentCid || post?.cid !== commentCid) return;
|
||||||
|
if (scrollThreadContainerToTop(commentCid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
}, [comment?.cid, comment?.parentCid]);
|
}, [commentCid, post?.cid]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const boardIdentifier = params.boardIdentifier;
|
const boardIdentifier = params.boardIdentifier;
|
||||||
|
|||||||
Reference in New Issue
Block a user