mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(board status): resolve offline indicators with strict community refs
This commit is contained in:
@@ -10,6 +10,8 @@ const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise
|
|||||||
|
|
||||||
const testState = vi.hoisted(() => ({
|
const testState = vi.hoisted(() => ({
|
||||||
accountComment: undefined as { communityAddress?: string } | undefined,
|
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 }>,
|
directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>,
|
||||||
directoriesMetadata: { title: '/all/ - Directories' } as { title?: string } | undefined,
|
directoriesMetadata: { title: '/all/ - Directories' } as { title?: string } | undefined,
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
@@ -52,6 +54,7 @@ vi.mock('react-router-dom', async () => {
|
|||||||
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
||||||
useAccount: () => undefined,
|
useAccount: () => undefined,
|
||||||
useAccountComment: () => testState.accountComment,
|
useAccountComment: () => testState.accountComment,
|
||||||
|
useCommunity: () => testState.community,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/accounts', () => ({
|
vi.mock('@bitsocialnet/bitsocial-react-hooks/dist/stores/accounts', () => ({
|
||||||
@@ -82,6 +85,10 @@ vi.mock('../../../hooks/use-directories', () => ({
|
|||||||
useDirectoriesMetadata: () => testState.directoriesMetadata,
|
useDirectoriesMetadata: () => testState.directoriesMetadata,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../hooks/use-community-identifiers', () => ({
|
||||||
|
useCommunityIdentifier: () => testState.communityIdentifier,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../../hooks/use-resolved-community-address', () => ({
|
vi.mock('../../../hooks/use-resolved-community-address', () => ({
|
||||||
useResolvedCommunityAddress: () => testState.resolvedAddress,
|
useResolvedCommunityAddress: () => testState.resolvedAddress,
|
||||||
}));
|
}));
|
||||||
@@ -122,6 +129,8 @@ describe('BoardHeader', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
testState.accountComment = undefined;
|
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.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }];
|
||||||
testState.directoriesMetadata = { title: '/all/ - Directories' };
|
testState.directoriesMetadata = { title: '/all/ - Directories' };
|
||||||
testState.isMobile = false;
|
testState.isMobile = false;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocation, useParams, useNavigate } from 'react-router-dom';
|
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 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 getShortAddress from '../../lib/get-short-address';
|
||||||
|
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
|
||||||
import { useStableCommunity } from '../../hooks/use-stable-community';
|
import { useStableCommunity } from '../../hooks/use-stable-community';
|
||||||
import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils';
|
import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils';
|
||||||
import { isArchiveRoute } from '../../lib/utils/route-utils';
|
import { isArchiveRoute } from '../../lib/utils/route-utils';
|
||||||
@@ -27,9 +28,9 @@ const ImageBanner = () => {
|
|||||||
// Separate component for offline indicator to isolate rerenders from updatingState
|
// Separate component for offline indicator to isolate rerenders from updatingState
|
||||||
// Only this component will rerender when updatingState changes, not the whole BoardHeader
|
// Only this component will rerender when updatingState changes, not the whole BoardHeader
|
||||||
const OfflineIndicator = ({ communityAddress }: { communityAddress: string | undefined }) => {
|
const OfflineIndicator = ({ communityAddress }: { communityAddress: string | undefined }) => {
|
||||||
// Subscribe to full community including transient state for offline detection
|
const communityIdentifier = useCommunityIdentifier(communityAddress);
|
||||||
const community = useCommunitiesStore((state) => (communityAddress ? state.communities[communityAddress] : undefined));
|
const community = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined);
|
||||||
const { isOffline, isOnlineStatusLoading, offlineIconClass, offlineTitle } = useIsCommunityOffline(community);
|
const { isOffline, isOnlineStatusLoading, offlineIconClass, offlineTitle } = useIsCommunityOffline(community, communityAddress);
|
||||||
|
|
||||||
if (!isOffline && !isOnlineStatusLoading) {
|
if (!isOffline && !isOnlineStatusLoading) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,35 +1,9 @@
|
|||||||
import { useMemo } from 'react';
|
import { useCommunity } from '@bitsocialnet/bitsocial-react-hooks';
|
||||||
import useCommunitiesStore from '@bitsocialnet/bitsocial-react-hooks/dist/stores/communities';
|
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
|
||||||
import { normalizeBoardAddress, useDirectoryByAddress } from '../../hooks/use-directories';
|
import { useDirectoryByAddress } from '../../hooks/use-directories';
|
||||||
import useIsCommunityOffline from '../../hooks/use-is-community-offline';
|
import useIsCommunityOffline from '../../hooks/use-is-community-offline';
|
||||||
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-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 {
|
interface BoardOfflineAlertProps {
|
||||||
className: string;
|
className: string;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
@@ -39,23 +13,10 @@ interface BoardOfflineAlertProps {
|
|||||||
const BoardOfflineAlert = ({ className, hidden = false, communityAddress }: BoardOfflineAlertProps) => {
|
const BoardOfflineAlert = ({ className, hidden = false, communityAddress }: BoardOfflineAlertProps) => {
|
||||||
const resolvedCommunityAddress = useResolvedCommunityAddress();
|
const resolvedCommunityAddress = useResolvedCommunityAddress();
|
||||||
const directoryEntry = useDirectoryByAddress(resolvedCommunityAddress || communityAddress);
|
const directoryEntry = useDirectoryByAddress(resolvedCommunityAddress || communityAddress);
|
||||||
const addressCandidates = useMemo(
|
const canonicalCommunityAddress = resolvedCommunityAddress || directoryEntry?.address || communityAddress;
|
||||||
() => getBoardAddressCandidates([resolvedCommunityAddress, directoryEntry?.address, communityAddress]),
|
const communityIdentifier = useCommunityIdentifier(canonicalCommunityAddress);
|
||||||
[directoryEntry?.address, resolvedCommunityAddress, communityAddress],
|
const community = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined);
|
||||||
);
|
const { isOffline, isOnlineStatusLoading, offlineTitle } = useIsCommunityOffline(community, canonicalCommunityAddress);
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
if (hidden || (!isOffline && !isOnlineStatusLoading)) {
|
if (hidden || (!isOffline && !isOnlineStatusLoading)) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
|||||||
setAccount: (account: unknown) => testState.setAccountMock(account),
|
setAccount: (account: unknown) => testState.setAccountMock(account),
|
||||||
useAccount: () => testState.account,
|
useAccount: () => testState.account,
|
||||||
useAccountComment: () => testState.accountComment,
|
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 }),
|
useEditedComment: () => ({ editedComment: testState.editedComment }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -88,6 +92,10 @@ vi.mock('../../../hooks/use-directories', () => ({
|
|||||||
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
|
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', () => ({
|
vi.mock('../../../hooks/use-resolved-community-address', () => ({
|
||||||
useResolvedCommunityAddress: () => testState.resolvedCommunityAddress,
|
useResolvedCommunityAddress: () => testState.resolvedCommunityAddress,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ vi.mock('react-i18next', () => ({
|
|||||||
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
vi.mock('@bitsocialnet/bitsocial-react-hooks', () => ({
|
||||||
setAccount: (account: unknown) => testState.setAccountMock(account),
|
setAccount: (account: unknown) => testState.setAccountMock(account),
|
||||||
useAccount: () => testState.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', () => ({
|
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)$/, ''),
|
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', () => ({
|
vi.mock('../../../hooks/use-resolved-community-address', () => ({
|
||||||
useResolvedCommunityAddress: () => testState.resolvedCommunityAddress,
|
useResolvedCommunityAddress: () => testState.resolvedCommunityAddress,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -49,8 +49,14 @@ let latestValue: ReturnType<typeof useIsCommunityOffline>;
|
|||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let root: Root;
|
let root: Root;
|
||||||
|
|
||||||
const HookHarness = ({ community }: { community?: { address?: string; state?: string; updatedAt?: number; updatingState?: string } }) => {
|
const HookHarness = ({
|
||||||
latestValue = useIsCommunityOffline(community as never);
|
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;
|
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 () => {
|
await act(async () => {
|
||||||
root.render(createElement(HookHarness, { community }));
|
root.render(createElement(HookHarness, { community, communityAddressHint }));
|
||||||
});
|
});
|
||||||
await flushEffects();
|
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 () => {
|
it('treats recently updated boards as online', async () => {
|
||||||
const freshUpdatedAt = 1_704_067_205;
|
const freshUpdatedAt = 1_704_067_205;
|
||||||
testState.communityOfflineState = {
|
testState.communityOfflineState = {
|
||||||
|
|||||||
@@ -5,25 +5,33 @@ import { getFormattedTimeAgo } from '../lib/utils/time-utils';
|
|||||||
import useCommunityOfflineStore from '../stores/use-community-offline-store';
|
import useCommunityOfflineStore from '../stores/use-community-offline-store';
|
||||||
import useCommunitiesLoadingStartTimestamps from '../stores/use-communities-loading-start-timestamps-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 { t } = useTranslation();
|
||||||
const { address, state, updatedAt, updatingState } = community || {};
|
const { state, updatedAt, updatingState } = community || {};
|
||||||
|
const communityKey = getCommunityOfflineKey(community, communityAddressHint);
|
||||||
const { communityOfflineState, setCommunityOfflineState, initializeCommunityOfflineState } = useCommunityOfflineStore();
|
const { communityOfflineState, setCommunityOfflineState, initializeCommunityOfflineState } = useCommunityOfflineStore();
|
||||||
const communitiesLoadingStartTimestamps = useCommunitiesLoadingStartTimestamps([address]);
|
const communitiesLoadingStartTimestamps = useCommunitiesLoadingStartTimestamps(communityKey ? [communityKey] : undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (address && !communityOfflineState[address]) {
|
if (communityKey && !communityOfflineState[communityKey]) {
|
||||||
initializeCommunityOfflineState(address);
|
initializeCommunityOfflineState(communityKey);
|
||||||
}
|
}
|
||||||
}, [address, communityOfflineState, initializeCommunityOfflineState]);
|
}, [communityKey, communityOfflineState, initializeCommunityOfflineState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (address) {
|
if (communityKey) {
|
||||||
setCommunityOfflineState(address, { state, updatedAt, updatingState });
|
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 loadingStartTimestamp = communitiesLoadingStartTimestamps[0] || 0;
|
||||||
const isLoading = offlineState.initialLoad && (!updatedAt || Date.now() / 1000 - updatedAt >= 120 * 120) && Date.now() / 1000 - loadingStartTimestamp < 30;
|
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));
|
const isOffline = !isLoading && ((updatedAt && updatedAt < Date.now() / 1000 - 120 * 120) || (!updatedAt && Date.now() / 1000 - loadingStartTimestamp >= 30));
|
||||||
|
|||||||
Reference in New Issue
Block a user