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:
Tommaso Casaburi
2026-07-24 16:36:09 +07:00
committed by GitHub
parent 047bb1d5ab
commit a91c5b588b
19 changed files with 594 additions and 170 deletions
+2 -2
View File
@@ -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",
+1 -2
View File
@@ -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 = {
+2 -2
View File
@@ -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 };
+18 -11
View File
@@ -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;
+100 -7
View File
@@ -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 -6
View File
@@ -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';
+2 -2
View File
@@ -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);
});
});
+15 -2
View File
@@ -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;
};
+3 -6
View File
@@ -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;
}
+1 -1
View File
@@ -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;
+1 -2
View File
@@ -168,8 +168,7 @@
.statusLoading {
display: block;
width: fit-content;
margin-inline: auto;
text-align: center;
}
.ownerName {
+1
View File
@@ -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,
};
+106 -97
View File
@@ -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