fix(reply-modal): align offline warning with post form

This commit is contained in:
plebeius
2026-03-08 17:10:48 +08:00
parent c27590372c
commit 08ec9f31ff
2 changed files with 54 additions and 34 deletions
@@ -11,7 +11,6 @@ const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise
const testState = vi.hoisted(() => ({
account: { author: { displayName: 'Alice' } } as { author?: { displayName?: string } },
closeModalMock: vi.fn(),
currentTime: 10_000,
directoryByAddress: {
'music-posting.eth': {
address: 'music-posting.eth',
@@ -21,6 +20,9 @@ const testState = vi.hoisted(() => ({
handleUploadMock: vi.fn(),
isMobile: false,
isUploading: false,
offlineTitle: '' as string | false,
offlineStatusLoading: false,
offlineWarningVisible: false,
openEmpty: false,
publishReplyMock: vi.fn(),
quoteInsertNumber: undefined as number | undefined,
@@ -32,8 +34,12 @@ const testState = vi.hoisted(() => ({
setAccountMock: vi.fn(),
setPublishReplyOptionsMock: vi.fn(),
springStartMock: vi.fn(),
subplebbits: {
'music-posting.eth': {
address: 'music-posting.eth',
},
} as Record<string, { address: string }>,
showUploadControls: true,
updatedAt: undefined as number | undefined,
uploadComplete: undefined as ((url: string) => void) | undefined,
uploadedFileName: null as string | null,
uploadMode: 'always',
@@ -63,8 +69,19 @@ vi.mock('@bitsocialhq/bitsocial-react-hooks', () => ({
useAccount: () => testState.account,
}));
vi.mock('../../../hooks/use-stable-subplebbit', () => ({
useSubplebbitField: () => testState.updatedAt,
vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits', () => ({
default: <T,>(selector: (state: { subplebbits: typeof testState.subplebbits }) => T) =>
selector({
subplebbits: testState.subplebbits,
}),
}));
vi.mock('../../../hooks/use-is-subplebbit-offline', () => ({
default: () => ({
isOffline: testState.offlineWarningVisible,
isOnlineStatusLoading: testState.offlineStatusLoading,
offlineTitle: testState.offlineTitle,
}),
}));
vi.mock('../../../stores/use-selected-text-store', () => ({
@@ -114,10 +131,6 @@ vi.mock('../../../hooks/use-is-mobile', () => ({
default: () => testState.isMobile,
}));
vi.mock('../../../hooks/use-current-time', () => ({
useCurrentTime: () => testState.currentTime,
}));
vi.mock('../../../hooks/use-file-upload', () => ({
useFileUpload: ({ onUploadComplete }: { onUploadComplete: (url: string) => void }) => {
testState.uploadComplete = onUploadComplete;
@@ -160,10 +173,6 @@ vi.mock('@use-gesture/react', () => ({
useDrag: () => () => ({}),
}));
vi.mock('../../../lib/utils/time-utils', () => ({
getFormattedTimeAgo: (time: number) => `ago:${time}`,
}));
let container: HTMLDivElement;
let root: Root;
@@ -224,7 +233,6 @@ describe('ReplyModal', () => {
vi.clearAllMocks();
testState.account = { author: { displayName: 'Alice' } };
testState.closeModalMock.mockReset();
testState.currentTime = 10_000;
testState.directoryByAddress = {
'music-posting.eth': {
address: 'music-posting.eth',
@@ -234,6 +242,9 @@ describe('ReplyModal', () => {
testState.handleUploadMock.mockReset();
testState.isMobile = false;
testState.isUploading = false;
testState.offlineTitle = '';
testState.offlineStatusLoading = false;
testState.offlineWarningVisible = false;
testState.openEmpty = false;
testState.publishReplyMock.mockReset();
testState.quoteInsertNumber = undefined;
@@ -245,8 +256,12 @@ describe('ReplyModal', () => {
testState.setAccountMock.mockReset();
testState.setPublishReplyOptionsMock.mockReset();
testState.springStartMock.mockReset();
testState.subplebbits = {
'music-posting.eth': {
address: 'music-posting.eth',
},
};
testState.showUploadControls = true;
testState.updatedAt = undefined;
testState.uploadComplete = undefined;
testState.uploadedFileName = null;
testState.uploadMode = 'always';
@@ -260,8 +275,9 @@ describe('ReplyModal', () => {
container.remove();
});
it('initializes quoted content, display name, upload controls, and offline warning on board routes', async () => {
testState.updatedAt = 1_000;
it('initializes quoted content, display name, upload controls, and shared offline warning on board routes', async () => {
testState.offlineTitle = 'posts_last_synced_info:{"time":"ago:1000"}';
testState.offlineWarningVisible = true;
await renderReplyModal('/mu/thread/post-1');
@@ -274,12 +290,18 @@ describe('ReplyModal', () => {
expect(textarea?.value).toBe('>>42\nselected text');
expect(container.textContent).toContain('choose_file');
expect(container.textContent).toContain('Spoiler?');
expect(container.textContent).toContain('warning');
expect(container.textContent).toContain('posts_last_synced_info:{"time":"ago:1000"}');
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ content: '>>42\nselected text' });
expect(testState.setPublishReplyOptionsMock).toHaveBeenCalledWith({ displayName: 'Alice' });
});
it('does not render an offline warning when the shared offline hook reports the board as online', async () => {
await renderReplyModal('/mu/thread/post-1');
expect(container.querySelector('[class*="offlineBoard"]')).toBeNull();
expect(container.textContent).not.toContain('subplebbit_offline_info');
});
it('validates empty and invalid replies, then publishes once the payload is valid', async () => {
testState.openEmpty = true;
testState.selectedText = '';
+15 -17
View File
@@ -1,9 +1,8 @@
import { useEffect, useRef, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import { setAccount, useAccount } from '@bitsocialhq/bitsocial-react-hooks';
import { useSubplebbitField } from '../../hooks/use-stable-subplebbit';
import { getFormattedTimeAgo } from '../../lib/utils/time-utils';
import useSubplebbitsStore from '@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits';
import { isValidURL } from '../../lib/utils/url-utils';
import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils';
import useSelectedTextStore from '../../stores/use-selected-text-store';
@@ -12,8 +11,8 @@ import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/sho
import useMediaHostingStore from '../../stores/use-media-hosting-store';
import { useDirectoryByAddress } from '../../hooks/use-directories';
import usePublishReply from '../../hooks/use-publish-reply';
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
import useIsMobile from '../../hooks/use-is-mobile';
import { useCurrentTime } from '../../hooks/use-current-time';
import { useFileUpload } from '../../hooks/use-file-upload';
import styles from './reply-modal.module.css';
import capitalize from 'lodash/capitalize';
@@ -32,6 +31,17 @@ interface ReplyModalProps {
subplebbitAddress: string;
}
const ReplyModalOfflineAlert = ({ hidden, subplebbitAddress }: { hidden: boolean; subplebbitAddress: string }) => {
const subplebbit = useSubplebbitsStore((state) => state.subplebbits[subplebbitAddress]);
const { isOffline, isOnlineStatusLoading, offlineTitle } = useIsSubplebbitOffline(subplebbit);
if (hidden || (!isOffline && !isOnlineStatusLoading)) {
return null;
}
return <div className={styles.offlineBoard}>{offlineTitle}</div>;
};
const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threadNumber, postCid, scrollY, subplebbitAddress }: ReplyModalProps) => {
const { t } = useTranslation();
const location = useLocation();
@@ -152,18 +162,6 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
}
}, [parentCid]);
const currentTime = useCurrentTime();
// Only subscribe to updatedAt to avoid rerenders from updatingState changes
const updatedAt = useSubplebbitField(subplebbitAddress, (subplebbit) => subplebbit?.updatedAt);
const isBoardOffline = updatedAt && updatedAt < currentTime - 60 * 60;
const offlineAlert = updatedAt
? isBoardOffline && (
<div className={styles.offlineBoard}>
{t('warning')}: <Trans i18nKey='posts_last_synced_info' values={{ time: getFormattedTimeAgo(updatedAt) }} />
</div>
)
: t('subplebbit_offline_info');
useEffect(() => {
if (showReplyModal && !isMobile) {
setTimeout(() => {
@@ -378,7 +376,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
</button>
</div>
{lengthError ? <div className={styles.error}>{lengthError}</div> : error && <div className={styles.error}>{error}</div>}
{!(isInAllView || isInSubscriptionsView) && offlineAlert}
<ReplyModalOfflineAlert hidden={isInAllView || isInSubscriptionsView} subplebbitAddress={subplebbitAddress} />
</div>
</animated.div>
);