mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(thread-page): make thread auto updates opt-in (#1115)
* fix(thread-page): make thread auto updates opt-in Wire `Auto` and `Update` to the same manual refresh path and cover the thread flow with an e2e harness. * fix(thread-page): address PR review findings
This commit is contained in:
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { DesktopBoardButtons, MobileBoardButtons } from '../board-buttons';
|
||||
import useThreadLiveUpdatesStore from '../../../stores/use-thread-live-updates-store';
|
||||
|
||||
(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>;
|
||||
@@ -237,6 +238,7 @@ describe('BoardButtons', () => {
|
||||
testState.sortType = 'active';
|
||||
testState.subscribed = false;
|
||||
testState.viewMode = 'compact';
|
||||
useThreadLiveUpdatesStore.getState().resetState();
|
||||
Object.defineProperty(globalThis, 'alert', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
@@ -321,7 +323,7 @@ describe('BoardButtons', () => {
|
||||
expect(testState.resetMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders thread actions and post stats, then updates, auto-alerts, and scrolls to the bottom', async () => {
|
||||
it('renders thread actions and post stats, then requests refreshes, toggles auto updates, and scrolls to the bottom', async () => {
|
||||
testState.commentsByCid = {
|
||||
'comment-1': {
|
||||
cid: 'comment-1',
|
||||
@@ -345,11 +347,36 @@ describe('BoardButtons', () => {
|
||||
|
||||
await clickButton('bottom');
|
||||
await clickButton('update');
|
||||
await clickButton('Auto');
|
||||
const autoCheckbox = container.querySelector<HTMLInputElement>('input[type="checkbox"]');
|
||||
expect(autoCheckbox?.checked).toBe(false);
|
||||
await act(async () => {
|
||||
autoCheckbox?.click();
|
||||
});
|
||||
|
||||
expect(window.scrollTo).toHaveBeenCalledWith({ behavior: 'instant', top: 2400 });
|
||||
expect(testState.resetMock).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.alert).toHaveBeenCalledWith('posts_auto_update_info');
|
||||
expect(useThreadLiveUpdatesStore.getState().updateRequestId).toBe(1);
|
||||
expect(useThreadLiveUpdatesStore.getState().enabled).toBe(true);
|
||||
expect(autoCheckbox?.checked).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the update button enabled while a manual thread refresh is in progress', async () => {
|
||||
testState.commentsByCid = {
|
||||
'comment-1': {
|
||||
cid: 'comment-1',
|
||||
postCid: 'comment-1',
|
||||
replyCount: 9,
|
||||
},
|
||||
};
|
||||
useThreadLiveUpdatesStore.getState().startUpdate();
|
||||
|
||||
await renderWithRoute(createElement(DesktopBoardButtons), '/mu/thread/comment-1');
|
||||
|
||||
const updateButton = Array.from(container.querySelectorAll('button')).find((candidate) => candidate.textContent === 'update');
|
||||
expect(updateButton?.hasAttribute('disabled')).toBe(false);
|
||||
|
||||
await clickButton('update');
|
||||
|
||||
expect(useThreadLiveUpdatesStore.getState().updateRequestId).toBe(1);
|
||||
});
|
||||
|
||||
it('renders mobile mod-queue controls and clamps alert threshold updates', async () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import useSortingStore from '../../stores/use-sorting-store';
|
||||
import useAllFeedFilterStore from '../../stores/use-all-feed-filter-store';
|
||||
import useModQueueStore from '../../stores/use-mod-queue-store';
|
||||
import useFeedViewSettingsStore from '../../stores/use-feed-view-settings-store';
|
||||
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
||||
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
import CatalogFilters from '../catalog-filters';
|
||||
@@ -35,7 +36,6 @@ interface BoardButtonsProps {
|
||||
|
||||
export const CatalogButton = ({ address, isInAllView, isInSubscriptionsView, isInModView }: BoardButtonsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const directories = useDirectories();
|
||||
|
||||
const createCatalogLink = () => {
|
||||
@@ -158,9 +158,9 @@ export const RefreshButton = () => {
|
||||
|
||||
export const UpdateButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const reset = useFeedResetStore((state) => state.reset);
|
||||
const requestUpdate = useThreadLiveUpdatesStore((state) => state.requestUpdate);
|
||||
return (
|
||||
<button className='button' onClick={() => reset?.()}>
|
||||
<button className='button' onClick={() => requestUpdate()}>
|
||||
{t('update')}
|
||||
</button>
|
||||
);
|
||||
@@ -169,26 +169,19 @@ export const UpdateButton = () => {
|
||||
export const AutoButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
const handleAutoClick = () => {
|
||||
window.alert(t('posts_auto_update_info'));
|
||||
};
|
||||
|
||||
const autoUpdateEnabled = useThreadLiveUpdatesStore((state) => state.enabled);
|
||||
const setAutoUpdateEnabled = useThreadLiveUpdatesStore((state) => state.setEnabled);
|
||||
return (
|
||||
<>
|
||||
{isMobile ? (
|
||||
<button className='button' onClick={handleAutoClick}>
|
||||
<label>
|
||||
<input type='checkbox' className={styles.autoCheckbox} checked disabled />
|
||||
{t('Auto')}
|
||||
</label>
|
||||
</button>
|
||||
) : (
|
||||
<label onClick={handleAutoClick}>
|
||||
{' '}
|
||||
<input type='checkbox' className={styles.autoCheckbox} checked disabled /> {t('Auto')}
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
<label className={isMobile ? 'button' : undefined}>
|
||||
<input
|
||||
type='checkbox'
|
||||
aria-label={t('Auto')}
|
||||
className={styles.autoCheckbox}
|
||||
checked={autoUpdateEnabled}
|
||||
onChange={(event) => setAutoUpdateEnabled(event.target.checked)}
|
||||
/>{' '}
|
||||
{t('Auto')}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -495,14 +488,15 @@ export const PostPageStats = () => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const autoUpdateEnabled = useThreadLiveUpdatesStore((state) => state.enabled);
|
||||
const commentCid = params?.commentCid as string | undefined;
|
||||
const resolvedAddress = useResolvedCommunityAddress();
|
||||
const accountComment = useAccountComment({ commentIndex: params?.accountCommentIndex as any });
|
||||
const communityAddress = resolvedAddress || accountComment?.communityAddress;
|
||||
|
||||
const comment = useComment({ commentCid });
|
||||
const comment = useComment({ commentCid, autoUpdate: autoUpdateEnabled });
|
||||
const postCid = comment?.postCid ?? commentCid;
|
||||
const post = useComment({ commentCid: postCid });
|
||||
const post = useComment({ commentCid: postCid, autoUpdate: autoUpdateEnabled });
|
||||
|
||||
const archived = isCommentArchived(post);
|
||||
const { closed, pinned, replyCount } = post || {};
|
||||
|
||||
@@ -7,6 +7,7 @@ import StyleSelector from '../style-selector/style-selector';
|
||||
import { ReturnButton, CatalogButton, TopButton, UpdateButton, AutoButton, PostPageStats, RefreshButton } from '../board-buttons/board-buttons';
|
||||
import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils';
|
||||
import useReplyModalStore from '../../stores/use-reply-modal-store';
|
||||
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
||||
import useCountLinksInReplies from '../../hooks/use-count-links-in-replies';
|
||||
import { usePostPageNumber } from '../../hooks/use-post-page-number';
|
||||
import { useDirectoryByAddress } from '../../hooks/use-directories';
|
||||
@@ -205,12 +206,13 @@ export const ThreadFooterMobile = ({ postCid, threadNumber, communityAddress, is
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
const { openReplyModalEmpty } = useReplyModalStore();
|
||||
const autoUpdateEnabled = useThreadLiveUpdatesStore((state) => state.enabled);
|
||||
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
|
||||
const isInModView = isModView(location.pathname);
|
||||
|
||||
const post = useComment({ commentCid: postCid });
|
||||
const post = useComment({ commentCid: postCid, autoUpdate: autoUpdateEnabled });
|
||||
const { replyCount } = post || {};
|
||||
const linkCount = useCountLinksInReplies(post);
|
||||
const directoryEntry = useDirectoryByAddress(communityAddress);
|
||||
|
||||
@@ -42,6 +42,7 @@ import useReplyModalStore from '../../stores/use-reply-modal-store';
|
||||
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
|
||||
import useChallengesStore from '../../stores/use-challenges-store';
|
||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
||||
import useRegisterFreshReplies from '../../hooks/use-register-fresh-replies';
|
||||
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
|
||||
import { usePublishCommentModeration } from '@bitsocialnet/bitsocial-react-hooks';
|
||||
@@ -884,6 +885,8 @@ const PostDesktop = ({
|
||||
const freshRepliesForRender = useFreshReplies(repliesForRender);
|
||||
useRegisterFreshReplies(resolvedPost, freshRepliesForRender);
|
||||
const setResetFunction = useFeedResetStore((s) => s.setResetFunction);
|
||||
const repliesResetRequestId = useThreadLiveUpdatesStore((state) => state.repliesResetRequestId);
|
||||
const lastHandledRepliesResetRequestIdRef = useRef(repliesResetRequestId);
|
||||
useEffect(() => {
|
||||
if ((isInPostPageView || isInPendingPostView) && reset) {
|
||||
setResetFunction(() => {
|
||||
@@ -891,6 +894,11 @@ const PostDesktop = ({
|
||||
});
|
||||
}
|
||||
}, [isInPostPageView, isInPendingPostView, reset, setResetFunction]);
|
||||
useEffect(() => {
|
||||
if (!reset || repliesResetRequestId === 0 || repliesResetRequestId === lastHandledRepliesResetRequestIdRef.current) return;
|
||||
lastHandledRepliesResetRequestIdRef.current = repliesResetRequestId;
|
||||
void reset();
|
||||
}, [repliesResetRequestId, reset]);
|
||||
const visiblelinksCount = useCountLinksInReplies(resolvedPost, BOARD_REPLIES_PREVIEW_VISIBLE_COUNT);
|
||||
const totalLinksCount = useCountLinksInReplies(resolvedPost);
|
||||
const replyCount = freshRepliesForRender.length;
|
||||
|
||||
@@ -37,6 +37,7 @@ import useReplyModalStore from '../../stores/use-reply-modal-store';
|
||||
import { selectPostMenuProps } from '../../lib/utils/post-menu-props';
|
||||
import useChallengesStore from '../../stores/use-challenges-store';
|
||||
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
||||
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
||||
import useRegisterFreshReplies from '../../hooks/use-register-fresh-replies';
|
||||
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
|
||||
import useQuotedByMap from '../../hooks/use-quoted-by-map';
|
||||
@@ -608,6 +609,8 @@ const PostMobile = ({
|
||||
useRegisterFreshReplies(resolvedPost, freshRepliesForRender);
|
||||
const reset = (repliesResult as { reset?: () => Promise<void> }).reset;
|
||||
const setResetFunction = useFeedResetStore((s) => s.setResetFunction);
|
||||
const repliesResetRequestId = useThreadLiveUpdatesStore((state) => state.repliesResetRequestId);
|
||||
const lastHandledRepliesResetRequestIdRef = useRef(repliesResetRequestId);
|
||||
useEffect(() => {
|
||||
if ((isInPostView || isInPendingPostView) && reset) {
|
||||
setResetFunction(() => {
|
||||
@@ -615,6 +618,11 @@ const PostMobile = ({
|
||||
});
|
||||
}
|
||||
}, [isInPostView, isInPendingPostView, reset, setResetFunction]);
|
||||
useEffect(() => {
|
||||
if (!reset || repliesResetRequestId === 0 || repliesResetRequestId === lastHandledRepliesResetRequestIdRef.current) return;
|
||||
lastHandledRepliesResetRequestIdRef.current = repliesResetRequestId;
|
||||
void reset();
|
||||
}, [repliesResetRequestId, reset]);
|
||||
|
||||
const isInPostPageView = isPostPageView(location.pathname, params);
|
||||
const { hidden, unhide } = useHide({ cid });
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createInstance } from 'i18next';
|
||||
import { I18nextProvider, initReactI18next } from 'react-i18next';
|
||||
import { AutoButton, UpdateButton } from '../components/board-buttons/board-buttons';
|
||||
import useThreadLiveUpdatesStore from '../stores/use-thread-live-updates-store';
|
||||
|
||||
type ReplySnapshot = {
|
||||
cid: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type ThreadSnapshot = {
|
||||
postLabel: string;
|
||||
replies: ReplySnapshot[];
|
||||
version: number;
|
||||
};
|
||||
|
||||
const i18n = createInstance();
|
||||
void i18n.use(initReactI18next).init({
|
||||
fallbackLng: 'en',
|
||||
initImmediate: false,
|
||||
lng: 'en',
|
||||
resources: {
|
||||
en: {
|
||||
translation: {
|
||||
Auto: 'Auto',
|
||||
update: 'update',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const buildSnapshot = (version: number): ThreadSnapshot => ({
|
||||
postLabel: `OP version ${version}`,
|
||||
replies: Array.from({ length: version }, (_, index) => ({
|
||||
cid: `reply-${index + 1}`,
|
||||
content: index === 0 ? `reply 1 edited v${version}` : `reply ${index + 1} added in v${index + 1}`,
|
||||
})),
|
||||
version,
|
||||
});
|
||||
|
||||
const Harness = () => {
|
||||
const enabled = useThreadLiveUpdatesStore((state) => state.enabled);
|
||||
const isUpdating = useThreadLiveUpdatesStore((state) => state.isUpdating);
|
||||
const updateRequestId = useThreadLiveUpdatesStore((state) => state.updateRequestId);
|
||||
const repliesResetRequestId = useThreadLiveUpdatesStore((state) => state.repliesResetRequestId);
|
||||
const startUpdate = useThreadLiveUpdatesStore((state) => state.startUpdate);
|
||||
const finishUpdate = useThreadLiveUpdatesStore((state) => state.finishUpdate);
|
||||
const resetState = useThreadLiveUpdatesStore((state) => state.resetState);
|
||||
const initialSnapshot = buildSnapshot(1);
|
||||
const [serverSnapshot, setServerSnapshot] = useState(initialSnapshot);
|
||||
const [visiblePostLabel, setVisiblePostLabel] = useState(initialSnapshot.postLabel);
|
||||
const [visibleReplies, setVisibleReplies] = useState(initialSnapshot.replies);
|
||||
const lastProcessedUpdateRequestIdRef = useRef(0);
|
||||
const lastHandledRepliesResetIdRef = useRef(0);
|
||||
const pendingRepliesRef = useRef<ReplySnapshot[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
resetState();
|
||||
return () => {
|
||||
resetState();
|
||||
};
|
||||
}, [resetState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
setVisiblePostLabel(serverSnapshot.postLabel);
|
||||
setVisibleReplies(serverSnapshot.replies);
|
||||
}, [enabled, serverSnapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (updateRequestId === 0 || updateRequestId === lastProcessedUpdateRequestIdRef.current) return;
|
||||
|
||||
lastProcessedUpdateRequestIdRef.current = updateRequestId;
|
||||
const snapshotToApply = serverSnapshot;
|
||||
startUpdate();
|
||||
pendingRepliesRef.current = snapshotToApply.replies;
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setVisiblePostLabel(snapshotToApply.postLabel);
|
||||
finishUpdate(updateRequestId, true);
|
||||
}, 20);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timeoutId);
|
||||
};
|
||||
}, [finishUpdate, serverSnapshot, startUpdate, updateRequestId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (repliesResetRequestId === 0 || repliesResetRequestId === lastHandledRepliesResetIdRef.current || !pendingRepliesRef.current) return;
|
||||
lastHandledRepliesResetIdRef.current = repliesResetRequestId;
|
||||
setVisibleReplies(pendingRepliesRef.current);
|
||||
pendingRepliesRef.current = null;
|
||||
}, [repliesResetRequestId]);
|
||||
|
||||
return (
|
||||
<main style={{ fontFamily: 'sans-serif', lineHeight: 1.5, margin: '40px auto', maxWidth: 720, padding: '0 20px' }}>
|
||||
<h1>Thread Auto Update E2E</h1>
|
||||
<p>Use the real thread buttons below, then simulate incoming server updates.</p>
|
||||
<div style={{ alignItems: 'center', display: 'flex', gap: 16, marginBottom: 20 }}>
|
||||
<UpdateButton />
|
||||
<AutoButton />
|
||||
<button className='button' data-testid='simulate-server-update' onClick={() => setServerSnapshot((current) => buildSnapshot(current.version + 1))}>
|
||||
Simulate server update
|
||||
</button>
|
||||
</div>
|
||||
<div data-testid='updating-state'>{isUpdating ? 'updating' : 'idle'}</div>
|
||||
<div data-testid='server-version'>Server version {serverSnapshot.version}</div>
|
||||
<div data-testid='visible-post-label'>{visiblePostLabel}</div>
|
||||
<div data-testid='visible-replies-count'>{visibleReplies.length}</div>
|
||||
<div data-testid='visible-first-reply'>{visibleReplies[0]?.content ?? 'no replies'}</div>
|
||||
<ul data-testid='visible-replies-list'>
|
||||
{visibleReplies.map((reply) => (
|
||||
<li key={reply.cid}>{reply.content}</li>
|
||||
))}
|
||||
</ul>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
const ThreadAutoUpdateHarness = () => (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<Harness />
|
||||
</I18nextProvider>
|
||||
);
|
||||
|
||||
export default ThreadAutoUpdateHarness;
|
||||
+24
-9
@@ -16,21 +16,36 @@ import { Analytics } from '@vercel/analytics/react';
|
||||
const isVercelDeployment =
|
||||
typeof window !== 'undefined' && (window.location.hostname === '5chan.app' || window.location.hostname === 'www.5chan.app') && !window.isElectron;
|
||||
const e2eStartHash = import.meta.env.VITE_E2E_START_HASH?.trim();
|
||||
const shouldRenderThreadAutoUpdateHarness =
|
||||
import.meta.env.DEV && typeof window !== 'undefined' && new URLSearchParams(window.location.search).get('e2e') === 'thread-auto-update';
|
||||
|
||||
if (typeof window !== 'undefined' && e2eStartHash && window.location.hash.length === 0) {
|
||||
window.location.hash = e2eStartHash.startsWith('#') ? e2eStartHash : `#${e2eStartHash}`;
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<Router>
|
||||
<AppUpdateRegistration />
|
||||
<App />
|
||||
{isVercelDeployment && <Analytics />}
|
||||
</Router>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
const renderRoot = async () => {
|
||||
let threadAutoUpdateHarness: React.ComponentType | null = null;
|
||||
if (shouldRenderThreadAutoUpdateHarness) {
|
||||
threadAutoUpdateHarness = (await import('./e2e/thread-auto-update-harness')).default;
|
||||
}
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
{threadAutoUpdateHarness ? (
|
||||
React.createElement(threadAutoUpdateHarness)
|
||||
) : (
|
||||
<Router>
|
||||
<AppUpdateRegistration />
|
||||
<App />
|
||||
{isVercelDeployment && <Analytics />}
|
||||
</Router>
|
||||
)}
|
||||
</React.StrictMode>,
|
||||
);
|
||||
};
|
||||
|
||||
void renderRoot();
|
||||
|
||||
// add back button in android app
|
||||
CapacitorApp.addListener('backButton', ({ canGoBack }) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import usePostNumberStore from '../use-post-number-store';
|
||||
import useReplyModalStore from '../use-reply-modal-store';
|
||||
import useSelectedTextStore from '../use-selected-text-store';
|
||||
import useSortingStore from '../use-sorting-store';
|
||||
import useThreadLiveUpdatesStore from '../use-thread-live-updates-store';
|
||||
|
||||
const resetReplyModalStore = () => {
|
||||
useReplyModalStore.setState({
|
||||
@@ -41,6 +42,7 @@ describe('interaction stores', () => {
|
||||
usePostNumberStore.setState({ numberToCid: {}, cidToNumber: {} });
|
||||
useSelectedTextStore.getState().resetSelectedText();
|
||||
useSortingStore.getState().setSortType('active');
|
||||
useThreadLiveUpdatesStore.getState().resetState();
|
||||
resetReplyModalStore();
|
||||
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
@@ -86,6 +88,61 @@ describe('interaction stores', () => {
|
||||
expect(useSortingStore.getState().sortType).toBe('replyCount');
|
||||
});
|
||||
|
||||
it('tracks thread live update toggle state and queues manual refresh requests', () => {
|
||||
const store = useThreadLiveUpdatesStore.getState();
|
||||
|
||||
expect(store.enabled).toBe(false);
|
||||
expect(store.isUpdating).toBe(false);
|
||||
expect(store.updateRequestId).toBe(0);
|
||||
expect(store.repliesResetRequestId).toBe(0);
|
||||
|
||||
store.setEnabled(true);
|
||||
expect(useThreadLiveUpdatesStore.getState().enabled).toBe(true);
|
||||
|
||||
store.toggleEnabled();
|
||||
expect(useThreadLiveUpdatesStore.getState().enabled).toBe(false);
|
||||
|
||||
store.requestUpdate();
|
||||
expect(useThreadLiveUpdatesStore.getState().updateRequestId).toBe(1);
|
||||
|
||||
store.startUpdate();
|
||||
expect(useThreadLiveUpdatesStore.getState().isUpdating).toBe(true);
|
||||
|
||||
store.finishUpdate(1);
|
||||
expect(useThreadLiveUpdatesStore.getState()).toMatchObject({
|
||||
isUpdating: false,
|
||||
repliesResetRequestId: 1,
|
||||
});
|
||||
|
||||
store.requestUpdate();
|
||||
expect(useThreadLiveUpdatesStore.getState().updateRequestId).toBe(2);
|
||||
|
||||
store.startUpdate();
|
||||
store.finishUpdate(2, false);
|
||||
expect(useThreadLiveUpdatesStore.getState()).toMatchObject({
|
||||
isUpdating: false,
|
||||
repliesResetRequestId: 1,
|
||||
});
|
||||
|
||||
store.requestUpdate();
|
||||
store.startUpdate();
|
||||
store.requestUpdate();
|
||||
store.startUpdate();
|
||||
store.finishUpdate(3);
|
||||
expect(useThreadLiveUpdatesStore.getState()).toMatchObject({
|
||||
isUpdating: true,
|
||||
repliesResetRequestId: 3,
|
||||
updateRequestId: 4,
|
||||
});
|
||||
|
||||
store.finishUpdate(4);
|
||||
expect(useThreadLiveUpdatesStore.getState()).toMatchObject({
|
||||
isUpdating: false,
|
||||
repliesResetRequestId: 4,
|
||||
updateRequestId: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it('queues challenges, abandons the current one, and logs abandon failures', async () => {
|
||||
const abandonMock = vi.fn().mockResolvedValue(undefined);
|
||||
const failingAbandonMock = vi.fn().mockRejectedValue(new Error('stop failed'));
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface ThreadLiveUpdatesState {
|
||||
enabled: boolean;
|
||||
isUpdating: boolean;
|
||||
updateRequestId: number;
|
||||
repliesResetRequestId: number;
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
toggleEnabled: () => void;
|
||||
requestUpdate: () => void;
|
||||
startUpdate: () => void;
|
||||
finishUpdate: (requestId: number, shouldResetReplies?: boolean) => void;
|
||||
resetState: () => void;
|
||||
}
|
||||
|
||||
const defaultState = {
|
||||
enabled: false,
|
||||
isUpdating: false,
|
||||
updateRequestId: 0,
|
||||
repliesResetRequestId: 0,
|
||||
};
|
||||
|
||||
const useThreadLiveUpdatesStore = create<ThreadLiveUpdatesState>((set) => ({
|
||||
...defaultState,
|
||||
setEnabled: (enabled) => set({ enabled }),
|
||||
toggleEnabled: () => set((state) => ({ enabled: !state.enabled })),
|
||||
requestUpdate: () =>
|
||||
set((state) => ({
|
||||
updateRequestId: state.updateRequestId + 1,
|
||||
})),
|
||||
startUpdate: () => set({ isUpdating: true }),
|
||||
finishUpdate: (requestId, shouldResetReplies = true) =>
|
||||
set((state) => ({
|
||||
isUpdating: state.updateRequestId === requestId ? false : state.isUpdating,
|
||||
repliesResetRequestId: shouldResetReplies ? Math.max(state.repliesResetRequestId, requestId) : state.repliesResetRequestId,
|
||||
})),
|
||||
resetState: () => set(defaultState),
|
||||
}));
|
||||
|
||||
export default useThreadLiveUpdatesStore;
|
||||
@@ -4,6 +4,7 @@ import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import PostPage, { Post } from '../post';
|
||||
import useThreadLiveUpdatesStore from '../../../stores/use-thread-live-updates-store';
|
||||
|
||||
(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>;
|
||||
@@ -48,6 +49,7 @@ const testState = vi.hoisted(() => ({
|
||||
'0xmod': { role: 'admin' },
|
||||
},
|
||||
} as { roles?: Record<string, unknown> },
|
||||
useCommentCalls: [] as Array<{ commentCid?: string; autoUpdate?: boolean }>,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
@@ -65,7 +67,10 @@ vi.mock('react-router-dom', async () => {
|
||||
});
|
||||
|
||||
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
||||
useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.commentsByCid[commentCid] : undefined),
|
||||
useComment: ({ commentCid, autoUpdate }: { commentCid?: string; autoUpdate?: boolean }) => {
|
||||
testState.useCommentCalls.push({ commentCid, autoUpdate });
|
||||
return commentCid ? testState.commentsByCid[commentCid] : undefined;
|
||||
},
|
||||
useEditedComment: ({ comment }: { comment?: TestComment }) => ({
|
||||
editedComment: comment?.cid ? testState.editedCommentsByCid[comment.cid] : undefined,
|
||||
}),
|
||||
@@ -196,6 +201,8 @@ describe('Post', () => {
|
||||
testState.editedCommentsByCid = {};
|
||||
testState.isMobile = false;
|
||||
testState.resolvedCommunityAddress = 'music-posting.eth';
|
||||
testState.useCommentCalls = [];
|
||||
useThreadLiveUpdatesStore.getState().resetState();
|
||||
testState.community = {
|
||||
error: undefined,
|
||||
shortAddress: 'music-posting.eth',
|
||||
@@ -440,4 +447,34 @@ describe('Post', () => {
|
||||
expect(Array.from(container.querySelectorAll('[data-testid="error-display"]')).map((node) => node.textContent)).toEqual(['board failed', 'missing comment']);
|
||||
expect(container.querySelector('[data-testid="thread-footer-first-row"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('uses frozen useComment subscriptions when thread auto updates are disabled', async () => {
|
||||
testState.commentsByCid = {
|
||||
'reply-cid': {
|
||||
cid: 'reply-cid',
|
||||
communityAddress: 'music-posting.eth',
|
||||
parentCid: 'root-cid',
|
||||
postCid: 'root-cid',
|
||||
replyCount: 0,
|
||||
timestamp: 2,
|
||||
},
|
||||
'root-cid': {
|
||||
cid: 'root-cid',
|
||||
communityAddress: 'music-posting.eth',
|
||||
postCid: 'root-cid',
|
||||
replyCount: 4,
|
||||
timestamp: 1,
|
||||
},
|
||||
};
|
||||
useThreadLiveUpdatesStore.getState().setEnabled(false);
|
||||
|
||||
await renderPostPage('/mu/thread/reply-cid');
|
||||
|
||||
expect(testState.useCommentCalls).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ commentCid: 'reply-cid', autoUpdate: false }),
|
||||
expect.objectContaining({ commentCid: 'root-cid', autoUpdate: false }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+76
-10
@@ -16,18 +16,32 @@ import { PageFooterDesktop, ThreadFooterFirstRow, ThreadFooterStyleRow, ThreadFo
|
||||
import PostDesktop from '../../components/post-desktop';
|
||||
import PostMobile from '../../components/post-mobile';
|
||||
import { getRequestedThreadTopCid, scrollThreadContainerToTop } from '../../lib/utils/thread-scroll-utils';
|
||||
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
|
||||
import styles from './post.module.css';
|
||||
|
||||
type CommentWithRefresh = Comment & {
|
||||
refresh?: () => Promise<void>;
|
||||
state?: string;
|
||||
error?: Error;
|
||||
errors?: Error[];
|
||||
};
|
||||
|
||||
// useComment may not return cached feed data immediately due to its updatedAt comparison logic.
|
||||
// This hook falls back to the communities pages store (populated by useFeed) so content
|
||||
// from the catalog appears instantly instead of going through a loading phase.
|
||||
const useCommentWithFeedCache = (options: { commentCid: string | undefined }) => {
|
||||
const useCommentWithFeedCache = (options: { commentCid: string | undefined; autoUpdate?: boolean }): CommentWithRefresh | undefined => {
|
||||
const comment = useComment(options);
|
||||
const cachedComment = useCommunitiesPagesStore((state) => state.comments[options?.commentCid || '']);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!cachedComment || comment?.timestamp) return comment;
|
||||
return { ...cachedComment, state: comment?.state, error: comment?.error, errors: comment?.errors } as Comment;
|
||||
return {
|
||||
...cachedComment,
|
||||
refresh: comment?.refresh,
|
||||
state: comment?.state,
|
||||
error: comment?.error,
|
||||
errors: comment?.errors,
|
||||
} as CommentWithRefresh;
|
||||
}, [comment, cachedComment]);
|
||||
};
|
||||
|
||||
@@ -134,13 +148,20 @@ const PostPage = () => {
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const { commentCid } = params;
|
||||
const autoUpdateEnabled = useThreadLiveUpdatesStore((state) => state.enabled);
|
||||
const updateRequestId = useThreadLiveUpdatesStore((state) => state.updateRequestId);
|
||||
const startUpdate = useThreadLiveUpdatesStore((state) => state.startUpdate);
|
||||
const finishUpdate = useThreadLiveUpdatesStore((state) => state.finishUpdate);
|
||||
const resetThreadLiveUpdates = useThreadLiveUpdatesStore((state) => state.resetState);
|
||||
const resolvedCommunityAddress = useResolvedCommunityAddress();
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
|
||||
const comment = useCommentWithFeedCache({ commentCid });
|
||||
const comment = useCommentWithFeedCache({ commentCid, autoUpdate: autoUpdateEnabled });
|
||||
const commentCommunityAddress = getCommentCommunityAddress(comment);
|
||||
const communityAddress = resolvedCommunityAddress ?? commentCommunityAddress;
|
||||
const consumedThreadTopScrollRef = useRef<string | null>(null);
|
||||
const previousThreadCidRef = useRef<string>();
|
||||
const lastProcessedUpdateRequestIdRef = useRef(0);
|
||||
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
@@ -154,13 +175,8 @@ const PostPage = () => {
|
||||
const directories = useDirectories();
|
||||
|
||||
// if the comment is a reply, return the post comment instead, then the reply will be highlighted in the thread
|
||||
const postComment = useCommentWithFeedCache({ commentCid: comment?.postCid });
|
||||
let post: Comment;
|
||||
if (comment.parentCid) {
|
||||
post = postComment;
|
||||
} else {
|
||||
post = comment;
|
||||
}
|
||||
const postComment = useCommentWithFeedCache({ commentCid: comment?.postCid, autoUpdate: autoUpdateEnabled });
|
||||
const post = comment?.parentCid ? postComment : comment;
|
||||
const requestedThreadTopCid = getRequestedThreadTopCid(location.state);
|
||||
|
||||
const { error } = post || {};
|
||||
@@ -212,6 +228,56 @@ const PostPage = () => {
|
||||
|
||||
const targetReplyCid = comment?.parentCid ? comment?.cid : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
resetThreadLiveUpdates();
|
||||
};
|
||||
}, [resetThreadLiveUpdates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!post?.cid) return;
|
||||
if (previousThreadCidRef.current && previousThreadCidRef.current !== post.cid) {
|
||||
lastProcessedUpdateRequestIdRef.current = 0;
|
||||
consumedThreadTopScrollRef.current = null;
|
||||
resetThreadLiveUpdates();
|
||||
}
|
||||
previousThreadCidRef.current = post.cid;
|
||||
}, [post?.cid, resetThreadLiveUpdates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!post?.cid || updateRequestId <= lastProcessedUpdateRequestIdRef.current) return;
|
||||
|
||||
const refreshByCid = new Map<string, () => Promise<void>>();
|
||||
if (comment?.cid && typeof comment.refresh === 'function') {
|
||||
refreshByCid.set(comment.cid, comment.refresh);
|
||||
}
|
||||
if (post?.cid && typeof post.refresh === 'function') {
|
||||
refreshByCid.set(post.cid, post.refresh);
|
||||
}
|
||||
if (refreshByCid.size === 0) return;
|
||||
|
||||
lastProcessedUpdateRequestIdRef.current = updateRequestId;
|
||||
let cancelled = false;
|
||||
startUpdate();
|
||||
|
||||
void (async () => {
|
||||
const results = await Promise.allSettled(Array.from(refreshByCid.values(), (refresh) => refresh()));
|
||||
if (cancelled) return;
|
||||
|
||||
const hasSuccessfulRefresh = results.some((result) => result.status === 'fulfilled');
|
||||
finishUpdate(updateRequestId, hasSuccessfulRefresh);
|
||||
|
||||
const rejectedResult = results.find((result) => result.status === 'rejected');
|
||||
if (rejectedResult?.status === 'rejected') {
|
||||
console.error('Failed to refresh thread comments:', rejectedResult.reason);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [comment?.cid, comment?.refresh, finishUpdate, post?.cid, post?.refresh, startUpdate, updateRequestId]);
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
{shouldShowPostError && (
|
||||
|
||||
Reference in New Issue
Block a user