mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Add board directory view (#1132)
* feat(directory): add board directory view * fix(directory): populate board status * style(directory): tighten board table * docs(board manager): point board owners to manager * fix(directory): show loading status * style(directory): center board column in directory table * perf(directory): cap board status checks * style(directory): simplify board row links * test(ci): stabilize coverage run * test(ci): stabilize coverage harness * test(ci): avoid async app flush act * test(app): narrow layout harness coverage * test(ci): stabilize app update distribution mock * test(ci): preload app harness before route tests * fix(directory): address final review findings
This commit is contained in:
@@ -124,7 +124,7 @@ describe('useIsCommunityOffline', () => {
|
||||
});
|
||||
|
||||
it('reports boards with stale updates as offline and includes the last synced time', async () => {
|
||||
const staleUpdatedAt = 1_704_052_000;
|
||||
const staleUpdatedAt = 1_704_067_210 - 31 * 60;
|
||||
testState.communityOfflineState = {
|
||||
'music.eth': {
|
||||
initialLoad: false,
|
||||
@@ -144,6 +144,50 @@ describe('useIsCommunityOffline', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('treats boards updated less than 30 minutes ago as online', async () => {
|
||||
const freshUpdatedAt = 1_704_067_210 - 29 * 60;
|
||||
testState.communityOfflineState = {
|
||||
'music.eth': {
|
||||
initialLoad: false,
|
||||
updatedAt: freshUpdatedAt,
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook({ address: 'music.eth', state: 'started', updatedAt: freshUpdatedAt });
|
||||
|
||||
expect(latestValue).toEqual({
|
||||
isOffline: false,
|
||||
isOnlineStatusLoading: false,
|
||||
offlineIconClass: '',
|
||||
offlineTitle: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('updates mounted boards when their last update crosses the offline threshold', async () => {
|
||||
const freshUpdatedAt = 1_704_067_210 - 29 * 60;
|
||||
testState.communityOfflineState = {
|
||||
'music.eth': {
|
||||
initialLoad: false,
|
||||
updatedAt: freshUpdatedAt,
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook({ address: 'music.eth', state: 'started', updatedAt: freshUpdatedAt });
|
||||
|
||||
expect(latestValue.isOffline).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2 * 60 * 1000);
|
||||
});
|
||||
|
||||
expect(latestValue).toEqual({
|
||||
isOffline: true,
|
||||
isOnlineStatusLoading: false,
|
||||
offlineIconClass: 'redOfflineIcon',
|
||||
offlineTitle: `posts_last_synced_info:ago:${freshUpdatedAt}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks boards without an update timestamp as offline once the loading timeout has elapsed', async () => {
|
||||
testState.communityOfflineState = {
|
||||
'music.eth': {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
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 { useResolvedCommunityAddress } from '../use-resolved-community-address';
|
||||
|
||||
(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>;
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
boardIdentifier: 'biz',
|
||||
directories: [
|
||||
{
|
||||
address: 'business-and-finance.bso',
|
||||
directoryCode: 'biz',
|
||||
title: '/biz/ - Business & Finance',
|
||||
},
|
||||
],
|
||||
list: {
|
||||
directoryCode: 'biz',
|
||||
boards: [
|
||||
{ address: 'business-and-finance.bso', score: 100, managedByDevs: true },
|
||||
{ address: 'backup-business.bso', score: 10, managedByDevs: false },
|
||||
],
|
||||
},
|
||||
offlineStates: {} as Record<string, { updatedAt?: number; state?: string }>,
|
||||
offlineSelections: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useParams: () => ({ boardIdentifier: testState.boardIdentifier }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../use-directories', () => ({
|
||||
useDirectories: () => testState.directories,
|
||||
}));
|
||||
|
||||
vi.mock('../use-directory-list', async () => {
|
||||
const actual = await vi.importActual<typeof import('../use-directory-list')>('../use-directory-list');
|
||||
return {
|
||||
...actual,
|
||||
useDirectoryList: () => ({ list: testState.list, loading: false, error: null }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../stores/use-community-offline-store', () => ({
|
||||
default: <T,>(selector: (state: { communityOfflineState: typeof testState.offlineStates }) => T) => {
|
||||
const selected = selector({ communityOfflineState: testState.offlineStates });
|
||||
testState.offlineSelections.push(selected);
|
||||
return selected;
|
||||
},
|
||||
}));
|
||||
|
||||
let latestValue: string | undefined;
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const HookHarness = () => {
|
||||
latestValue = useResolvedCommunityAddress();
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderHook = async () => {
|
||||
await act(async () => {
|
||||
root.render(createElement(HookHarness));
|
||||
});
|
||||
};
|
||||
|
||||
describe('useResolvedCommunityAddress', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T00:00:10Z'));
|
||||
latestValue = undefined;
|
||||
testState.boardIdentifier = 'biz';
|
||||
testState.offlineStates = {};
|
||||
testState.offlineSelections = [];
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('skips a higher-ranked directory board when its last update is 30 minutes stale', async () => {
|
||||
testState.offlineStates = {
|
||||
'business-and-finance.bso': {
|
||||
updatedAt: 1_704_067_210 - 31 * 60,
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook();
|
||||
|
||||
expect(latestValue).toBe('backup-business.bso');
|
||||
});
|
||||
|
||||
it('keeps a higher-ranked directory board when its last update is newer than 30 minutes', async () => {
|
||||
testState.offlineStates = {
|
||||
'business-and-finance.bso': {
|
||||
updatedAt: 1_704_067_210 - 29 * 60,
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook();
|
||||
|
||||
expect(latestValue).toBe('business-and-finance.bso');
|
||||
});
|
||||
|
||||
it('does not subscribe to offline state on non-directory board routes', async () => {
|
||||
testState.boardIdentifier = 'custom-board.bso';
|
||||
testState.offlineStates = {
|
||||
unrelated: {
|
||||
updatedAt: 1,
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook();
|
||||
|
||||
expect(latestValue).toBe('custom-board.bso');
|
||||
expect(testState.offlineSelections).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it('switches away from a directory board when it crosses the offline threshold while mounted', async () => {
|
||||
testState.offlineStates = {
|
||||
'business-and-finance.bso': {
|
||||
updatedAt: 1_704_067_210 - 29 * 60,
|
||||
},
|
||||
};
|
||||
|
||||
await renderHook();
|
||||
|
||||
expect(latestValue).toBe('business-and-finance.bso');
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2 * 60 * 1000);
|
||||
});
|
||||
|
||||
expect(latestValue).toBe('backup-business.bso');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { DirectoryCommunity, useDirectories } from './use-directories';
|
||||
|
||||
export interface DirectoryListBoard {
|
||||
address: string;
|
||||
publicKey?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
owner?: string;
|
||||
score: number;
|
||||
managedByDevs: boolean;
|
||||
addedAt?: number;
|
||||
}
|
||||
|
||||
interface DirectoryList {
|
||||
directoryCode: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
boards: DirectoryListBoard[];
|
||||
}
|
||||
|
||||
interface DirectoryListState {
|
||||
list: DirectoryList | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
const GITHUB_URL_TEMPLATE = 'https://raw.githubusercontent.com/bitsocialnet/lists/master/5chan-{code}-directory.json';
|
||||
const LOCALSTORAGE_KEY_PREFIX = '5chan-directory-list-cache:';
|
||||
const LOCALSTORAGE_TIMESTAMP_KEY_PREFIX = '5chan-directory-list-cache-timestamp:';
|
||||
const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
|
||||
const FETCH_RETRY_DELAY_MS = 60 * 1000; // 1 minute
|
||||
const FETCH_TIMEOUT_MS = 10 * 1000;
|
||||
|
||||
// Per-code module caches keyed by directory code (e.g. 'biz').
|
||||
const moduleCaches = new Map<string, DirectoryList>();
|
||||
const inFlightFetches = new Map<string, Promise<DirectoryList | null>>();
|
||||
const lastFetchSuccessAt = new Map<string, number>();
|
||||
const lastFetchAttemptAt = new Map<string, number>();
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null;
|
||||
|
||||
const toNumber = (value: unknown): number | undefined => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
|
||||
|
||||
const toString = (value: unknown): string | undefined => (typeof value === 'string' && value.length > 0 ? value : undefined);
|
||||
|
||||
const toBool = (value: unknown): boolean => value === true;
|
||||
|
||||
const normalizeBoard = (raw: unknown): DirectoryListBoard | null => {
|
||||
if (!isRecord(raw)) return null;
|
||||
const address = toString(raw.address) ?? toString(raw.name);
|
||||
if (!address) return null;
|
||||
|
||||
return {
|
||||
address,
|
||||
...(toString(raw.publicKey) ? { publicKey: toString(raw.publicKey)! } : {}),
|
||||
...(toString(raw.title) ? { title: toString(raw.title)! } : {}),
|
||||
...(toString(raw.description) ? { description: toString(raw.description)! } : {}),
|
||||
...(toString(raw.owner) ? { owner: toString(raw.owner)! } : {}),
|
||||
score: toNumber(raw.score) ?? 0,
|
||||
managedByDevs: toBool(raw.managedByDevs),
|
||||
...(toNumber(raw.addedAt) !== undefined ? { addedAt: toNumber(raw.addedAt) } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeDirectoryList = (raw: unknown, fallbackCode: string): DirectoryList | null => {
|
||||
if (!isRecord(raw)) return null;
|
||||
const boardsRaw = Array.isArray(raw.boards) ? raw.boards : Array.isArray(raw.communities) ? raw.communities : null;
|
||||
if (!boardsRaw) return null;
|
||||
|
||||
const boards = boardsRaw.map(normalizeBoard).filter((board): board is DirectoryListBoard => board !== null);
|
||||
if (boards.length === 0) return null;
|
||||
|
||||
return {
|
||||
directoryCode: toString(raw.directoryCode) ?? fallbackCode,
|
||||
...(toString(raw.title) ? { title: toString(raw.title)! } : {}),
|
||||
...(toString(raw.description) ? { description: toString(raw.description)! } : {}),
|
||||
...(toNumber(raw.createdAt) !== undefined ? { createdAt: toNumber(raw.createdAt) } : {}),
|
||||
...(toNumber(raw.updatedAt) !== undefined ? { updatedAt: toNumber(raw.updatedAt) } : {}),
|
||||
boards,
|
||||
};
|
||||
};
|
||||
|
||||
const synthesizeFromMainDirectory = (directoryCode: string, directories: DirectoryCommunity[]): DirectoryList | null => {
|
||||
const match = directories.find((community) => community.directoryCode === directoryCode);
|
||||
if (!match || !match.address) return null;
|
||||
|
||||
const board: DirectoryListBoard = {
|
||||
address: match.address,
|
||||
...(match.publicKey ? { publicKey: match.publicKey } : {}),
|
||||
...(match.title ? { title: match.title } : {}),
|
||||
score: 0,
|
||||
managedByDevs: true,
|
||||
};
|
||||
|
||||
return {
|
||||
directoryCode,
|
||||
...(match.title ? { title: match.title } : {}),
|
||||
boards: [board],
|
||||
};
|
||||
};
|
||||
|
||||
const getLocalStorageKey = (code: string) => `${LOCALSTORAGE_KEY_PREFIX}${code}`;
|
||||
const getLocalStorageTimestampKey = (code: string) => `${LOCALSTORAGE_TIMESTAMP_KEY_PREFIX}${code}`;
|
||||
|
||||
const getFromLocalStorage = (code: string): DirectoryList | null => {
|
||||
try {
|
||||
const cached = localStorage.getItem(getLocalStorageKey(code));
|
||||
const timestamp = localStorage.getItem(getLocalStorageTimestampKey(code));
|
||||
if (cached && timestamp) {
|
||||
const age = Date.now() - parseInt(timestamp, 10);
|
||||
if (age < CACHE_MAX_AGE_MS) {
|
||||
const parsed = JSON.parse(cached);
|
||||
const normalized = normalizeDirectoryList(parsed, code);
|
||||
if (normalized) return normalized;
|
||||
localStorage.removeItem(getLocalStorageKey(code));
|
||||
localStorage.removeItem(getLocalStorageTimestampKey(code));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to read directory list "${code}" from localStorage:`, e);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const saveToLocalStorage = (code: string, data: DirectoryList) => {
|
||||
try {
|
||||
localStorage.setItem(getLocalStorageKey(code), JSON.stringify(data));
|
||||
localStorage.setItem(getLocalStorageTimestampKey(code), Date.now().toString());
|
||||
} catch (e) {
|
||||
console.warn(`Failed to save directory list "${code}" to localStorage:`, e);
|
||||
}
|
||||
};
|
||||
|
||||
const shouldRefreshFromGitHub = (code: string): boolean => {
|
||||
const now = Date.now();
|
||||
const lastSuccess = lastFetchSuccessAt.get(code);
|
||||
if (lastSuccess !== undefined && now - lastSuccess < CACHE_MAX_AGE_MS) {
|
||||
return false;
|
||||
}
|
||||
const lastAttempt = lastFetchAttemptAt.get(code);
|
||||
if (lastAttempt !== undefined && now - lastAttempt < FETCH_RETRY_DELAY_MS) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const fetchDirectoryListFromGitHub = async (code: string): Promise<DirectoryList | null> => {
|
||||
const url = GITHUB_URL_TEMPLATE.replace('{code}', code);
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, { cache: 'no-cache', signal: controller.signal });
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
if (response.status === 404) {
|
||||
// Not yet published; treat as missing — caller will fall back.
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const normalized = normalizeDirectoryList(await response.json(), code);
|
||||
if (!normalized) {
|
||||
throw new Error(`Invalid directory list payload for ${code}`);
|
||||
}
|
||||
moduleCaches.set(code, normalized);
|
||||
lastFetchSuccessAt.set(code, Date.now());
|
||||
saveToLocalStorage(code, normalized);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const fetchDirectoryListDeduped = (code: string): Promise<DirectoryList | null> => {
|
||||
const existing = inFlightFetches.get(code);
|
||||
if (existing) return existing;
|
||||
|
||||
if (!shouldRefreshFromGitHub(code)) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
lastFetchAttemptAt.set(code, Date.now());
|
||||
const promise = fetchDirectoryListFromGitHub(code).finally(() => {
|
||||
inFlightFetches.delete(code);
|
||||
});
|
||||
inFlightFetches.set(code, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the candidate boards for a single directory code (e.g. 'biz').
|
||||
*
|
||||
* Source: `bitsocialnet/lists/5chan-{code}-directory.json`. When the network is unavailable
|
||||
* or the file is not yet published, falls back to a synthesized single-entry list derived
|
||||
* from the main 5chan-directories.json (the dev-managed default).
|
||||
*/
|
||||
export const useDirectoryList = (directoryCode: string | undefined): DirectoryListState => {
|
||||
const directories = useDirectories();
|
||||
const fallback = useMemo(() => (directoryCode ? synthesizeFromMainDirectory(directoryCode, directories) : null), [directoryCode, directories]);
|
||||
|
||||
const [state, setState] = useState<DirectoryListState>(() => {
|
||||
if (!directoryCode) {
|
||||
return { list: null, loading: false, error: null };
|
||||
}
|
||||
const cached = moduleCaches.get(directoryCode);
|
||||
if (cached) {
|
||||
return { list: cached, loading: false, error: null };
|
||||
}
|
||||
return { list: fallback, loading: true, error: null };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!directoryCode) {
|
||||
setState({ list: null, loading: false, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const hydrate = (list: DirectoryList) => {
|
||||
moduleCaches.set(directoryCode, list);
|
||||
if (isMounted) {
|
||||
setState({ list, loading: false, error: null });
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const cached = moduleCaches.get(directoryCode);
|
||||
if (cached) {
|
||||
setState({ list: cached, loading: false, error: null });
|
||||
} else {
|
||||
const local = getFromLocalStorage(directoryCode);
|
||||
if (local) {
|
||||
hydrate(local);
|
||||
} else if (fallback) {
|
||||
setState({ list: fallback, loading: true, error: null });
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fetched = await fetchDirectoryListDeduped(directoryCode);
|
||||
if (fetched) {
|
||||
hydrate(fetched);
|
||||
} else if (!moduleCaches.get(directoryCode) && fallback) {
|
||||
if (isMounted) {
|
||||
setState({ list: fallback, loading: false, error: null });
|
||||
}
|
||||
} else if (isMounted) {
|
||||
// Stop the loading indicator once the fetch resolves (even if it returned null).
|
||||
setState((prev) => ({ ...prev, loading: false }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch directory list "${directoryCode}":`, error);
|
||||
if (!isMounted) return;
|
||||
const cachedAfter = moduleCaches.get(directoryCode);
|
||||
if (cachedAfter) {
|
||||
setState({ list: cachedAfter, loading: false, error: null });
|
||||
} else if (fallback) {
|
||||
setState({ list: fallback, loading: false, error: error instanceof Error ? error : new Error(String(error)) });
|
||||
} else {
|
||||
setState({ list: null, loading: false, error: error instanceof Error ? error : new Error(String(error)) });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [directoryCode, fallback]);
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sort boards by score (desc). Ties break in favor of `managedByDevs`, then `addedAt` asc.
|
||||
*/
|
||||
export const sortDirectoryBoardsByRank = (boards: DirectoryListBoard[]): DirectoryListBoard[] =>
|
||||
[...boards].sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
if (a.managedByDevs !== b.managedByDevs) return a.managedByDevs ? -1 : 1;
|
||||
return (a.addedAt ?? 0) - (b.addedAt ?? 0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Pick the winning board for a directory, skipping any boards reported offline.
|
||||
* 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 => {
|
||||
const ranked = sortDirectoryBoardsByRank(boards);
|
||||
return ranked.find((board) => !isOffline(board.address)) ?? ranked[0];
|
||||
};
|
||||
@@ -4,6 +4,8 @@ import { Community } 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 { useNowSeconds } from './use-now-seconds';
|
||||
|
||||
const getCommunityOfflineKey = (community?: Community, communityAddressHint?: string) =>
|
||||
communityAddressHint || community?.address || community?.name || community?.publicKey;
|
||||
@@ -12,6 +14,7 @@ const useIsCommunityOffline = (community?: Community | undefined, communityAddre
|
||||
const { t } = useTranslation();
|
||||
const { state, updatedAt, updatingState } = community || {};
|
||||
const communityKey = getCommunityOfflineKey(community, communityAddressHint);
|
||||
const nowSeconds = useNowSeconds(!!communityKey);
|
||||
const { communityOfflineState, setCommunityOfflineState, initializeCommunityOfflineState } = useCommunityOfflineStore();
|
||||
const communitiesLoadingStartTimestamps = useCommunitiesLoadingStartTimestamps(communityKey ? [communityKey] : undefined);
|
||||
|
||||
@@ -33,10 +36,11 @@ const useIsCommunityOffline = (community?: Community | undefined, communityAddre
|
||||
|
||||
const offlineState = communityOfflineState[communityKey] || { initialLoad: true };
|
||||
const loadingStartTimestamp = communitiesLoadingStartTimestamps[0] || 0;
|
||||
const isLoading = offlineState.initialLoad && (!updatedAt || Date.now() / 1000 - updatedAt >= 120 * 120) && Date.now() / 1000 - loadingStartTimestamp < 30;
|
||||
const isOffline = !isLoading && ((updatedAt && updatedAt < Date.now() / 1000 - 120 * 120) || (!updatedAt && Date.now() / 1000 - loadingStartTimestamp >= 30));
|
||||
const isStale = isCommunityUpdateStale(updatedAt, nowSeconds);
|
||||
const isLoading = offlineState.initialLoad && (!updatedAt || isStale) && nowSeconds - loadingStartTimestamp < 30;
|
||||
const isOffline = !isLoading && (isStale || (!updatedAt && nowSeconds - loadingStartTimestamp >= 30));
|
||||
|
||||
const isOnline = updatedAt && Date.now() / 1000 - updatedAt < 120 * 120;
|
||||
const isOnline = updatedAt && !isStale;
|
||||
const offlineIconClass = isLoading ? 'yellowOfflineIcon' : isOffline ? 'redOfflineIcon' : '';
|
||||
|
||||
const offlineTitle = isLoading
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const STATUS_REFRESH_INTERVAL_MS = 30_000;
|
||||
|
||||
const getNowSeconds = () => Date.now() / 1000;
|
||||
|
||||
export const useNowSeconds = (enabled = true) => {
|
||||
const [nowSeconds, setNowSeconds] = useState(getNowSeconds);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const updateNow = () => setNowSeconds(getNowSeconds());
|
||||
updateNow();
|
||||
const interval = window.setInterval(updateNow, STATUS_REFRESH_INTERVAL_MS);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [enabled]);
|
||||
|
||||
return nowSeconds;
|
||||
};
|
||||
@@ -1,24 +1,37 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useDirectories } from './use-directories';
|
||||
import { getCommunityAddress, getBoardPath } from '../lib/utils/route-utils';
|
||||
import { pickDirectoryWinner, useDirectoryList } from './use-directory-list';
|
||||
import useCommunityOfflineStore from '../stores/use-community-offline-store';
|
||||
import { getCommunityAddress, getBoardPath, isDirectoryRoute } from '../lib/utils/route-utils';
|
||||
import { isCommunityKnownOffline } from '../lib/utils/community-freshness-utils';
|
||||
import { useNowSeconds } from './use-now-seconds';
|
||||
|
||||
/**
|
||||
* Resolve a board identifier from URL params to canonical community address.
|
||||
*
|
||||
* For directory codes (e.g. /biz) with a per-directory list of candidates, picks the
|
||||
* highest-ranked candidate that is not currently flagged offline. Falls back to the
|
||||
* vendored single-candidate default while the per-directory list is still loading.
|
||||
*/
|
||||
export const useResolvedCommunityAddress = (): string | undefined => {
|
||||
const params = useParams<{ boardIdentifier?: string }>();
|
||||
const directories = useDirectories();
|
||||
|
||||
const boardIdentifier = params.boardIdentifier;
|
||||
const isCode = !!boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
|
||||
const { list } = useDirectoryList(isCode ? boardIdentifier : undefined);
|
||||
const offlineStates = useCommunityOfflineStore((state) => (isCode ? state.communityOfflineState : undefined));
|
||||
const nowSeconds = useNowSeconds(isCode);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!boardIdentifier) {
|
||||
return undefined;
|
||||
if (!boardIdentifier) return undefined;
|
||||
if (isCode && list && list.boards.length > 0) {
|
||||
const isOffline = (address: string) => isCommunityKnownOffline(offlineStates?.[address], nowSeconds);
|
||||
const winner = pickDirectoryWinner(list.boards, isOffline);
|
||||
if (winner) return winner.address;
|
||||
}
|
||||
|
||||
return getCommunityAddress(boardIdentifier, directories);
|
||||
}, [boardIdentifier, directories]);
|
||||
}, [boardIdentifier, directories, isCode, list, offlineStates, nowSeconds]);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user