diff --git a/src/components/board-header/__tests__/board-header.test.tsx b/src/components/board-header/__tests__/board-header.test.tsx index 907805bf..45424e7a 100644 --- a/src/components/board-header/__tests__/board-header.test.tsx +++ b/src/components/board-header/__tests__/board-header.test.tsx @@ -10,6 +10,8 @@ const act = (React as { act?: (cb: () => void | Promise) => void | Promise const testState = vi.hoisted(() => ({ accountComment: undefined as { communityAddress?: string } | undefined, + community: { address: 'music-posting.eth' } as { address?: string; name?: string; publicKey?: string } | undefined, + communityIdentifier: { name: 'music-posting.eth' } as { name?: string; publicKey?: string } | undefined, directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>, directoriesMetadata: { title: '/all/ - Directories' } as { title?: string } | undefined, isMobile: false, @@ -52,6 +54,7 @@ vi.mock('react-router-dom', async () => { vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ useAccount: () => undefined, useAccountComment: () => testState.accountComment, + useCommunity: () => testState.community, })); vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/accounts', () => ({ @@ -82,6 +85,10 @@ vi.mock('../../../hooks/use-directories', () => ({ useDirectoriesMetadata: () => testState.directoriesMetadata, })); +vi.mock('../../../hooks/use-community-identifiers', () => ({ + useCommunityIdentifier: () => testState.communityIdentifier, +})); + vi.mock('../../../hooks/use-resolved-community-address', () => ({ useResolvedCommunityAddress: () => testState.resolvedAddress, })); @@ -122,6 +129,8 @@ describe('BoardHeader', () => { beforeEach(() => { vi.clearAllMocks(); testState.accountComment = undefined; + testState.community = { address: 'music-posting.eth' }; + testState.communityIdentifier = { name: 'music-posting.eth' }; testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }]; testState.directoriesMetadata = { title: '/all/ - Directories' }; testState.isMobile = false; diff --git a/src/components/board-header/board-header.tsx b/src/components/board-header/board-header.tsx index eff6a38c..900fe1c0 100644 --- a/src/components/board-header/board-header.tsx +++ b/src/components/board-header/board-header.tsx @@ -1,9 +1,10 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useLocation, useParams, useNavigate } from 'react-router-dom'; +import { useCommunity } from '@bitsocialnet/bitsocial-react-hooks'; import useAccountsStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/accounts'; -import useCommunitiesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities'; import getShortAddress from '../../lib/get-short-address'; +import { useCommunityIdentifier } from '../../hooks/use-community-identifiers'; import { useStableCommunity } from '../../hooks/use-stable-community'; import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils'; import { isArchiveRoute } from '../../lib/utils/route-utils'; @@ -27,9 +28,9 @@ const ImageBanner = () => { // Separate component for offline indicator to isolate rerenders from updatingState // Only this component will rerender when updatingState changes, not the whole BoardHeader const OfflineIndicator = ({ communityAddress }: { communityAddress: string | undefined }) => { - // Subscribe to full community including transient state for offline detection - const community = useCommunitiesStore((state) => (communityAddress ? state.communities[communityAddress] : undefined)); - const { isOffline, isOnlineStatusLoading, offlineIconClass, offlineTitle } = useIsCommunityOffline(community); + const communityIdentifier = useCommunityIdentifier(communityAddress); + const community = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined); + const { isOffline, isOnlineStatusLoading, offlineIconClass, offlineTitle } = useIsCommunityOffline(community, communityAddress); if (!isOffline && !isOnlineStatusLoading) { return null; diff --git a/src/components/board-offline-alert/board-offline-alert.tsx b/src/components/board-offline-alert/board-offline-alert.tsx index d8e489ad..44c5dafd 100644 --- a/src/components/board-offline-alert/board-offline-alert.tsx +++ b/src/components/board-offline-alert/board-offline-alert.tsx @@ -1,35 +1,9 @@ -import { useMemo } from 'react'; -import useCommunitiesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities'; -import { normalizeBoardAddress, useDirectoryByAddress } from '../../hooks/use-directories'; +import { useCommunity } from '@bitsocialnet/bitsocial-react-hooks'; +import { useCommunityIdentifier } from '../../hooks/use-community-identifiers'; +import { useDirectoryByAddress } from '../../hooks/use-directories'; import useIsCommunityOffline from '../../hooks/use-is-community-offline'; import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-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; @@ -39,23 +13,10 @@ interface BoardOfflineAlertProps { const BoardOfflineAlert = ({ className, hidden = false, communityAddress }: BoardOfflineAlertProps) => { const resolvedCommunityAddress = useResolvedCommunityAddress(); const directoryEntry = useDirectoryByAddress(resolvedCommunityAddress || communityAddress); - const addressCandidates = useMemo( - () => getBoardAddressCandidates([resolvedCommunityAddress, directoryEntry?.address, communityAddress]), - [directoryEntry?.address, resolvedCommunityAddress, communityAddress], - ); - - // Probe common aliases first so loading/offline state stays consistent across route and post payload address formats. - const community = useCommunitiesStore((state) => { - for (const candidate of addressCandidates) { - const matchedCommunity = state.communities[candidate]; - if (matchedCommunity) { - return matchedCommunity; - } - } - - return undefined; - }); - const { isOffline, isOnlineStatusLoading, offlineTitle } = useIsCommunityOffline(community); + const canonicalCommunityAddress = resolvedCommunityAddress || directoryEntry?.address || communityAddress; + const communityIdentifier = useCommunityIdentifier(canonicalCommunityAddress); + const community = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined); + const { isOffline, isOnlineStatusLoading, offlineTitle } = useIsCommunityOffline(community, canonicalCommunityAddress); if (hidden || (!isOffline && !isOnlineStatusLoading)) { return null; diff --git a/src/components/post-form/__tests__/post-form.test.tsx b/src/components/post-form/__tests__/post-form.test.tsx index 2525ffd8..dfb1ed23 100644 --- a/src/components/post-form/__tests__/post-form.test.tsx +++ b/src/components/post-form/__tests__/post-form.test.tsx @@ -67,6 +67,10 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ setAccount: (account: unknown) => testState.setAccountMock(account), useAccount: () => testState.account, useAccountComment: () => testState.accountComment, + useCommunity: (options?: { community?: { name?: string; publicKey?: string } }) => { + const communityKey = options?.community?.name ?? options?.community?.publicKey; + return communityKey ? testState.communities[communityKey] : undefined; + }, useEditedComment: () => ({ editedComment: testState.editedComment }), })); @@ -88,6 +92,10 @@ vi.mock('../../../hooks/use-directories', () => ({ normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''), })); +vi.mock('../../../hooks/use-community-identifiers', () => ({ + useCommunityIdentifier: (address?: string) => (address ? { name: address } : undefined), +})); + vi.mock('../../../hooks/use-resolved-community-address', () => ({ useResolvedCommunityAddress: () => testState.resolvedCommunityAddress, })); diff --git a/src/components/reply-modal/__tests__/reply-modal.test.tsx b/src/components/reply-modal/__tests__/reply-modal.test.tsx index 7f3f514b..338ca4d9 100644 --- a/src/components/reply-modal/__tests__/reply-modal.test.tsx +++ b/src/components/reply-modal/__tests__/reply-modal.test.tsx @@ -73,6 +73,10 @@ vi.mock('react-i18next', () => ({ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({ setAccount: (account: unknown) => testState.setAccountMock(account), useAccount: () => testState.account, + useCommunity: (options?: { community?: { name?: string; publicKey?: string } }) => { + const communityKey = options?.community?.name ?? options?.community?.publicKey; + return communityKey ? testState.communities[communityKey] : undefined; + }, })); vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/communities', () => ({ @@ -126,6 +130,10 @@ vi.mock('../../../hooks/use-directories', () => ({ normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''), })); +vi.mock('../../../hooks/use-community-identifiers', () => ({ + useCommunityIdentifier: (address?: string) => (address ? { name: address } : undefined), +})); + vi.mock('../../../hooks/use-resolved-community-address', () => ({ useResolvedCommunityAddress: () => testState.resolvedCommunityAddress, })); diff --git a/src/hooks/__tests__/use-is-community-offline.test.tsx b/src/hooks/__tests__/use-is-community-offline.test.tsx index ea9fc908..e4f3b1e9 100644 --- a/src/hooks/__tests__/use-is-community-offline.test.tsx +++ b/src/hooks/__tests__/use-is-community-offline.test.tsx @@ -49,8 +49,14 @@ let latestValue: ReturnType; let container: HTMLDivElement; let root: Root; -const HookHarness = ({ community }: { community?: { address?: string; state?: string; updatedAt?: number; updatingState?: string } }) => { - latestValue = useIsCommunityOffline(community as never); +const HookHarness = ({ + community, + communityAddressHint, +}: { + community?: { address?: string; name?: string; publicKey?: string; state?: string; updatedAt?: number; updatingState?: string }; + communityAddressHint?: string; +}) => { + latestValue = useIsCommunityOffline(community as never, communityAddressHint); return null; }; @@ -62,9 +68,12 @@ const flushEffects = async (count = 3) => { } }; -const renderHook = async (community?: { address?: string; state?: string; updatedAt?: number; updatingState?: string }) => { +const renderHook = async ( + community?: { address?: string; name?: string; publicKey?: string; state?: string; updatedAt?: number; updatingState?: string }, + communityAddressHint?: string, +) => { await act(async () => { - root.render(createElement(HookHarness, { community })); + root.render(createElement(HookHarness, { community, communityAddressHint })); }); await flushEffects(); }; @@ -153,6 +162,24 @@ describe('useIsCommunityOffline', () => { }); }); + it('tracks strict-community objects with a canonical address hint instead of the undefined key', async () => { + await renderHook({ name: 'music.eth', publicKey: '12D3KooWBoardKey', state: 'updating', updatingState: 'fetching' }, 'music.eth'); + + expect(testState.requestedAddresses).toEqual(['music.eth']); + expect(testState.initializeMock).toHaveBeenCalledWith('music.eth'); + expect(testState.setOfflineStateMock).toHaveBeenCalledWith('music.eth', { + state: 'updating', + updatedAt: undefined, + updatingState: 'fetching', + }); + expect(latestValue).toEqual({ + isOffline: false, + isOnlineStatusLoading: true, + offlineIconClass: 'yellowOfflineIcon', + offlineTitle: 'downloading board...', + }); + }); + it('treats recently updated boards as online', async () => { const freshUpdatedAt = 1_704_067_205; testState.communityOfflineState = { diff --git a/src/hooks/use-is-community-offline.ts b/src/hooks/use-is-community-offline.ts index e3fc5cd8..b91c0f44 100644 --- a/src/hooks/use-is-community-offline.ts +++ b/src/hooks/use-is-community-offline.ts @@ -5,25 +5,33 @@ import { getFormattedTimeAgo } from '../lib/utils/time-utils'; import useCommunityOfflineStore from '../stores/use-community-offline-store'; import useCommunitiesLoadingStartTimestamps from '../stores/use-communities-loading-start-timestamps-store'; -const useIsCommunityOffline = (community?: Community | undefined) => { +const getCommunityOfflineKey = (community?: Community, communityAddressHint?: string) => + communityAddressHint || community?.address || community?.name || community?.publicKey; + +const useIsCommunityOffline = (community?: Community | undefined, communityAddressHint?: string) => { const { t } = useTranslation(); - const { address, state, updatedAt, updatingState } = community || {}; + const { state, updatedAt, updatingState } = community || {}; + const communityKey = getCommunityOfflineKey(community, communityAddressHint); const { communityOfflineState, setCommunityOfflineState, initializeCommunityOfflineState } = useCommunityOfflineStore(); - const communitiesLoadingStartTimestamps = useCommunitiesLoadingStartTimestamps([address]); + const communitiesLoadingStartTimestamps = useCommunitiesLoadingStartTimestamps(communityKey ? [communityKey] : undefined); useEffect(() => { - if (address && !communityOfflineState[address]) { - initializeCommunityOfflineState(address); + if (communityKey && !communityOfflineState[communityKey]) { + initializeCommunityOfflineState(communityKey); } - }, [address, communityOfflineState, initializeCommunityOfflineState]); + }, [communityKey, communityOfflineState, initializeCommunityOfflineState]); useEffect(() => { - if (address) { - setCommunityOfflineState(address, { state, updatedAt, updatingState }); + if (communityKey) { + setCommunityOfflineState(communityKey, { state, updatedAt, updatingState }); } - }, [address, state, updatedAt, updatingState, setCommunityOfflineState]); + }, [communityKey, state, updatedAt, updatingState, setCommunityOfflineState]); - const offlineState = communityOfflineState[address] || { initialLoad: true }; + if (!communityKey) { + return { isOffline: false, isOnlineStatusLoading: false, offlineIconClass: '', offlineTitle: false }; + } + + const offlineState = communityOfflineState[communityKey] || { initialLoad: true }; const loadingStartTimestamp = communitiesLoadingStartTimestamps[0] || 0; const isLoading = offlineState.initialLoad && (!updatedAt || Date.now() / 1000 - updatedAt >= 120 * 120) && Date.now() / 1000 - loadingStartTimestamp < 30; const isOffline = !isLoading && ((updatedAt && updatedAt < Date.now() / 1000 - 120 * 120) || (!updatedAt && Date.now() / 1000 - loadingStartTimestamp >= 30));