fix: share board offline state between post form and reply modal

This commit is contained in:
plebeius
2026-03-08 19:02:49 +08:00
parent 71b0b0fa42
commit 723a2dee52
5 changed files with 115 additions and 39 deletions
@@ -0,0 +1,67 @@
import { useMemo } from 'react';
import useSubplebbitsStore from '@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits';
import { normalizeBoardAddress, useDirectoryByAddress } from '../../hooks/use-directories';
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
const BOARD_ALIAS_SUFFIXES = ['.bso', '.eth'] as const;
const getBoardAddressCandidates = (addresses: Array<string | undefined>) => {
const uniqueCandidates = new Set<string>();
const addCandidate = (candidate: string | undefined) => {
if (candidate) {
uniqueCandidates.add(candidate);
}
};
addresses.forEach((address) => {
if (!address) {
return;
}
addCandidate(address);
const normalizedAddress = normalizeBoardAddress(address);
addCandidate(normalizedAddress);
BOARD_ALIAS_SUFFIXES.forEach((suffix) => addCandidate(`${normalizedAddress}${suffix}`));
});
return Array.from(uniqueCandidates);
};
interface BoardOfflineAlertProps {
className: string;
hidden?: boolean;
subplebbitAddress?: string;
}
const BoardOfflineAlert = ({ className, hidden = false, subplebbitAddress }: BoardOfflineAlertProps) => {
const resolvedSubplebbitAddress = useResolvedSubplebbitAddress();
const directoryEntry = useDirectoryByAddress(resolvedSubplebbitAddress || subplebbitAddress);
const addressCandidates = useMemo(
() => getBoardAddressCandidates([resolvedSubplebbitAddress, directoryEntry?.address, subplebbitAddress]),
[directoryEntry?.address, resolvedSubplebbitAddress, subplebbitAddress],
);
// Probe common aliases first so loading/offline state stays consistent across route and post payload address formats.
const subplebbit = useSubplebbitsStore((state) => {
for (const candidate of addressCandidates) {
const matchedSubplebbit = state.subplebbits[candidate];
if (matchedSubplebbit) {
return matchedSubplebbit;
}
}
return undefined;
});
const { isOffline, isOnlineStatusLoading, offlineTitle } = useIsSubplebbitOffline(subplebbit);
if (hidden || (!isOffline && !isOnlineStatusLoading)) {
return null;
}
return <div className={className}>{offlineTitle}</div>;
};
export default BoardOfflineAlert;
@@ -82,6 +82,7 @@ vi.mock('../../../hooks/use-account-subplebbit-addresses', () => ({
vi.mock('../../../hooks/use-directories', () => ({ vi.mock('../../../hooks/use-directories', () => ({
useDirectories: () => testState.directories, useDirectories: () => testState.directories,
useDirectoryByAddress: (address: string | undefined) => testState.directories.find((entry) => entry.address === address), useDirectoryByAddress: (address: string | undefined) => testState.directories.find((entry) => entry.address === address),
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
})); }));
vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({ vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
+3 -17
View File
@@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { Comment, setAccount, useAccount, useAccountComment, useEditedComment } from '@bitsocialhq/bitsocial-react-hooks'; import { Comment, setAccount, useAccount, useAccountComment, useEditedComment } from '@bitsocialhq/bitsocial-react-hooks';
import getShortAddress from '../../lib/get-short-address'; import getShortAddress from '../../lib/get-short-address';
import useSubplebbitsStore from '@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits';
import useSubplebbitsPagesStore from '@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits-pages'; import useSubplebbitsPagesStore from '@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits-pages';
import { getLinkMediaInfo } from '../../lib/utils/media-utils'; import { getLinkMediaInfo } from '../../lib/utils/media-utils';
import { isValidURL } from '../../lib/utils/url-utils'; import { isValidURL } from '../../lib/utils/url-utils';
@@ -13,29 +12,16 @@ import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directori
import useIsMobile from '../../hooks/use-is-mobile'; import useIsMobile from '../../hooks/use-is-mobile';
import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address'; import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address';
import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame'; import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame';
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
import usePublishPost from '../../hooks/use-publish-post'; import usePublishPost from '../../hooks/use-publish-post';
import usePublishReply from '../../hooks/use-publish-reply'; import usePublishReply from '../../hooks/use-publish-reply';
import { useFileUpload } from '../../hooks/use-file-upload'; import { useFileUpload } from '../../hooks/use-file-upload';
import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls'; import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls';
import useMediaHostingStore from '../../stores/use-media-hosting-store'; import useMediaHostingStore from '../../stores/use-media-hosting-store';
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
import styles from './post-form.module.css'; import styles from './post-form.module.css';
import capitalize from 'lodash/capitalize'; import capitalize from 'lodash/capitalize';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
// Separate component for offline alert to isolate rerenders from updatingState
// Only this component will rerender when updatingState changes, not the whole PostForm
const OfflineAlert = ({ subplebbitAddress }: { subplebbitAddress: string | undefined }) => {
const subplebbit = useSubplebbitsStore((state) => (subplebbitAddress ? state.subplebbits[subplebbitAddress] : undefined));
const { isOffline, isOnlineStatusLoading, offlineTitle } = useIsSubplebbitOffline(subplebbit);
if (!isOffline && !isOnlineStatusLoading) {
return null;
}
return <div className={styles.offlineBoard}>{offlineTitle}</div>;
};
export const LinkTypePreviewer = ({ link }: { link: string }) => { export const LinkTypePreviewer = ({ link }: { link: string }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const mediaInfo = getLinkMediaInfo(link); const mediaInfo = getLinkMediaInfo(link);
@@ -539,7 +525,7 @@ const PostForm = () => {
if (isMobile) { if (isMobile) {
return ( return (
<div className={styles.postFormMobile}> <div className={styles.postFormMobile}>
{shouldShowOfflineAlert && <OfflineAlert subplebbitAddress={subplebbitAddress} />} {shouldShowOfflineAlert && <BoardOfflineAlert className={styles.offlineBoard} subplebbitAddress={subplebbitAddress} />}
{isInModQueueView ? ( {isInModQueueView ? (
<div className={styles.modQueueTitle}>{t('moderation_queue')}</div> <div className={styles.modQueueTitle}>{t('moderation_queue')}</div>
) : isThreadClosed ? ( ) : isThreadClosed ? (
@@ -563,7 +549,7 @@ const PostForm = () => {
return ( return (
<div className={styles.postFormDesktop}> <div className={styles.postFormDesktop}>
{shouldShowOfflineAlert && <OfflineAlert subplebbitAddress={subplebbitAddress} />} {shouldShowOfflineAlert && <BoardOfflineAlert className={styles.offlineBoard} subplebbitAddress={subplebbitAddress} />}
{isInModQueueView ? ( {isInModQueueView ? (
<div className={styles.modQueueTitle}>{t('moderation_queue')}</div> <div className={styles.modQueueTitle}>{t('moderation_queue')}</div>
) : isThreadClosed ? ( ) : isThreadClosed ? (
@@ -21,6 +21,7 @@ const testState = vi.hoisted(() => ({
isMobile: false, isMobile: false,
isUploading: false, isUploading: false,
offlineTitle: '' as string | false, offlineTitle: '' as string | false,
offlineStates: {} as Record<string, { isOffline: boolean; isOnlineStatusLoading: boolean; offlineTitle: string | false }>,
offlineStatusLoading: false, offlineStatusLoading: false,
offlineWarningVisible: false, offlineWarningVisible: false,
openEmpty: false, openEmpty: false,
@@ -30,6 +31,7 @@ const testState = vi.hoisted(() => ({
quoteInsertSelectedText: '', quoteInsertSelectedText: '',
replyIndex: undefined as number | undefined, replyIndex: undefined as number | undefined,
resetPublishReplyOptionsMock: vi.fn(), resetPublishReplyOptionsMock: vi.fn(),
resolvedSubplebbitAddress: undefined as string | undefined,
selectedText: 'selected text', selectedText: 'selected text',
setAccountMock: vi.fn(), setAccountMock: vi.fn(),
setPublishReplyOptionsMock: vi.fn(), setPublishReplyOptionsMock: vi.fn(),
@@ -77,11 +79,12 @@ vi.mock('@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits', () => ({
})); }));
vi.mock('../../../hooks/use-is-subplebbit-offline', () => ({ vi.mock('../../../hooks/use-is-subplebbit-offline', () => ({
default: () => ({ default: (subplebbit?: { address?: string }) =>
isOffline: testState.offlineWarningVisible, (subplebbit?.address ? testState.offlineStates[subplebbit.address] : undefined) || {
isOnlineStatusLoading: testState.offlineStatusLoading, isOffline: testState.offlineWarningVisible,
offlineTitle: testState.offlineTitle, isOnlineStatusLoading: testState.offlineStatusLoading,
}), offlineTitle: testState.offlineTitle,
},
})); }));
vi.mock('../../../stores/use-selected-text-store', () => ({ vi.mock('../../../stores/use-selected-text-store', () => ({
@@ -116,6 +119,11 @@ vi.mock('../../../stores/use-media-hosting-store', () => ({
vi.mock('../../../hooks/use-directories', () => ({ vi.mock('../../../hooks/use-directories', () => ({
useDirectoryByAddress: (address: string) => testState.directoryByAddress[address], useDirectoryByAddress: (address: string) => testState.directoryByAddress[address],
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
}));
vi.mock('../../../hooks/use-resolved-subplebbit-address', () => ({
useResolvedSubplebbitAddress: () => testState.resolvedSubplebbitAddress,
})); }));
vi.mock('../../../hooks/use-publish-reply', () => ({ vi.mock('../../../hooks/use-publish-reply', () => ({
@@ -185,7 +193,7 @@ const flushEffects = async (count = 4) => {
} }
}; };
const renderReplyModal = async (initialEntry = '/mu/thread/post-1') => { const renderReplyModal = async (initialEntry = '/mu/thread/post-1', subplebbitAddress = 'music-posting.eth') => {
await act(async () => { await act(async () => {
root.render( root.render(
createElement( createElement(
@@ -198,7 +206,7 @@ const renderReplyModal = async (initialEntry = '/mu/thread/post-1') => {
postCid: 'post-cid', postCid: 'post-cid',
scrollY: 120, scrollY: 120,
showReplyModal: true, showReplyModal: true,
subplebbitAddress: 'music-posting.eth', subplebbitAddress,
threadNumber: 42, threadNumber: 42,
}), }),
), ),
@@ -243,6 +251,7 @@ describe('ReplyModal', () => {
testState.isMobile = false; testState.isMobile = false;
testState.isUploading = false; testState.isUploading = false;
testState.offlineTitle = ''; testState.offlineTitle = '';
testState.offlineStates = {};
testState.offlineStatusLoading = false; testState.offlineStatusLoading = false;
testState.offlineWarningVisible = false; testState.offlineWarningVisible = false;
testState.openEmpty = false; testState.openEmpty = false;
@@ -252,6 +261,7 @@ describe('ReplyModal', () => {
testState.quoteInsertSelectedText = ''; testState.quoteInsertSelectedText = '';
testState.replyIndex = undefined; testState.replyIndex = undefined;
testState.resetPublishReplyOptionsMock.mockReset(); testState.resetPublishReplyOptionsMock.mockReset();
testState.resolvedSubplebbitAddress = undefined;
testState.selectedText = 'selected text'; testState.selectedText = 'selected text';
testState.setAccountMock.mockReset(); testState.setAccountMock.mockReset();
testState.setPublishReplyOptionsMock.mockReset(); testState.setPublishReplyOptionsMock.mockReset();
@@ -302,6 +312,29 @@ describe('ReplyModal', () => {
expect(container.textContent).not.toContain('subplebbit_offline_info'); expect(container.textContent).not.toContain('subplebbit_offline_info');
}); });
it('prefers the resolved board entry when the modal prop address uses a different alias', async () => {
testState.offlineTitle = 'subplebbit_offline_info';
testState.offlineWarningVisible = true;
testState.resolvedSubplebbitAddress = 'music-posting.eth';
testState.subplebbits = {
'music-posting.eth': {
address: 'music-posting.eth',
},
};
testState.offlineStates = {
'music-posting.eth': {
isOffline: false,
isOnlineStatusLoading: false,
offlineTitle: '',
},
};
await renderReplyModal('/mu/thread/post-1', 'music-posting.bso');
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 () => { it('validates empty and invalid replies, then publishes once the payload is valid', async () => {
testState.openEmpty = true; testState.openEmpty = true;
testState.selectedText = ''; testState.selectedText = '';
+4 -15
View File
@@ -2,18 +2,17 @@ import { useEffect, useRef, useState } from 'react';
import { useLocation, useParams } from 'react-router-dom'; import { useLocation, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { setAccount, useAccount } from '@bitsocialhq/bitsocial-react-hooks'; import { setAccount, useAccount } from '@bitsocialhq/bitsocial-react-hooks';
import useSubplebbitsStore from '@bitsocialhq/bitsocial-react-hooks/dist/stores/subplebbits';
import { isValidURL } from '../../lib/utils/url-utils'; import { isValidURL } from '../../lib/utils/url-utils';
import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils'; import { isAllView, isModView, isSubscriptionsView } from '../../lib/utils/view-utils';
import useSelectedTextStore from '../../stores/use-selected-text-store'; import useSelectedTextStore from '../../stores/use-selected-text-store';
import useReplyModalStore from '../../stores/use-reply-modal-store'; import useReplyModalStore from '../../stores/use-reply-modal-store';
import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls'; import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls';
import useMediaHostingStore from '../../stores/use-media-hosting-store'; import useMediaHostingStore from '../../stores/use-media-hosting-store';
import { useDirectoryByAddress } from '../../hooks/use-directories'; import { useDirectoryByAddress } from '../../hooks/use-directories';
import usePublishReply from '../../hooks/use-publish-reply'; import usePublishReply from '../../hooks/use-publish-reply';
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
import useIsMobile from '../../hooks/use-is-mobile'; import useIsMobile from '../../hooks/use-is-mobile';
import { useFileUpload } from '../../hooks/use-file-upload'; import { useFileUpload } from '../../hooks/use-file-upload';
import BoardOfflineAlert from '../board-offline-alert/board-offline-alert';
import styles from './reply-modal.module.css'; import styles from './reply-modal.module.css';
import capitalize from 'lodash/capitalize'; import capitalize from 'lodash/capitalize';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
@@ -31,22 +30,12 @@ interface ReplyModalProps {
subplebbitAddress: string; 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 ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threadNumber, postCid, scrollY, subplebbitAddress }: ReplyModalProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const params = useParams(); const params = useParams();
const isInAllView = isAllView(location.pathname); const isInAllView = isAllView(location.pathname);
const isInModView = isModView(location.pathname);
const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); const isInSubscriptionsView = isSubscriptionsView(location.pathname, params);
const directoryEntry = useDirectoryByAddress(subplebbitAddress); const directoryEntry = useDirectoryByAddress(subplebbitAddress);
const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true; const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true;
@@ -376,7 +365,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
</button> </button>
</div> </div>
{lengthError ? <div className={styles.error}>{lengthError}</div> : error && <div className={styles.error}>{error}</div>} {lengthError ? <div className={styles.error}>{lengthError}</div> : error && <div className={styles.error}>{error}</div>}
<ReplyModalOfflineAlert hidden={isInAllView || isInSubscriptionsView} subplebbitAddress={subplebbitAddress} /> <BoardOfflineAlert className={styles.offlineBoard} hidden={isInAllView || isInSubscriptionsView || isInModView} subplebbitAddress={subplebbitAddress} />
</div> </div>
</animated.div> </animated.div>
); );