diff --git a/src/components/board-offline-alert/board-offline-alert.tsx b/src/components/board-offline-alert/board-offline-alert.tsx new file mode 100644 index 00000000..3f2480d8 --- /dev/null +++ b/src/components/board-offline-alert/board-offline-alert.tsx @@ -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) => { + const uniqueCandidates = new Set(); + + 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
{offlineTitle}
; +}; + +export default BoardOfflineAlert; diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index fad7fd64..f0272946 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -82,6 +82,7 @@ vi.mock('../../../hooks/use-account-subplebbit-addresses', () => ({ vi.mock('../../../hooks/use-directories', () => ({ useDirectories: () => testState.directories, 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', () => ({ diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index 4becdb24..1e3007dc 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -3,7 +3,6 @@ import { useTranslation } from 'react-i18next'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { Comment, setAccount, useAccount, useAccountComment, useEditedComment } from '@bitsocialhq/bitsocial-react-hooks'; 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 { getLinkMediaInfo } from '../../lib/utils/media-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 { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address'; 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 usePublishReply from '../../hooks/use-publish-reply'; import { useFileUpload } from '../../hooks/use-file-upload'; import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls'; 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 capitalize from 'lodash/capitalize'; 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
{offlineTitle}
; -}; - export const LinkTypePreviewer = ({ link }: { link: string }) => { const { t } = useTranslation(); const mediaInfo = getLinkMediaInfo(link); @@ -539,7 +525,7 @@ const PostForm = () => { if (isMobile) { return (
- {shouldShowOfflineAlert && } + {shouldShowOfflineAlert && } {isInModQueueView ? (
{t('moderation_queue')}
) : isThreadClosed ? ( @@ -563,7 +549,7 @@ const PostForm = () => { return (
- {shouldShowOfflineAlert && } + {shouldShowOfflineAlert && } {isInModQueueView ? (
{t('moderation_queue')}
) : isThreadClosed ? ( diff --git a/src/components/reply-modal/__tests__/reply-modal.test.tsx b/src/components/reply-modal/__tests__/reply-modal.test.tsx index 5a0df9e9..7b2aa4ba 100644 --- a/src/components/reply-modal/__tests__/reply-modal.test.tsx +++ b/src/components/reply-modal/__tests__/reply-modal.test.tsx @@ -21,6 +21,7 @@ const testState = vi.hoisted(() => ({ isMobile: false, isUploading: false, offlineTitle: '' as string | false, + offlineStates: {} as Record, offlineStatusLoading: false, offlineWarningVisible: false, openEmpty: false, @@ -30,6 +31,7 @@ const testState = vi.hoisted(() => ({ quoteInsertSelectedText: '', replyIndex: undefined as number | undefined, resetPublishReplyOptionsMock: vi.fn(), + resolvedSubplebbitAddress: undefined as string | undefined, selectedText: 'selected text', setAccountMock: 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', () => ({ - default: () => ({ - isOffline: testState.offlineWarningVisible, - isOnlineStatusLoading: testState.offlineStatusLoading, - offlineTitle: testState.offlineTitle, - }), + default: (subplebbit?: { address?: string }) => + (subplebbit?.address ? testState.offlineStates[subplebbit.address] : undefined) || { + isOffline: testState.offlineWarningVisible, + isOnlineStatusLoading: testState.offlineStatusLoading, + offlineTitle: testState.offlineTitle, + }, })); vi.mock('../../../stores/use-selected-text-store', () => ({ @@ -116,6 +119,11 @@ vi.mock('../../../stores/use-media-hosting-store', () => ({ vi.mock('../../../hooks/use-directories', () => ({ 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', () => ({ @@ -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 () => { root.render( createElement( @@ -198,7 +206,7 @@ const renderReplyModal = async (initialEntry = '/mu/thread/post-1') => { postCid: 'post-cid', scrollY: 120, showReplyModal: true, - subplebbitAddress: 'music-posting.eth', + subplebbitAddress, threadNumber: 42, }), ), @@ -243,6 +251,7 @@ describe('ReplyModal', () => { testState.isMobile = false; testState.isUploading = false; testState.offlineTitle = ''; + testState.offlineStates = {}; testState.offlineStatusLoading = false; testState.offlineWarningVisible = false; testState.openEmpty = false; @@ -252,6 +261,7 @@ describe('ReplyModal', () => { testState.quoteInsertSelectedText = ''; testState.replyIndex = undefined; testState.resetPublishReplyOptionsMock.mockReset(); + testState.resolvedSubplebbitAddress = undefined; testState.selectedText = 'selected text'; testState.setAccountMock.mockReset(); testState.setPublishReplyOptionsMock.mockReset(); @@ -302,6 +312,29 @@ describe('ReplyModal', () => { 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 () => { testState.openEmpty = true; testState.selectedText = ''; diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index 2ad976a6..4fcea831 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -2,18 +2,17 @@ import { useEffect, useRef, useState } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; 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 { 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 useReplyModalStore from '../../stores/use-reply-modal-store'; import { getShowUploadControls, isWebRuntime } from '../../lib/media-hosting/show-upload-controls'; 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 { useFileUpload } from '../../hooks/use-file-upload'; +import BoardOfflineAlert from '../board-offline-alert/board-offline-alert'; import styles from './reply-modal.module.css'; import capitalize from 'lodash/capitalize'; import debounce from 'lodash/debounce'; @@ -31,22 +30,12 @@ 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
{offlineTitle}
; -}; - const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threadNumber, postCid, scrollY, subplebbitAddress }: ReplyModalProps) => { const { t } = useTranslation(); const location = useLocation(); const params = useParams(); const isInAllView = isAllView(location.pathname); + const isInModView = isModView(location.pathname); const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); const directoryEntry = useDirectoryByAddress(subplebbitAddress); const showSpoilerForReply = directoryEntry?.features?.noSpoilerReplies !== true; @@ -376,7 +365,7 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
{lengthError ?
{lengthError}
: error &&
{error}
} -
);