mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(community status): use sync lifecycle for availability (#1187)
* chore(deps): upgrade pkc-js to 0.0.72 and bitsocial-react-hooks to 0.1.31 Update bitsocial internals import paths for the new package dist layout. * fix(community status): use sync lifecycle for availability * fix(community status): handle terminal sync without cache * fix(directory routing): resolve candidate sync aliases * fix(directory routing): preserve fresh cached winners
This commit is contained in:
+2
-2
@@ -9,7 +9,7 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@bbob/parser": "4.3.1",
|
||||
"@bitsocial/bitsocial-react-hooks": "0.1.30",
|
||||
"@bitsocial/bitsocial-react-hooks": "0.1.31",
|
||||
"@bitsocial/bso-resolver": "0.0.10",
|
||||
"@capacitor/app": "7.0.1",
|
||||
"@capacitor/browser": "7.0.5",
|
||||
@@ -17,7 +17,7 @@
|
||||
"@capawesome/capacitor-android-edge-to-edge-support": "7.2.2",
|
||||
"@chenglou/pretext": "0.0.8",
|
||||
"@floating-ui/react": "0.26.1",
|
||||
"@pkcprotocol/pkc-js": "0.0.71",
|
||||
"@pkcprotocol/pkc-js": "0.0.72",
|
||||
"@react-spring/web": "10.0.3",
|
||||
"@ruffle-rs/ruffle": "0.2.0",
|
||||
"@types/node": "20.19.37",
|
||||
|
||||
@@ -25,8 +25,7 @@ const ImageBanner = () => {
|
||||
return <img src={banner} alt='' />;
|
||||
};
|
||||
|
||||
// Separate component for offline indicator to isolate rerenders from updatingState
|
||||
// Only this component will rerender when updatingState changes, not the whole BoardHeader
|
||||
// Separate component for offline indicator to isolate rerenders from sync lifecycle changes.
|
||||
const OfflineIndicator = ({ communityAddress }: { communityAddress: string | undefined }) => {
|
||||
const communityIdentifier = useCommunityIdentifier(communityAddress);
|
||||
const community = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined);
|
||||
|
||||
@@ -2,17 +2,28 @@ import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { CommunitySyncState } from '@bitsocial/bitsocial-react-hooks';
|
||||
import useIsCommunityOffline from '../use-is-community-offline';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
interface TestCommunity {
|
||||
address?: string;
|
||||
name?: string;
|
||||
publicKey?: string;
|
||||
state?: string;
|
||||
syncState?: CommunitySyncState;
|
||||
hasCachedData?: boolean;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
initializeMock: vi.fn(),
|
||||
loadingTimestamps: [0] as number[],
|
||||
requestedAddresses: undefined as string[] | undefined,
|
||||
setOfflineStateMock: vi.fn(),
|
||||
communityOfflineState: {} as Record<string, { initialLoad: boolean; state?: string; updatedAt?: number; updatingState?: string }>,
|
||||
communityOfflineState: {} as Record<string, { initialLoad: boolean; state?: string; updatedAt?: number }>,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
@@ -49,13 +60,7 @@ let latestValue: ReturnType<typeof useIsCommunityOffline>;
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const HookHarness = ({
|
||||
community,
|
||||
communityAddressHint,
|
||||
}: {
|
||||
community?: { address?: string; name?: string; publicKey?: string; state?: string; updatedAt?: number; updatingState?: string };
|
||||
communityAddressHint?: string;
|
||||
}) => {
|
||||
const HookHarness = ({ community, communityAddressHint }: { community?: TestCommunity; communityAddressHint?: string }) => {
|
||||
latestValue = useIsCommunityOffline(community as never, communityAddressHint);
|
||||
return null;
|
||||
};
|
||||
@@ -68,10 +73,7 @@ const flushEffects = async (count = 3) => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderHook = async (
|
||||
community?: { address?: string; name?: string; publicKey?: string; state?: string; updatedAt?: number; updatingState?: string },
|
||||
communityAddressHint?: string,
|
||||
) => {
|
||||
const renderHook = async (community?: TestCommunity, communityAddressHint?: string) => {
|
||||
await act(async () => {
|
||||
root.render(createElement(HookHarness, { community, communityAddressHint }));
|
||||
});
|
||||
@@ -106,14 +108,13 @@ describe('useIsCommunityOffline', () => {
|
||||
});
|
||||
|
||||
it('initializes unseen boards and reports a loading state during the first sync window', async () => {
|
||||
await renderHook({ address: 'music.eth', state: 'updating', updatingState: 'fetching' });
|
||||
await renderHook({ address: 'music.eth', state: 'initializing', syncState: 'loading', hasCachedData: false });
|
||||
|
||||
expect(testState.requestedAddresses).toEqual(['music.eth']);
|
||||
expect(testState.initializeMock).toHaveBeenCalledWith('music.eth');
|
||||
expect(testState.setOfflineStateMock).toHaveBeenCalledWith('music.eth', {
|
||||
state: 'updating',
|
||||
state: 'initializing',
|
||||
updatedAt: undefined,
|
||||
updatingState: 'fetching',
|
||||
});
|
||||
expect(latestValue).toEqual({
|
||||
isOffline: false,
|
||||
@@ -133,7 +134,7 @@ describe('useIsCommunityOffline', () => {
|
||||
};
|
||||
testState.loadingTimestamps = [1_704_067_000];
|
||||
|
||||
await renderHook({ address: 'music.eth', state: 'stopped', updatedAt: staleUpdatedAt });
|
||||
await renderHook({ address: 'music.eth', state: 'succeeded', syncState: 'stopped', hasCachedData: true, updatedAt: staleUpdatedAt });
|
||||
|
||||
expect(testState.initializeMock).not.toHaveBeenCalled();
|
||||
expect(latestValue).toEqual({
|
||||
@@ -153,7 +154,7 @@ describe('useIsCommunityOffline', () => {
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook({ address: 'music.eth', state: 'started', updatedAt: freshUpdatedAt });
|
||||
await renderHook({ address: 'music.eth', state: 'succeeded', syncState: 'loading', hasCachedData: true, updatedAt: freshUpdatedAt });
|
||||
|
||||
expect(latestValue).toEqual({
|
||||
isOffline: false,
|
||||
@@ -172,7 +173,7 @@ describe('useIsCommunityOffline', () => {
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook({ address: 'music.eth', state: 'started', updatedAt: freshUpdatedAt });
|
||||
await renderHook({ address: 'music.eth', state: 'succeeded', syncState: 'succeeded', hasCachedData: true, updatedAt: freshUpdatedAt });
|
||||
|
||||
expect(latestValue.isOffline).toBe(false);
|
||||
|
||||
@@ -206,15 +207,80 @@ describe('useIsCommunityOffline', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('marks a stopped first synchronization offline without waiting for the legacy timeout', async () => {
|
||||
testState.communityOfflineState = {
|
||||
'music.eth': {
|
||||
initialLoad: false,
|
||||
},
|
||||
};
|
||||
testState.loadingTimestamps = [1_704_067_200];
|
||||
|
||||
await renderHook({ address: 'music.eth', syncState: 'stopped', hasCachedData: false });
|
||||
|
||||
expect(latestValue).toEqual({
|
||||
isOffline: true,
|
||||
isOnlineStatusLoading: false,
|
||||
offlineIconClass: 'redOfflineIcon',
|
||||
offlineTitle: 'community_offline_info',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps boards loading while their first fetch remains active past the legacy timeout', async () => {
|
||||
testState.communityOfflineState = {
|
||||
'music.eth': {
|
||||
initialLoad: false,
|
||||
},
|
||||
};
|
||||
testState.loadingTimestamps = [1_704_067_100];
|
||||
|
||||
await renderHook({ address: 'music.eth', syncState: 'loading', hasCachedData: false });
|
||||
|
||||
expect(latestValue).toEqual({
|
||||
isOffline: false,
|
||||
isOnlineStatusLoading: true,
|
||||
offlineIconClass: 'yellowOfflineIcon',
|
||||
offlineTitle: 'downloading board...',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps stale cached boards offline while synchronization is retrying', async () => {
|
||||
const staleUpdatedAt = 1_704_067_210 - 31 * 60;
|
||||
testState.communityOfflineState = {
|
||||
'music.eth': {
|
||||
initialLoad: false,
|
||||
updatedAt: staleUpdatedAt,
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook({ address: 'music.eth', state: 'succeeded', syncState: 'retrying', hasCachedData: true, updatedAt: staleUpdatedAt });
|
||||
|
||||
expect(latestValue).toEqual({
|
||||
isOffline: true,
|
||||
isOnlineStatusLoading: false,
|
||||
offlineIconClass: 'redOfflineIcon',
|
||||
offlineTitle: `posts_last_synced_info:ago:${staleUpdatedAt}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a definitive initial synchronization failure without waiting for the legacy timeout', async () => {
|
||||
await renderHook({ address: 'music.eth', syncState: 'failed', hasCachedData: false });
|
||||
|
||||
expect(latestValue).toEqual({
|
||||
isOffline: true,
|
||||
isOnlineStatusLoading: false,
|
||||
offlineIconClass: 'redOfflineIcon',
|
||||
offlineTitle: 'community_offline_info',
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
await renderHook({ name: 'music.eth', publicKey: '12D3KooWBoardKey', state: 'initializing', syncState: 'loading', hasCachedData: false }, 'music.eth');
|
||||
|
||||
expect(testState.requestedAddresses).toEqual(['music.eth']);
|
||||
expect(testState.initializeMock).toHaveBeenCalledWith('music.eth');
|
||||
expect(testState.setOfflineStateMock).toHaveBeenCalledWith('music.eth', {
|
||||
state: 'updating',
|
||||
state: 'initializing',
|
||||
updatedAt: undefined,
|
||||
updatingState: 'fetching',
|
||||
});
|
||||
expect(latestValue).toEqual({
|
||||
isOffline: false,
|
||||
@@ -233,7 +299,7 @@ describe('useIsCommunityOffline', () => {
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook({ address: 'music.eth', state: 'started', updatedAt: freshUpdatedAt });
|
||||
await renderHook({ address: 'music.eth', state: 'succeeded', syncState: 'failed', hasCachedData: true, updatedAt: freshUpdatedAt });
|
||||
|
||||
expect(latestValue).toEqual({
|
||||
isOffline: false,
|
||||
|
||||
@@ -3,8 +3,8 @@ import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Comment } from '@bitsocial/bitsocial-react-hooks';
|
||||
import communitiesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities';
|
||||
import communitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
|
||||
import communitiesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities/index.js';
|
||||
import communitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages/index.js';
|
||||
import usePruneHiddenCatalogThreads from '../use-prune-hidden-catalog-threads';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { CommunitySyncState } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { useResolvedCommunityAddress, useResolvedDirectoryBoardPath } from '../use-resolved-community-address';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -20,11 +21,14 @@ const testState = vi.hoisted(() => ({
|
||||
list: {
|
||||
directoryCode: 'biz',
|
||||
boards: [
|
||||
{ address: 'business-and-finance.bso', score: 100 },
|
||||
{ address: 'business-and-finance.bso', publicKey: '12D3KooWBusiness', score: 100 },
|
||||
{ address: 'bizraelis.bso', score: 10 },
|
||||
],
|
||||
},
|
||||
offlineStates: {} as Record<string, { updatedAt?: number; state?: string }>,
|
||||
communities: {} as Record<string, { address?: string; name?: string; publicKey?: string; state?: string; updatedAt?: number }>,
|
||||
syncStatuses: {} as Record<string, { syncState: CommunitySyncState }>,
|
||||
candidatePublicKeys: {} as Record<string, string | undefined>,
|
||||
offlineSelections: [] as unknown[],
|
||||
}));
|
||||
|
||||
@@ -57,6 +61,25 @@ vi.mock('../../stores/use-community-offline-store', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/bitsocial-internals/stores', () => ({
|
||||
communitiesStore: <T,>(selector: (state: { communities: typeof testState.communities; syncStatuses: typeof testState.syncStatuses }) => T) =>
|
||||
selector({
|
||||
communities: testState.communities,
|
||||
syncStatuses: testState.syncStatuses,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/utils/directory-list-lookup-utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../lib/utils/directory-list-lookup-utils')>('../../lib/utils/directory-list-lookup-utils');
|
||||
return {
|
||||
...actual,
|
||||
getDirectoryCandidateBoardByAddress: (address: string) => {
|
||||
const publicKey = testState.candidatePublicKeys[address];
|
||||
return publicKey ? { address, publicKey } : undefined;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
let latestValue: string | undefined;
|
||||
let latestDirectoryBoardPath: { boardPath: string | undefined; isDirectoryCandidate: boolean };
|
||||
let container: HTMLDivElement;
|
||||
@@ -82,7 +105,14 @@ describe('useResolvedCommunityAddress', () => {
|
||||
latestDirectoryBoardPath = { boardPath: undefined, isDirectoryCandidate: false };
|
||||
testState.boardIdentifier = 'biz';
|
||||
testState.boardIdentifierOverride = undefined;
|
||||
testState.list.boards = [
|
||||
{ address: 'business-and-finance.bso', publicKey: '12D3KooWBusiness', score: 100 },
|
||||
{ address: 'bizraelis.bso', score: 10 },
|
||||
];
|
||||
testState.offlineStates = {};
|
||||
testState.communities = {};
|
||||
testState.syncStatuses = {};
|
||||
testState.candidatePublicKeys = {};
|
||||
testState.offlineSelections = [];
|
||||
|
||||
container = document.createElement('div');
|
||||
@@ -120,6 +150,149 @@ describe('useResolvedCommunityAddress', () => {
|
||||
expect(latestValue).toBe('business-and-finance.bso');
|
||||
});
|
||||
|
||||
it('skips a stale higher-ranked directory board while its synchronization retries', async () => {
|
||||
testState.offlineStates = {
|
||||
'business-and-finance.bso': {
|
||||
updatedAt: 1_704_067_210 - 31 * 60,
|
||||
},
|
||||
};
|
||||
testState.syncStatuses = {
|
||||
'12D3KooWBusiness': {
|
||||
syncState: 'loading',
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook();
|
||||
|
||||
expect(latestValue).toBe('bizraelis.bso');
|
||||
});
|
||||
|
||||
it('skips a higher-ranked directory board when its first synchronization fails by public key', async () => {
|
||||
testState.syncStatuses = {
|
||||
'12D3KooWBusiness': {
|
||||
syncState: 'failed',
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook();
|
||||
|
||||
expect(latestValue).toBe('bizraelis.bso');
|
||||
});
|
||||
|
||||
it('keeps a fresh cached winner when a later synchronization fails', async () => {
|
||||
testState.communities = {
|
||||
'12D3KooWBusiness': {
|
||||
address: '12D3KooWBusiness',
|
||||
name: 'business-and-finance.bso',
|
||||
publicKey: '12D3KooWBusiness',
|
||||
state: 'succeeded',
|
||||
updatedAt: 1_704_067_210 - 60,
|
||||
},
|
||||
};
|
||||
testState.syncStatuses = {
|
||||
'12D3KooWBusiness': {
|
||||
syncState: 'failed',
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook();
|
||||
|
||||
expect(latestValue).toBe('business-and-finance.bso');
|
||||
});
|
||||
|
||||
it('uses the freshest timestamp across matching cached aliases and the offline mirror', async () => {
|
||||
testState.offlineStates = {
|
||||
'business-and-finance.bso': {
|
||||
updatedAt: 1_704_067_210 - 31 * 60,
|
||||
},
|
||||
};
|
||||
testState.communities = {
|
||||
'business-and-finance.bso': {
|
||||
address: 'business-and-finance.bso',
|
||||
name: 'business-and-finance.bso',
|
||||
publicKey: '12D3KooWBusiness',
|
||||
updatedAt: 1_704_067_210 - 31 * 60,
|
||||
},
|
||||
'12D3KooWBusiness': {
|
||||
address: '12D3KooWBusiness',
|
||||
name: 'business-and-finance.bso',
|
||||
publicKey: '12D3KooWBusiness',
|
||||
updatedAt: 1_704_067_210 - 60,
|
||||
},
|
||||
};
|
||||
testState.syncStatuses = {
|
||||
'12D3KooWBusiness': {
|
||||
syncState: 'failed',
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook();
|
||||
|
||||
expect(latestValue).toBe('business-and-finance.bso');
|
||||
});
|
||||
|
||||
it('treats a zero cached timestamp as stale while synchronization retries', async () => {
|
||||
testState.communities = {
|
||||
'12D3KooWBusiness': {
|
||||
address: '12D3KooWBusiness',
|
||||
name: 'business-and-finance.bso',
|
||||
publicKey: '12D3KooWBusiness',
|
||||
updatedAt: 0,
|
||||
},
|
||||
};
|
||||
testState.syncStatuses = {
|
||||
'12D3KooWBusiness': {
|
||||
syncState: 'retrying',
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook();
|
||||
|
||||
expect(latestValue).toBe('bizraelis.bso');
|
||||
});
|
||||
|
||||
it('uses a cached community public key when the directory list only has its address', async () => {
|
||||
testState.list.boards = [
|
||||
{ address: 'business-and-finance.bso', score: 100 },
|
||||
{ address: 'bizraelis.bso', score: 10 },
|
||||
];
|
||||
testState.communities = {
|
||||
'business-and-finance.bso': {
|
||||
address: 'business-and-finance.bso',
|
||||
name: 'business-and-finance.bso',
|
||||
publicKey: '12D3KooWBusiness',
|
||||
},
|
||||
};
|
||||
testState.syncStatuses = {
|
||||
'12D3KooWBusiness': {
|
||||
syncState: 'failed',
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook();
|
||||
|
||||
expect(latestValue).toBe('bizraelis.bso');
|
||||
});
|
||||
|
||||
it('uses the vendored candidate public key when the fetched directory list only has its address', async () => {
|
||||
testState.list.boards = [
|
||||
{ address: 'business-and-finance.bso', score: 100 },
|
||||
{ address: 'bizraelis.bso', score: 10 },
|
||||
];
|
||||
testState.candidatePublicKeys = {
|
||||
'business-and-finance.bso': '12D3KooWBusiness',
|
||||
};
|
||||
testState.syncStatuses = {
|
||||
'12D3KooWBusiness': {
|
||||
syncState: 'failed',
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook();
|
||||
|
||||
expect(latestValue).toBe('bizraelis.bso');
|
||||
});
|
||||
|
||||
it('does not subscribe to offline state on non-directory board routes', async () => {
|
||||
testState.boardIdentifier = 'custom-board.bso';
|
||||
testState.offlineStates = {
|
||||
|
||||
@@ -322,9 +322,9 @@ export const useDirectoryLists = (directoryCodes: string[] | undefined): Directo
|
||||
* Returns the highest-ranked online board, or — if every candidate looks offline —
|
||||
* the highest-ranked board anyway, so the user still lands somewhere.
|
||||
*/
|
||||
export const pickDirectoryWinner = (boards: DirectoryListBoard[], isOffline: (address: string) => boolean): DirectoryListBoard | undefined => {
|
||||
export const pickDirectoryWinner = (boards: DirectoryListBoard[], isOffline: (board: DirectoryListBoard) => boolean): DirectoryListBoard | undefined => {
|
||||
const ranked = sortDirectoryBoardsByRank(boards);
|
||||
return ranked.find((board) => !isOffline(board.address)) ?? ranked[0];
|
||||
return ranked.find((board) => !isOffline(board)) ?? ranked[0];
|
||||
};
|
||||
|
||||
export { sortDirectoryBoardsByRank };
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEffect } from 'react';
|
||||
import { Community } from '@bitsocial/bitsocial-react-hooks';
|
||||
import type { Community, UseCommunityResult } from '@bitsocial/bitsocial-react-hooks';
|
||||
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';
|
||||
import { isCommunityUpdateStale } from '../lib/utils/community-freshness-utils';
|
||||
import { isCommunitySyncLoading, isCommunitySyncTerminal, isCommunityUpdateStale } from '../lib/utils/community-freshness-utils';
|
||||
import { useNowSeconds } from './use-now-seconds';
|
||||
|
||||
const getCommunityOfflineKey = (community?: Community, communityAddressHint?: string) =>
|
||||
type CommunityWithSyncLifecycle = Community & Partial<Pick<UseCommunityResult, 'syncState' | 'hasCachedData'>>;
|
||||
|
||||
const getCommunityOfflineKey = (community?: CommunityWithSyncLifecycle, communityAddressHint?: string) =>
|
||||
communityAddressHint || community?.address || community?.name || community?.publicKey;
|
||||
|
||||
const useIsCommunityOffline = (community?: Community | undefined, communityAddressHint?: string) => {
|
||||
const useIsCommunityOffline = (community?: CommunityWithSyncLifecycle | undefined, communityAddressHint?: string) => {
|
||||
const { t } = useTranslation();
|
||||
const { state, updatedAt, updatingState } = community || {};
|
||||
const { state, syncState, hasCachedData, updatedAt } = community || {};
|
||||
const communityKey = getCommunityOfflineKey(community, communityAddressHint);
|
||||
const nowSeconds = useNowSeconds(!!communityKey);
|
||||
const { communityOfflineState, setCommunityOfflineState, initializeCommunityOfflineState } = useCommunityOfflineStore();
|
||||
@@ -26,9 +28,9 @@ const useIsCommunityOffline = (community?: Community | undefined, communityAddre
|
||||
|
||||
useEffect(() => {
|
||||
if (communityKey) {
|
||||
setCommunityOfflineState(communityKey, { state, updatedAt, updatingState });
|
||||
setCommunityOfflineState(communityKey, { state, updatedAt });
|
||||
}
|
||||
}, [communityKey, state, updatedAt, updatingState, setCommunityOfflineState]);
|
||||
}, [communityKey, state, updatedAt, setCommunityOfflineState]);
|
||||
|
||||
if (!communityKey) {
|
||||
return { isOffline: false, isOnlineStatusLoading: false, offlineIconClass: '', offlineTitle: false };
|
||||
@@ -37,10 +39,15 @@ const useIsCommunityOffline = (community?: Community | undefined, communityAddre
|
||||
const offlineState = communityOfflineState[communityKey] || { initialLoad: true };
|
||||
const loadingStartTimestamp = communitiesLoadingStartTimestamps[0] || 0;
|
||||
const isStale = isCommunityUpdateStale(updatedAt, nowSeconds);
|
||||
const isLoading = offlineState.initialLoad && (!updatedAt || isStale) && nowSeconds - loadingStartTimestamp < 30;
|
||||
const isOffline = !isLoading && (isStale || (!updatedAt && nowSeconds - loadingStartTimestamp >= 30));
|
||||
const hasUsableCachedData = (hasCachedData ?? typeof updatedAt === 'number') && updatedAt !== undefined;
|
||||
const isOnline = hasUsableCachedData && !isStale;
|
||||
const hasFailed = syncState === 'failed' || (!syncState && state === 'failed');
|
||||
const isSyncLoading = isCommunitySyncLoading(syncState);
|
||||
const hasTerminalSyncState = isCommunitySyncTerminal(syncState);
|
||||
const isFallbackLoading = !syncState && offlineState.initialLoad && nowSeconds - loadingStartTimestamp < 30;
|
||||
const isLoading = !isOnline && !isStale && !hasFailed && (isSyncLoading || isFallbackLoading);
|
||||
const isOffline = !isOnline && !isLoading && (hasFailed || isStale || (!hasUsableCachedData && (hasTerminalSyncState || nowSeconds - loadingStartTimestamp >= 30)));
|
||||
|
||||
const isOnline = updatedAt && !isStale;
|
||||
const offlineIconClass = isLoading ? 'yellowOfflineIcon' : isOffline ? 'redOfflineIcon' : '';
|
||||
|
||||
const offlineTitle = isLoading
|
||||
@@ -49,7 +56,7 @@ const useIsCommunityOffline = (community?: Community | undefined, communityAddre
|
||||
? isOffline && t('posts_last_synced_info', { time: getFormattedTimeAgo(updatedAt), interpolation: { escapeValue: false } })
|
||||
: t('community_offline_info');
|
||||
|
||||
return { isOffline: !isOnline && isOffline, isOnlineStatusLoading: !isOnline && isLoading, offlineIconClass, offlineTitle };
|
||||
return { isOffline, isOnlineStatusLoading: isLoading, offlineIconClass, offlineTitle };
|
||||
};
|
||||
|
||||
export default useIsCommunityOffline;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useDirectories } from './use-directories';
|
||||
import { getDirectoryCodeForBoardAddress, pickDirectoryWinner, useDirectoryList } from './use-directory-list';
|
||||
import type { CommunitySyncState } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { normalizeBoardAddress, useDirectories } from './use-directories';
|
||||
import { getDirectoryCodeForBoardAddress, pickDirectoryWinner, useDirectoryList, type DirectoryListBoard } from './use-directory-list';
|
||||
import useCommunityOfflineStore from '../stores/use-community-offline-store';
|
||||
import { areSameBoardAddress, getCommunityAddress, getBoardPath, isDirectoryRoute } from '../lib/utils/route-utils';
|
||||
import { isCommunityKnownOffline } from '../lib/utils/community-freshness-utils';
|
||||
import { isCommunityKnownOffline, type CommunityFreshnessState } from '../lib/utils/community-freshness-utils';
|
||||
import { communitiesStore as useCommunitiesStore } from '../lib/bitsocial-internals/stores';
|
||||
import { getDirectoryCandidateBoardByAddress } from '../lib/utils/directory-list-lookup-utils';
|
||||
import { useNowSeconds } from './use-now-seconds';
|
||||
|
||||
interface ResolvedDirectoryBoardPath {
|
||||
@@ -12,6 +15,90 @@ interface ResolvedDirectoryBoardPath {
|
||||
isDirectoryCandidate: boolean;
|
||||
}
|
||||
|
||||
interface StoredCommunity {
|
||||
address?: string;
|
||||
name?: string;
|
||||
publicKey?: string;
|
||||
state?: string;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
type StoredCommunities = Record<string, StoredCommunity | undefined>;
|
||||
type CommunitySyncStatuses = Record<string, { syncState: CommunitySyncState } | undefined>;
|
||||
|
||||
interface CommunityLifecycleState {
|
||||
state?: string;
|
||||
syncState?: CommunitySyncState;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
const getCommunityLifecycleState = (
|
||||
communities: StoredCommunities | undefined,
|
||||
syncStatuses: CommunitySyncStatuses | undefined,
|
||||
communityAddress: string,
|
||||
communityPublicKey?: string,
|
||||
): CommunityLifecycleState => {
|
||||
const directSyncStatus = (communityPublicKey && syncStatuses?.[communityPublicKey]) || syncStatuses?.[communityAddress];
|
||||
let syncState = directSyncStatus?.syncState;
|
||||
let matchedCommunity: StoredCommunity | undefined;
|
||||
const normalizedAddress = normalizeBoardAddress(communityAddress);
|
||||
|
||||
for (const [key, community] of Object.entries(communities || {})) {
|
||||
const storedIdentifiers = [key, community?.address, community?.name, community?.publicKey];
|
||||
const matchesCommunity =
|
||||
(communityPublicKey && storedIdentifiers.includes(communityPublicKey)) ||
|
||||
storedIdentifiers.some((identifier) => identifier && normalizeBoardAddress(identifier) === normalizedAddress);
|
||||
if (!matchesCommunity) continue;
|
||||
|
||||
if (
|
||||
community &&
|
||||
(!matchedCommunity || (community.updatedAt !== undefined && (matchedCommunity.updatedAt === undefined || community.updatedAt > matchedCommunity.updatedAt)))
|
||||
) {
|
||||
matchedCommunity = community;
|
||||
}
|
||||
|
||||
if (!syncState) {
|
||||
for (const identifier of storedIdentifiers) {
|
||||
if (identifier && syncStatuses?.[identifier]) {
|
||||
syncState = syncStatuses[identifier]?.syncState;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state: matchedCommunity?.state,
|
||||
syncState,
|
||||
updatedAt: matchedCommunity?.updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
const getDirectoryBoardLifecycleState = (
|
||||
communities: StoredCommunities | undefined,
|
||||
syncStatuses: CommunitySyncStatuses | undefined,
|
||||
board: DirectoryListBoard,
|
||||
): CommunityLifecycleState => {
|
||||
const publicKey = board.publicKey ?? getDirectoryCandidateBoardByAddress(board.address)?.publicKey;
|
||||
return getCommunityLifecycleState(communities, syncStatuses, board.address, publicKey);
|
||||
};
|
||||
|
||||
const getDirectoryBoardFreshnessState = (
|
||||
communities: StoredCommunities | undefined,
|
||||
syncStatuses: CommunitySyncStatuses | undefined,
|
||||
offlineState: CommunityFreshnessState | undefined,
|
||||
board: DirectoryListBoard,
|
||||
): CommunityFreshnessState => {
|
||||
const lifecycleState = getDirectoryBoardLifecycleState(communities, syncStatuses, board);
|
||||
const updatedAts = [lifecycleState.updatedAt, offlineState?.updatedAt].filter((updatedAt): updatedAt is number => updatedAt !== undefined);
|
||||
|
||||
return {
|
||||
state: lifecycleState.state === 'failed' || offlineState?.state === 'failed' ? 'failed' : (lifecycleState.state ?? offlineState?.state),
|
||||
syncState: lifecycleState.syncState,
|
||||
updatedAt: updatedAts.length > 0 ? Math.max(...updatedAts) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a board identifier to its canonical community address.
|
||||
*
|
||||
@@ -26,17 +113,20 @@ export const useResolvedCommunityAddress = (boardIdentifierOverride?: string): s
|
||||
const isCode = !!boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
|
||||
const { list } = useDirectoryList(isCode ? boardIdentifier : undefined);
|
||||
const offlineStates = useCommunityOfflineStore((state) => (isCode ? state.communityOfflineState : undefined));
|
||||
const communities = useCommunitiesStore((state) => (isCode ? state.communities : undefined));
|
||||
const syncStatuses = useCommunitiesStore((state) => (isCode ? state.syncStatuses : undefined));
|
||||
const nowSeconds = useNowSeconds(isCode);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!boardIdentifier) return undefined;
|
||||
if (isCode && list && list.boards.length > 0) {
|
||||
const isOffline = (address: string) => isCommunityKnownOffline(offlineStates?.[address], nowSeconds);
|
||||
const isOffline = (board: DirectoryListBoard) =>
|
||||
isCommunityKnownOffline(getDirectoryBoardFreshnessState(communities, syncStatuses, offlineStates?.[board.address], board), nowSeconds);
|
||||
const winner = pickDirectoryWinner(list.boards, isOffline);
|
||||
if (winner) return winner.address;
|
||||
}
|
||||
return getCommunityAddress(boardIdentifier, directories);
|
||||
}, [boardIdentifier, directories, isCode, list, offlineStates, nowSeconds]);
|
||||
}, [boardIdentifier, communities, directories, isCode, list, offlineStates, nowSeconds, syncStatuses]);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -49,6 +139,8 @@ export const useResolvedDirectoryBoardPath = (boardIdentifier: string | undefine
|
||||
const directoryCode = useMemo(() => (boardIdentifier && !isCode ? getDirectoryCodeForBoardAddress(boardIdentifier) : undefined), [boardIdentifier, isCode]);
|
||||
const { list } = useDirectoryList(directoryCode);
|
||||
const offlineStates = useCommunityOfflineStore((state) => (directoryCode ? state.communityOfflineState : undefined));
|
||||
const communities = useCommunitiesStore((state) => (directoryCode ? state.communities : undefined));
|
||||
const syncStatuses = useCommunitiesStore((state) => (directoryCode ? state.syncStatuses : undefined));
|
||||
const nowSeconds = useNowSeconds(!!directoryCode);
|
||||
|
||||
return useMemo(() => {
|
||||
@@ -60,14 +152,15 @@ export const useResolvedDirectoryBoardPath = (boardIdentifier: string | undefine
|
||||
return { boardPath: undefined, isDirectoryCandidate: true };
|
||||
}
|
||||
|
||||
const isOffline = (address: string) => isCommunityKnownOffline(offlineStates?.[address], nowSeconds);
|
||||
const isOffline = (board: DirectoryListBoard) =>
|
||||
isCommunityKnownOffline(getDirectoryBoardFreshnessState(communities, syncStatuses, offlineStates?.[board.address], board), nowSeconds);
|
||||
const winner = pickDirectoryWinner(list.boards, isOffline);
|
||||
|
||||
return {
|
||||
boardPath: winner && areSameBoardAddress(winner.address, boardIdentifier) ? directoryCode : undefined,
|
||||
isDirectoryCandidate: true,
|
||||
};
|
||||
}, [boardIdentifier, directoryCode, list, offlineStates, nowSeconds]);
|
||||
}, [boardIdentifier, communities, directoryCode, list, offlineStates, nowSeconds, syncStatuses]);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
// API changes stay localized to one reviewable file instead of scattering
|
||||
// across views, hooks, stores, components, and utils.
|
||||
|
||||
export { default as accountsStore } from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts';
|
||||
export { default as communitiesStore } from '@bitsocial/bitsocial-react-hooks/dist/stores/communities';
|
||||
export { default as communitiesPagesStore } from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
|
||||
export { default as feedsStore } from '@bitsocial/bitsocial-react-hooks/dist/stores/feeds';
|
||||
export { default as repliesStore, feedOptionsToFeedName } from '@bitsocial/bitsocial-react-hooks/dist/stores/replies';
|
||||
export { default as repliesPagesStore } from '@bitsocial/bitsocial-react-hooks/dist/stores/replies-pages';
|
||||
export { default as accountsStore } from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js';
|
||||
export { default as communitiesStore } from '@bitsocial/bitsocial-react-hooks/dist/stores/communities/index.js';
|
||||
export { default as communitiesPagesStore } from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages/index.js';
|
||||
export { default as feedsStore } from '@bitsocial/bitsocial-react-hooks/dist/stores/feeds/index.js';
|
||||
export { default as repliesStore, feedOptionsToFeedName } from '@bitsocial/bitsocial-react-hooks/dist/stores/replies/index.js';
|
||||
export { default as repliesPagesStore } from '@bitsocial/bitsocial-react-hooks/dist/stores/replies-pages/index.js';
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
// package's compiled `dist/lib/...` helpers. Do not import those paths directly
|
||||
// elsewhere in production code.
|
||||
|
||||
export { default as localForageLru } from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru';
|
||||
export { flattenCommentsPages } from '@bitsocial/bitsocial-react-hooks/dist/lib/utils';
|
||||
export { default as localForageLru } from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru/index.js';
|
||||
export { flattenCommentsPages } from '@bitsocial/bitsocial-react-hooks/dist/lib/utils/index.js';
|
||||
export { getEquivalentCommunityAddressGroupKey, pickPreferredEquivalentCommunityAddress } from '@bitsocial/bitsocial-react-hooks/dist/lib/community-address.js';
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { CommunitySyncState } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { isCommunityKnownOffline } from '../community-freshness-utils';
|
||||
|
||||
const NOW_SECONDS = 1_704_067_210;
|
||||
|
||||
describe('isCommunityKnownOffline', () => {
|
||||
it.each<CommunitySyncState>(['succeeded', 'failed', 'stopped'])('treats terminal %s synchronization without cached data as offline', (syncState) => {
|
||||
expect(isCommunityKnownOffline({ syncState }, NOW_SECONDS)).toBe(true);
|
||||
});
|
||||
|
||||
it.each<CommunitySyncState>(['initializing', 'loading', 'retrying'])('does not treat active %s synchronization without cached data as offline', (syncState) => {
|
||||
expect(isCommunityKnownOffline({ syncState }, NOW_SECONDS)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps fresh cached data online after a terminal failure', () => {
|
||||
expect(
|
||||
isCommunityKnownOffline(
|
||||
{
|
||||
syncState: 'failed',
|
||||
updatedAt: NOW_SECONDS - 60,
|
||||
},
|
||||
NOW_SECONDS,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps stale cached data offline while synchronization retries', () => {
|
||||
expect(
|
||||
isCommunityKnownOffline(
|
||||
{
|
||||
syncState: 'retrying',
|
||||
updatedAt: NOW_SECONDS - 31 * 60,
|
||||
},
|
||||
NOW_SECONDS,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,28 @@
|
||||
import type { CommunitySyncState } from '@bitsocial/bitsocial-react-hooks';
|
||||
|
||||
export const COMMUNITY_OFFLINE_THRESHOLD_SECONDS = 30 * 60;
|
||||
|
||||
export interface CommunityFreshnessState {
|
||||
state?: string;
|
||||
syncState?: CommunitySyncState;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export const isCommunityUpdateStale = (updatedAt: number | undefined, nowSeconds: number): boolean =>
|
||||
updatedAt !== undefined && nowSeconds - updatedAt >= COMMUNITY_OFFLINE_THRESHOLD_SECONDS;
|
||||
|
||||
export const isCommunitySyncLoading = (syncState: CommunitySyncState | undefined): boolean =>
|
||||
syncState === 'initializing' || syncState === 'loading' || syncState === 'retrying';
|
||||
|
||||
export const isCommunitySyncTerminal = (syncState: CommunitySyncState | undefined): boolean => syncState !== undefined && !isCommunitySyncLoading(syncState);
|
||||
|
||||
export const isCommunityKnownOffline = (communityState: CommunityFreshnessState | undefined, nowSeconds: number): boolean => {
|
||||
if (!communityState) return false;
|
||||
if (communityState.state === 'failed') return true;
|
||||
return isCommunityUpdateStale(communityState.updatedAt, nowSeconds);
|
||||
|
||||
const isStale = isCommunityUpdateStale(communityState.updatedAt, nowSeconds);
|
||||
if (communityState.updatedAt !== undefined && !isStale) return false;
|
||||
if (isStale) return true;
|
||||
if (isCommunitySyncLoading(communityState.syncState)) return false;
|
||||
if (isCommunitySyncTerminal(communityState.syncState) || communityState.state === 'failed') return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -56,25 +56,22 @@ describe('ui state stores', () => {
|
||||
expect(store.getState().communityOfflineState['music-posting.eth']).toEqual({ initialLoad: true });
|
||||
|
||||
store.getState().setCommunityOfflineState('music-posting.eth', {
|
||||
state: 'offline',
|
||||
state: 'succeeded',
|
||||
updatedAt: 123,
|
||||
updatingState: 'recovering',
|
||||
});
|
||||
|
||||
expect(store.getState().communityOfflineState['music-posting.eth']).toEqual({
|
||||
initialLoad: true,
|
||||
state: 'offline',
|
||||
state: 'succeeded',
|
||||
updatedAt: 123,
|
||||
updatingState: 'recovering',
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(30_000);
|
||||
|
||||
expect(store.getState().communityOfflineState['music-posting.eth']).toEqual({
|
||||
initialLoad: false,
|
||||
state: 'offline',
|
||||
state: 'succeeded',
|
||||
updatedAt: 123,
|
||||
updatingState: 'recovering',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { create } from 'zustand';
|
||||
interface CommunityOfflineState {
|
||||
state?: string;
|
||||
updatedAt?: number;
|
||||
updatingState?: string;
|
||||
initialLoad: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import communitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
|
||||
import communitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages/index.js';
|
||||
import Board, { type BoardProps } from '../board';
|
||||
import { TRASH_BOARD_ADDRESS, TRASH_BOARD_TITLE } from '../../../lib/special-boards';
|
||||
import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../../../lib/utils/time-filter-utils';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { CommunitySyncState } from '@bitsocial/bitsocial-react-hooks';
|
||||
import Directory from '../directory';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
@@ -10,7 +11,7 @@ const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
boardIdentifier: 'a' as string | undefined,
|
||||
communities: {} as Record<string, { address: string; name?: string; state?: string; updatedAt?: number }>,
|
||||
communities: {} as Record<string, { address: string; name?: string; state?: string; syncState?: CommunitySyncState; hasCachedData?: boolean; updatedAt?: number }>,
|
||||
communityIdentifierRequests: [] as Array<string | undefined>,
|
||||
directoryListLoading: false,
|
||||
directoryBoards: [
|
||||
@@ -163,7 +164,9 @@ const createDirectoryBoard = (address: string, score = 12) => ({
|
||||
const createCommunity = (address: string, updatedAt = testState.nowSeconds - 60) => ({
|
||||
address,
|
||||
name: address,
|
||||
state: 'started',
|
||||
state: 'succeeded',
|
||||
syncState: 'succeeded' as const,
|
||||
hasCachedData: true,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
@@ -178,7 +181,9 @@ describe('Directory', () => {
|
||||
'anime-and-manga.bso': {
|
||||
address: 'anime-and-manga.bso',
|
||||
name: 'anime-and-manga.bso',
|
||||
state: 'started',
|
||||
state: 'succeeded',
|
||||
syncState: 'succeeded',
|
||||
hasCachedData: true,
|
||||
updatedAt: testState.nowSeconds - 60,
|
||||
},
|
||||
};
|
||||
@@ -273,7 +278,9 @@ describe('Directory', () => {
|
||||
testState.communities['anime-and-manga.bso'] = {
|
||||
address: 'anime-and-manga.bso',
|
||||
name: 'anime-and-manga.bso',
|
||||
state: 'started',
|
||||
state: 'succeeded',
|
||||
syncState: 'stopped',
|
||||
hasCachedData: true,
|
||||
updatedAt: testState.nowSeconds - 31 * 60,
|
||||
};
|
||||
|
||||
@@ -282,6 +289,28 @@ describe('Directory', () => {
|
||||
expect(getDirectoryRow()?.textContent).toContain('offline');
|
||||
});
|
||||
|
||||
it('keeps a stale listed board offline while synchronization retries', async () => {
|
||||
testState.communities['anime-and-manga.bso'] = {
|
||||
address: 'anime-and-manga.bso',
|
||||
name: 'anime-and-manga.bso',
|
||||
state: 'succeeded',
|
||||
syncState: 'loading',
|
||||
hasCachedData: true,
|
||||
updatedAt: testState.nowSeconds - 31 * 60,
|
||||
};
|
||||
testState.offlineHookValue = {
|
||||
isOffline: false,
|
||||
isOnlineStatusLoading: true,
|
||||
offlineIconClass: 'yellowOfflineIcon',
|
||||
offlineTitle: 'downloading board...',
|
||||
};
|
||||
|
||||
await renderDirectory();
|
||||
|
||||
expect(getDirectoryRow()?.textContent).toContain('offline');
|
||||
expect(getDirectoryRow()?.textContent).not.toContain('loading');
|
||||
});
|
||||
|
||||
it('does not request status checks after the top five boards', async () => {
|
||||
const boards = Array.from({ length: 6 }, (_, index) => createDirectoryBoard(`board-${index + 1}.bso`, 100 - index));
|
||||
testState.directoryBoards = boards;
|
||||
|
||||
@@ -168,8 +168,7 @@
|
||||
|
||||
.statusLoading {
|
||||
display: block;
|
||||
width: fit-content;
|
||||
margin-inline: auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ownerName {
|
||||
|
||||
@@ -33,6 +33,7 @@ const computeBoardStatus = (
|
||||
): 'online' | 'offline' | 'loading' | 'unknown' => {
|
||||
const freshnessState = {
|
||||
state: communityState?.state ?? offlineState?.state,
|
||||
syncState: communityState?.syncState,
|
||||
updatedAt: communityState?.updatedAt ?? offlineState?.updatedAt,
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ __metadata:
|
||||
resolution: "5chan@workspace:."
|
||||
dependencies:
|
||||
"@bbob/parser": "npm:4.3.1"
|
||||
"@bitsocial/bitsocial-react-hooks": "npm:0.1.30"
|
||||
"@bitsocial/bitsocial-react-hooks": "npm:0.1.31"
|
||||
"@bitsocial/bso-resolver": "npm:0.0.10"
|
||||
"@capacitor/android": "npm:7.4.5"
|
||||
"@capacitor/app": "npm:7.0.1"
|
||||
@@ -26,7 +26,7 @@ __metadata:
|
||||
"@electron-forge/maker-zip": "npm:7.8.0"
|
||||
"@electron/rebuild": "npm:3.7.2"
|
||||
"@floating-ui/react": "npm:0.26.1"
|
||||
"@pkcprotocol/pkc-js": "npm:0.0.71"
|
||||
"@pkcprotocol/pkc-js": "npm:0.0.72"
|
||||
"@react-spring/web": "npm:10.0.3"
|
||||
"@reforged/maker-appimage": "npm:5.1.1"
|
||||
"@ruffle-rs/ruffle": "npm:0.2.0"
|
||||
@@ -1581,28 +1581,28 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@bitsocial/bitsocial-react-hooks@npm:0.1.30":
|
||||
version: 0.1.30
|
||||
resolution: "@bitsocial/bitsocial-react-hooks@npm:0.1.30"
|
||||
"@bitsocial/bitsocial-react-hooks@npm:0.1.31":
|
||||
version: 0.1.31
|
||||
resolution: "@bitsocial/bitsocial-react-hooks@npm:0.1.31"
|
||||
dependencies:
|
||||
"@bitsocial/bso-resolver": "npm:0.0.10"
|
||||
"@pkcprotocol/pkc-js": "npm:0.0.71"
|
||||
"@pkcprotocol/pkc-js": "npm:0.0.72"
|
||||
"@pkcprotocol/pkc-logger": "npm:0.1.0"
|
||||
assert: "npm:2.0.0"
|
||||
assert: "npm:2.1.0"
|
||||
ethers: "npm:5.8.0"
|
||||
localforage: "npm:1.10.0"
|
||||
lodash.isequal: "npm:4.5.0"
|
||||
memoizee: "npm:0.4.15"
|
||||
memoizee: "npm:0.4.17"
|
||||
multiformats: "npm:13.4.2"
|
||||
peer-id: "npm:0.16.0"
|
||||
quick-lru: "npm:5.1.1"
|
||||
uint8arrays: "npm:3.1.1"
|
||||
uuid: "npm:14.0.0"
|
||||
viem: "npm:2.45.0"
|
||||
zustand: "npm:4.0.0"
|
||||
uuid: "npm:14.0.1"
|
||||
viem: "npm:2.55.8"
|
||||
zustand: "npm:4.5.7"
|
||||
peerDependencies:
|
||||
react: ">=16.8"
|
||||
checksum: 10c0/da3b6d4f0bc304228dd0208e39e375c8cd16d2f11454319456a7d30c64bd8c6296dcbf867c008abd8ddd087a2091302742d539f705f4d93ef9682bc0c0453d20
|
||||
checksum: 10c0/3bb68ebca7008dde97a93b04f745e427dc57a8eabaeeddff00bc3517fae34873d2b8e747311f2e4a3bc0b131955a1afb9f9a86f8d2c693260aebe6d0234b03ba
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -6502,9 +6502,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@pkcprotocol/pkc-js@npm:0.0.71":
|
||||
version: 0.0.71
|
||||
resolution: "@pkcprotocol/pkc-js@npm:0.0.71"
|
||||
"@pkcprotocol/pkc-js@npm:0.0.72":
|
||||
version: 0.0.72
|
||||
resolution: "@pkcprotocol/pkc-js@npm:0.0.72"
|
||||
dependencies:
|
||||
"@enhances/with-resolvers": "npm:0.0.5"
|
||||
"@helia/block-brokers": "npm:5.2.4"
|
||||
@@ -6565,7 +6565,7 @@ __metadata:
|
||||
uuid: "npm:13.0.0"
|
||||
ws: "npm:8.20.0"
|
||||
zod: "npm:4.3.6"
|
||||
checksum: 10c0/3f9823b9977ca45d91819f645f7b255217944a4a648ebfc53ab45da7e30f76ed8530092d4e34616960f8ec36da2a2e2fda4a19edec9d24b5a12f79b5e42d36f2
|
||||
checksum: 10c0/b463c86257563480d570bee0a375eb0eb6c8883789c207ed11f56527303fa19e602413f4a9d8783ae14ccd518dc02a6ef921446a81febf299aa5c9f00b608824
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -8266,18 +8266,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"assert@npm:2.0.0":
|
||||
version: 2.0.0
|
||||
resolution: "assert@npm:2.0.0"
|
||||
dependencies:
|
||||
es6-object-assign: "npm:^1.1.0"
|
||||
is-nan: "npm:^1.2.1"
|
||||
object-is: "npm:^1.0.1"
|
||||
util: "npm:^0.12.0"
|
||||
checksum: 10c0/a25c7ebc07b52cc4dadd5c46d73472e7d4b86e40eb7ebaa12f78c1ba954dbe83612be5dea314b862fc364c305ab3bdbcd1c9d4ec2d92bc37214ae7d5596347f3
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"assert@npm:2.1.0":
|
||||
version: 2.1.0
|
||||
resolution: "assert@npm:2.1.0"
|
||||
@@ -10843,13 +10831,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"es6-object-assign@npm:^1.1.0":
|
||||
version: 1.1.0
|
||||
resolution: "es6-object-assign@npm:1.1.0"
|
||||
checksum: 10c0/11c165ae16866aca897dee9b689402f0e871589e859809343ef9e0fdd067133684db16fd15abdba2a99e7319222b9f43e6b747baabb909cee9d0ecbac8deebee
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"es6-symbol@npm:^3.1.1, es6-symbol@npm:^3.1.3":
|
||||
version: 3.1.4
|
||||
resolution: "es6-symbol@npm:3.1.4"
|
||||
@@ -13357,7 +13338,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"is-nan@npm:^1.2.1, is-nan@npm:^1.3.2":
|
||||
"is-nan@npm:^1.3.2":
|
||||
version: 1.3.2
|
||||
resolution: "is-nan@npm:1.3.2"
|
||||
dependencies:
|
||||
@@ -15341,6 +15322,22 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"memoizee@npm:0.4.17":
|
||||
version: 0.4.17
|
||||
resolution: "memoizee@npm:0.4.17"
|
||||
dependencies:
|
||||
d: "npm:^1.0.2"
|
||||
es5-ext: "npm:^0.10.64"
|
||||
es6-weak-map: "npm:^2.0.3"
|
||||
event-emitter: "npm:^0.3.5"
|
||||
is-promise: "npm:^2.2.2"
|
||||
lru-queue: "npm:^0.1.0"
|
||||
next-tick: "npm:^1.1.0"
|
||||
timers-ext: "npm:^0.1.7"
|
||||
checksum: 10c0/19821d055f0f641e79b718f91d6d89a6c92840643234a6f4e91d42aa330e8406f06c47d3828931e177c38830aa9b959710e5b7f0013be452af46d0f9eae4baf4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"meow@npm:^12.0.1":
|
||||
version: 12.1.1
|
||||
resolution: "meow@npm:12.1.1"
|
||||
@@ -16216,7 +16213,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"object-is@npm:^1.0.1, object-is@npm:^1.1.5":
|
||||
"object-is@npm:^1.1.5":
|
||||
version: 1.1.6
|
||||
resolution: "object-is@npm:1.1.6"
|
||||
dependencies:
|
||||
@@ -16357,27 +16354,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ox@npm:0.11.3":
|
||||
version: 0.11.3
|
||||
resolution: "ox@npm:0.11.3"
|
||||
dependencies:
|
||||
"@adraffy/ens-normalize": "npm:^1.11.0"
|
||||
"@noble/ciphers": "npm:^1.3.0"
|
||||
"@noble/curves": "npm:1.9.1"
|
||||
"@noble/hashes": "npm:^1.8.0"
|
||||
"@scure/bip32": "npm:^1.7.0"
|
||||
"@scure/bip39": "npm:^1.6.0"
|
||||
abitype: "npm:^1.2.3"
|
||||
eventemitter3: "npm:5.0.1"
|
||||
peerDependencies:
|
||||
typescript: ">=5.4.0"
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
checksum: 10c0/aab488bb5ff2e9c9d688f4044ebceb5efc4f62e71c19c2813d1ba93509dfcf618c7c97dd06ed867843340ac9744911ef3f3a4504850711a67f220f94aa9d8feb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ox@npm:0.14.30":
|
||||
version: 0.14.30
|
||||
resolution: "ox@npm:0.14.30"
|
||||
@@ -16399,6 +16375,27 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ox@npm:0.14.32":
|
||||
version: 0.14.32
|
||||
resolution: "ox@npm:0.14.32"
|
||||
dependencies:
|
||||
"@adraffy/ens-normalize": "npm:^1.11.0"
|
||||
"@noble/ciphers": "npm:^1.3.0"
|
||||
"@noble/curves": "npm:1.9.1"
|
||||
"@noble/hashes": "npm:^1.8.0"
|
||||
"@scure/bip32": "npm:^1.7.0"
|
||||
"@scure/bip39": "npm:^1.6.0"
|
||||
abitype: "npm:^1.2.3"
|
||||
eventemitter3: "npm:5.0.1"
|
||||
peerDependencies:
|
||||
typescript: ">=5.4.0"
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
checksum: 10c0/e07c0b18f967f93bf9b52dd5f00cd72db38be435f06cb16e7852c08cc6f05730c031a3ae5dbe058682160ec25b8a8664df334e28fe0677f7848abd0b9a9b59f0
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"oxc-parser@npm:^0.133.0":
|
||||
version: 0.133.0
|
||||
resolution: "oxc-parser@npm:0.133.0"
|
||||
@@ -20814,7 +20811,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"util@npm:^0.12.0, util@npm:^0.12.5":
|
||||
"util@npm:^0.12.5":
|
||||
version: 0.12.5
|
||||
resolution: "util@npm:0.12.5"
|
||||
dependencies:
|
||||
@@ -20836,6 +20833,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"uuid@npm:14.0.1":
|
||||
version: 14.0.1
|
||||
resolution: "uuid@npm:14.0.1"
|
||||
bin:
|
||||
uuid: dist-node/bin/uuid
|
||||
checksum: 10c0/2d961289097cb68c37d93d1da0b5c3aafc1eb9948a455cca8d0ae53a099b2fe583b8933254a84097f700e6bac2e23033364904df0467c22551d5d9611febcaf6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"validate-npm-package-license@npm:^3.0.1, validate-npm-package-license@npm:^3.0.4":
|
||||
version: 3.0.4
|
||||
resolution: "validate-npm-package-license@npm:3.0.4"
|
||||
@@ -20860,27 +20866,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"viem@npm:2.45.0":
|
||||
version: 2.45.0
|
||||
resolution: "viem@npm:2.45.0"
|
||||
dependencies:
|
||||
"@noble/curves": "npm:1.9.1"
|
||||
"@noble/hashes": "npm:1.8.0"
|
||||
"@scure/bip32": "npm:1.7.0"
|
||||
"@scure/bip39": "npm:1.6.0"
|
||||
abitype: "npm:1.2.3"
|
||||
isows: "npm:1.0.7"
|
||||
ox: "npm:0.11.3"
|
||||
ws: "npm:8.18.3"
|
||||
peerDependencies:
|
||||
typescript: ">=5.0.4"
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
checksum: 10c0/099c73d2980493a372d7c91d7593c62c9bb534a91293f6ae308a88292e16a4c89301db8899fec98597a9e6ee51f02bd9ed857f498946b2ee905c9cae00e5289d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"viem@npm:2.55.1":
|
||||
version: 2.55.1
|
||||
resolution: "viem@npm:2.55.1"
|
||||
@@ -20902,6 +20887,27 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"viem@npm:2.55.8":
|
||||
version: 2.55.8
|
||||
resolution: "viem@npm:2.55.8"
|
||||
dependencies:
|
||||
"@noble/curves": "npm:1.9.1"
|
||||
"@noble/hashes": "npm:1.8.0"
|
||||
"@scure/bip32": "npm:1.7.0"
|
||||
"@scure/bip39": "npm:1.6.0"
|
||||
abitype: "npm:1.2.3"
|
||||
isows: "npm:1.0.7"
|
||||
ox: "npm:0.14.32"
|
||||
ws: "npm:8.21.0"
|
||||
peerDependencies:
|
||||
typescript: ">=5.0.4"
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
checksum: 10c0/30457ab22c174e02680831f841cd27ba806346ea2099e6b091a53bb46f0bcd6406d93704ef1d91be0d21fd367e38fe81573a9a89a84906e469bdb9c12be7a0bb
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"vite-plugin-pwa@npm:1.2.0":
|
||||
version: 1.2.0
|
||||
resolution: "vite-plugin-pwa@npm:1.2.0"
|
||||
@@ -21840,23 +21846,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zustand@npm:4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "zustand@npm:4.0.0"
|
||||
dependencies:
|
||||
use-sync-external-store: "npm:1.2.0"
|
||||
peerDependencies:
|
||||
immer: ">=9.0"
|
||||
react: ">=16.8"
|
||||
peerDependenciesMeta:
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
checksum: 10c0/b56c85068499370082da1dce050ece55ffbdd44513f97f16ddd119a62198e8068571c92b57174bae8430a1ab637650322884094aee4ca43c9f6d88d60154d08a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zustand@npm:4.4.3":
|
||||
version: 4.4.3
|
||||
resolution: "zustand@npm:4.4.3"
|
||||
@@ -21876,3 +21865,23 @@ __metadata:
|
||||
checksum: 10c0/39a3d7928dbffd644ade553e2bd0a41c007d49e3f933156e21c02a8eaea2a39ef6c633bc1f4aa33dccecba12077e551f27a1138ba06f6d61b393225f214ded25
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zustand@npm:4.5.7":
|
||||
version: 4.5.7
|
||||
resolution: "zustand@npm:4.5.7"
|
||||
dependencies:
|
||||
use-sync-external-store: "npm:^1.2.2"
|
||||
peerDependencies:
|
||||
"@types/react": ">=16.8"
|
||||
immer: ">=9.0.6"
|
||||
react: ">=16.8"
|
||||
peerDependenciesMeta:
|
||||
"@types/react":
|
||||
optional: true
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
checksum: 10c0/55559e37a82f0c06cadc61cb08f08314c0fe05d6a93815e41e3376130c13db22a5017cbb0cd1f018c82f2dad0051afe3592561d40f980bd4082e32005e8a950c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
Reference in New Issue
Block a user