Merge branch 'codex/feature/homepage-stats-scope'

This commit is contained in:
Tommaso Casaburi
2026-06-03 14:48:16 +07:00
40 changed files with 420 additions and 181 deletions
@@ -6,6 +6,7 @@ const flushMicrotasks = async () => {
};
const loadBlotterVisibilityStore = async () => (await import('../use-blotter-visibility-store')).default;
const loadHomepageStatsOptionsStore = async () => (await import('../use-homepage-stats-options-store')).default;
const loadModQueueStore = async () => (await import('../use-mod-queue-store')).default;
const loadPopularThreadsOptionsStore = async () => (await import('../use-popular-threads-options-store')).default;
@@ -56,6 +57,17 @@ describe('persisted extra stores', () => {
expect(localStorage.getItem('showNsfwContentOnly')).toBe('true');
});
it('loads homepage stats scope from localStorage defaults and persists changes', async () => {
const useHomepageStatsOptionsStore = await loadHomepageStatsOptionsStore();
expect(useHomepageStatsOptionsStore.getState().statsScope).toBe('directory');
useHomepageStatsOptionsStore.getState().setStatsScope('all');
expect(useHomepageStatsOptionsStore.getState().statsScope).toBe('all');
expect(localStorage.getItem('5chan-homepage-stats-scope')).toBe('all');
});
it('migrates legacy mod queue storage and computes threshold seconds from the active unit', async () => {
localStorage.setItem(
'mod-queue-storage',
@@ -0,0 +1,22 @@
import { create } from 'zustand';
export type HomepageStatsScope = 'directory' | 'all';
const LOCALSTORAGE_KEY = '5chan-homepage-stats-scope';
const readInitialStatsScope = (): HomepageStatsScope => (localStorage.getItem(LOCALSTORAGE_KEY) === 'all' ? 'all' : 'directory');
interface HomepageStatsOptionsStore {
statsScope: HomepageStatsScope;
setStatsScope: (value: HomepageStatsScope) => void;
}
const useHomepageStatsOptionsStore = create<HomepageStatsOptionsStore>((set) => ({
statsScope: readInitialStatsScope(),
setStatsScope: (value) => {
set({ statsScope: value });
localStorage.setItem(LOCALSTORAGE_KEY, value);
},
}));
export default useHomepageStatsOptionsStore;
+78 -29
View File
@@ -15,9 +15,9 @@ const testState = vi.hoisted(() => ({
directoryAddresses: [] as string[],
directoryListCodes: [] as string[],
directoryListsByCode: {} as Record<string, { boards: Array<{ address: string; score: number; managedByDevs: boolean; addedAt?: number }> }>,
loadingStartTimestamps: [] as number[],
nowSeconds: 1_704_067_210,
navigateMock: vi.fn(),
setStatsScopeMock: vi.fn(),
statsScope: 'directory' as 'directory' | 'all',
communities: {} as Record<string, unknown>,
communityStats: {} as Record<string, { allPostCount?: number; weekActiveUserCount?: number; state?: string }>,
feedStateString: 'Downloading boards',
@@ -70,12 +70,12 @@ vi.mock('../../../hooks/use-communities-stats', () => ({
useCommunitiesStatsStore: (selector: (state: { communityStats: typeof testState.communityStats }) => unknown) => selector({ communityStats: testState.communityStats }),
}));
vi.mock('../../../stores/use-communities-loading-start-timestamps-store', () => ({
default: () => testState.loadingStartTimestamps,
}));
vi.mock('../../../hooks/use-now-seconds', () => ({
useNowSeconds: () => testState.nowSeconds,
vi.mock('../../../stores/use-homepage-stats-options-store', () => ({
default: (selector: (state: { statsScope: typeof testState.statsScope; setStatsScope: typeof testState.setStatsScopeMock }) => unknown) =>
selector({
statsScope: testState.statsScope,
setStatsScope: testState.setStatsScopeMock,
}),
}));
vi.mock('../../../hooks/use-state-string', () => ({
@@ -86,6 +86,11 @@ vi.mock('../../../components/loading-ellipsis', () => ({
default: ({ string }: { string: string }) => createElement('span', { 'data-testid': 'loading-ellipsis' }, string),
}));
vi.mock('../../../components/tooltip', () => ({
default: ({ content, children }: { content: React.ReactNode; children: React.ReactNode }) =>
createElement('span', { 'data-testid': 'tooltip', 'data-content': typeof content === 'string' ? content : undefined }, children),
}));
vi.mock('../../../stores/use-directory-modal-store', () => ({
default: () => ({
closeDirectoryModal: testState.closeDirectoryModalMock,
@@ -135,8 +140,6 @@ describe('Home', () => {
testState.directoryAddresses = ['music-posting.eth', 'tech-posting.eth'];
testState.directoryListCodes = [];
testState.directoryListsByCode = {};
testState.loadingStartTimestamps = [];
testState.nowSeconds = 1_704_067_210;
testState.communities = {
'music-posting.eth': { address: 'music-posting.eth' },
'tech-posting.eth': { address: 'tech-posting.eth' },
@@ -146,6 +149,10 @@ describe('Home', () => {
'tech-posting.eth': { allPostCount: 7, weekActiveUserCount: 5 },
};
testState.feedStateString = 'Downloading boards';
testState.statsScope = 'directory';
testState.setStatsScopeMock.mockImplementation((value: 'directory' | 'all') => {
testState.statsScope = value;
});
container = document.createElement('div');
document.body.appendChild(container);
@@ -160,47 +167,62 @@ describe('Home', () => {
it('renders the home view chrome, child sections, collectors, and aggregated stats', () => {
renderHome();
expect(vi.mocked(useFeedStateString)).not.toHaveBeenCalled();
expect(vi.mocked(useFeedStateString)).toHaveBeenCalledWith([]);
expect(document.title).toBe('5chan');
expect(container.querySelector('[data-testid="disclaimer-modal"]')?.textContent).toBe('disclaimer-modal');
expect(container.querySelector('[data-testid="directory-modal"]')?.textContent).toBe('directory-modal');
expect(container.querySelector('[data-testid="boards-list"]')?.textContent).toBe('boards:2');
expect(container.querySelector('[data-testid="popular-threads-box"]')?.textContent).toBe('popular:2:2');
expect(container.querySelectorAll('[data-testid="stats-collector"]')).toHaveLength(2);
expect(testState.directoryListCodes).toEqual([]);
expect(container.textContent).toContain('stats');
expect(container.textContent).not.toContain('loaded 2/2 boards p2p');
expect(container.querySelector('.yellowOfflineIcon')).toBeNull();
expect(container.querySelectorAll('[data-testid="loading-ellipsis"]')).toHaveLength(0);
expect(container.textContent).toContain('total_posts 12');
expect(container.textContent).toContain('current_users 7');
expect(container.textContent).toContain('boards_tracked 2');
expect(container.textContent).toContain('boards_loaded 2');
expect(container.querySelector('[data-testid="site-legal-meta"]')?.textContent).toBe('site-legal-meta');
expect(container.querySelector<HTMLAnchorElement>('a[href="/pass"]')?.textContent).toBe('support_5chan');
});
it('keeps the stats values loading until every directory has loaded stats', () => {
it('shows the stats state string while no board stats have loaded yet', () => {
testState.communityStats = {};
renderHome();
expect(vi.mocked(useFeedStateString)).toHaveBeenCalledWith(['music-posting.eth', 'tech-posting.eth']);
expect(container.querySelector('.yellowOfflineIcon')).not.toBeNull();
expect(container.querySelector('[data-testid="tooltip"]')?.getAttribute('data-content')).toBe('Downloading boards');
expect(container.querySelector('[data-testid="loading-ellipsis"]')?.textContent).toBe('Downloading boards');
expect(container.textContent).not.toContain('total_posts');
expect(container.textContent).not.toContain('boards_loaded');
});
it('shows partial stats while some boards are still loading', () => {
testState.communityStats = {
'music-posting.eth': { allPostCount: 5, weekActiveUserCount: 2 },
};
renderHome();
const loadingValues = Array.from(container.querySelectorAll('[data-testid="loading-ellipsis"]'));
expect(loadingValues).toHaveLength(1);
expect(loadingValues[0]?.textContent).toBe('Downloading boards');
expect(vi.mocked(useFeedStateString)).toHaveBeenCalledWith(['music-posting.eth', 'tech-posting.eth']);
expect(container.textContent).not.toContain('total_posts');
expect(container.textContent).not.toContain('current_users');
expect(container.textContent).not.toContain('boards_tracked');
expect(container.textContent).not.toContain('total_posts 5');
expect(container.textContent).not.toContain('current_users 2');
expect(container.textContent).not.toContain('boards_tracked 1');
expect(container.querySelector('.yellowOfflineIcon')).not.toBeNull();
expect(container.querySelector('[data-testid="tooltip"]')?.getAttribute('data-content')).toBe('Downloading boards');
expect(container.querySelectorAll('[data-testid="loading-ellipsis"]')).toHaveLength(0);
expect(container.textContent).not.toContain('loaded 1/2 boards p2p');
expect(container.textContent).toContain('total_posts 5');
expect(container.textContent).toContain('current_users 2');
expect(container.textContent).toContain('boards_loaded 1');
});
it('uses a ranked directory fallback board when the default board stats stay unresolved', () => {
it('loads every listed board when the slow stats scope is selected', () => {
testState.statsScope = 'all';
testState.directories = [
{ address: 'business-and-finance.bso', title: '/biz/ - Business & Finance', directoryCode: 'biz' },
{ address: 'tech-posting.eth', title: '/g/ - Technology', directoryCode: 'g' },
];
testState.directoryAddresses = ['business-and-finance.bso', 'tech-posting.eth'];
testState.loadingStartTimestamps = [1_704_067_170, 1_704_067_170];
testState.directoryListsByCode = {
biz: {
boards: [
@@ -217,12 +239,15 @@ describe('Home', () => {
renderHome();
const collectorAddresses = Array.from(container.querySelectorAll('[data-testid="stats-collector"]')).map((collector) => collector.getAttribute('data-address'));
expect(testState.directoryListCodes).toEqual(['biz']);
expect(collectorAddresses).toEqual(['business-and-finance.bso', 'tech-posting.eth', 'backup-business.bso']);
expect(testState.directoryListCodes).toEqual(['biz', 'g']);
expect(collectorAddresses).toEqual(['business-and-finance.bso', 'backup-business.bso', 'tech-posting.eth']);
expect(vi.mocked(useFeedStateString)).toHaveBeenCalledWith(['business-and-finance.bso', 'backup-business.bso', 'tech-posting.eth']);
expect(container.querySelector('.yellowOfflineIcon')).not.toBeNull();
expect(container.querySelectorAll('[data-testid="loading-ellipsis"]')).toHaveLength(0);
expect(container.textContent).not.toContain('loaded 2/3 boards p2p');
expect(container.textContent).toContain('total_posts 18');
expect(container.textContent).toContain('current_users 7');
expect(container.textContent).toContain('boards_tracked 2');
expect(container.textContent).toContain('boards_loaded 2');
});
it('shows zero totals after every directory has loaded zero-count stats', () => {
@@ -234,9 +259,11 @@ describe('Home', () => {
renderHome();
expect(container.querySelectorAll('[data-testid="loading-ellipsis"]')).toHaveLength(0);
expect(container.querySelector('.yellowOfflineIcon')).toBeNull();
expect(container.textContent).not.toContain('loaded 2/2 boards p2p');
expect(container.textContent).toContain('total_posts 0');
expect(container.textContent).toContain('current_users 0');
expect(container.textContent).toContain('boards_tracked 2');
expect(container.textContent).toContain('boards_loaded 2');
});
it('does not keep aggregate stats loading after a directory stats fetch fails', () => {
@@ -248,9 +275,31 @@ describe('Home', () => {
renderHome();
expect(container.querySelectorAll('[data-testid="loading-ellipsis"]')).toHaveLength(0);
expect(container.querySelector('.yellowOfflineIcon')).toBeNull();
expect(container.textContent).not.toContain('loaded 2/2 boards p2p');
expect(container.textContent).toContain('total_posts 5');
expect(container.textContent).toContain('current_users 2');
expect(container.textContent).toContain('boards_tracked 2');
expect(container.textContent).toContain('boards_loaded 2');
});
it('switches the stats scope from the options menu', () => {
renderHome();
const optionsButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'options ▼');
expect(optionsButton).toBeTruthy();
act(() => {
optionsButton?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
});
const option = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'stats_scope_all_listed_boards');
expect(option).toBeTruthy();
act(() => {
option?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
});
expect(testState.setStatsScopeMock).toHaveBeenCalledWith('all');
});
it('navigates to the canonical board path when the search form is submitted', async () => {
+31 -4
View File
@@ -57,7 +57,7 @@
font-weight: 700;
}
.boxBar span, .boxBar > button {
.boxBar > span, .boxBar > button {
all: unset;
position: absolute;
top: 0;
@@ -69,7 +69,7 @@
line-height: inherit;
}
.boxBar span:hover, .boxBar > button:hover {
.boxBar > span:hover, .boxBar > button:hover {
cursor: pointer;
color: var(--homepage-box-bar-text-color-hover);
}
@@ -135,13 +135,14 @@
top: 30px;
right: 5px;
display: inline-block;
position: absolute;
text-align: right;
background: #fff;
border: 1px solid gray;
box-shadow: 2px 2px 0 1px rgba(0, 0, 0, .1);
padding: 4px 0;
line-height: 1.7em;
min-width: max-content;
white-space: nowrap;
z-index: 100;
}
@@ -155,12 +156,13 @@
all: unset;
box-sizing: border-box;
display: block;
width: 100%;
min-width: 100%;
padding: 0 10px;
color: inherit;
font: inherit;
line-height: inherit;
text-align: right;
white-space: nowrap;
}
.filterModal .option:hover {
@@ -259,6 +261,31 @@
color: #800;
}
.statsTitle {
padding-right: 6em;
text-transform: none;
}
.statsLoadingIconWrapper {
display: inline-flex;
align-items: center;
margin-left: 6px;
line-height: 1;
vertical-align: middle;
}
.statsLoadingIconWrapper > span {
display: inline-flex;
align-items: center;
}
.statsLoadingIcon {
box-sizing: border-box;
display: inline-block;
width: 14px;
height: 14px;
}
.stats .stat {
display: inline-block;
width: 33%;
+137 -78
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, FormEvent } from 'react';
import { useEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import styles from './home.module.css';
@@ -9,15 +9,15 @@ import PopularThreadsBox from './popular-threads-box';
import BoardsList from './boards-list';
import SiteLegalMeta from '../../components/site-legal-meta';
import LoadingEllipsis from '../../components/loading-ellipsis';
import { useFeedStateString } from '../../hooks/use-state-string';
import Tooltip from '../../components/tooltip';
import useDirectoryModalStore from '../../stores/use-directory-modal-store';
import useHomepageStatsOptionsStore, { type HomepageStatsScope } from '../../stores/use-homepage-stats-options-store';
import DisclaimerModal from '../../components/disclaimer-modal';
import DirectoryModal from '../../components/directory-modal';
import { extractDirectoryFromTitle, getBoardPath } from '../../lib/utils/route-utils';
import { isWebRuntime } from '../../lib/media-hosting/show-upload-controls';
import { useFeedStateString } from '../../hooks/use-state-string';
import lowerCase from 'lodash/lowerCase';
import useCommunitiesLoadingStartTimestamps from '../../stores/use-communities-loading-start-timestamps-store';
import { useNowSeconds } from '../../hooks/use-now-seconds';
// https://github.com/bitsocialnet/lists/tree/master/5chan-directories
@@ -106,103 +106,152 @@ type HomepageStats = {
state?: string;
};
const StatsLoading = ({ communityAddresses }: { communityAddresses: string[] }) => {
const { t } = useTranslation();
const loadingStateString = useFeedStateString(communityAddresses) || t('loading');
return <LoadingEllipsis string={loadingStateString} />;
};
const STATS_DIRECTORY_FALLBACK_DELAY_SECONDS = 30;
const getDirectoryCode = (directory: DirectoryCommunity): string | null => directory.directoryCode ?? extractDirectoryFromTitle(directory.title ?? '');
const getUniqueAddresses = (addresses: string[]): string[] => [...new Set(addresses.filter((address) => address.length > 0))];
const hasLoadedStats = (stat: HomepageStats | undefined): stat is HomepageStats & { allPostCount: number } => stat?.allPostCount !== undefined;
const hasFailedStats = (stat: HomepageStats | undefined): boolean => stat?.state === 'failed';
const hasResolvedStats = (stat: HomepageStats | undefined): boolean => hasLoadedStats(stat) || hasFailedStats(stat);
const STATS_SCOPE_OPTIONS: Array<{ scope: HomepageStatsScope; labelKey: string }> = [
{ scope: 'directory', labelKey: 'stats_scope_directory_boards' },
{ scope: 'all', labelKey: 'stats_scope_all_listed_boards' },
];
const EMPTY_STATS_LIST: string[] = [];
const StatsOptionsModal = () => {
const { t } = useTranslation();
const [showFilterModal, setShowFilterModal] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const statsScope = useHomepageStatsOptionsStore((state) => state.statsScope);
const setStatsScope = useHomepageStatsOptionsStore((state) => state.setStatsScope);
useEffect(() => {
if (!showFilterModal) return;
const handleClickOutside = (event: globalThis.MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node) && buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
setShowFilterModal(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [showFilterModal]);
const selectScope = (scope: HomepageStatsScope) => {
setStatsScope(scope);
setShowFilterModal(false);
};
const handleScopeKey = (event: ReactKeyboardEvent<HTMLButtonElement>, scope: HomepageStatsScope) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
selectScope(scope);
}
};
return (
<>
<button
type='button'
ref={buttonRef}
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (!showFilterModal) setShowFilterModal(true);
}
}}
onClick={() => !showFilterModal && setShowFilterModal(true)}
>
{t('options')}
</button>
{showFilterModal && (
<div ref={modalRef} className={styles.filterModal}>
{STATS_SCOPE_OPTIONS.map((option) => (
<button
key={option.scope}
type='button'
className={`${styles.option} ${statsScope === option.scope ? styles.selected : ''}`}
tabIndex={0}
onKeyDown={(event) => handleScopeKey(event, option.scope)}
onClick={() => selectScope(option.scope)}
>
{t(option.labelKey)}
</button>
))}
</div>
)}
</>
);
};
const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
const { t } = useTranslation();
const statsScope = useHomepageStatsOptionsStore((state) => state.statsScope);
const communitiesStats = useCommunitiesStatsStore((state) => state.communityStats);
const defaultDirectoryAddresses = useMemo(() => directories.map((directory) => directory.address), [directories]);
const loadingStartTimestamps = useCommunitiesLoadingStartTimestamps(defaultDirectoryAddresses);
const nowSeconds = useNowSeconds(defaultDirectoryAddresses.length > 0);
const fallbackDirectoryCodes = useMemo(
const allDirectoryCodes = useMemo(
() =>
directories.flatMap((directory, index) => {
const defaultStats = communitiesStats[directory.address];
if (hasLoadedStats(defaultStats)) {
return [];
}
const loadingStartTimestamp = loadingStartTimestamps[index];
if (!loadingStartTimestamp || nowSeconds - loadingStartTimestamp < STATS_DIRECTORY_FALLBACK_DELAY_SECONDS) {
return [];
}
const directoryCode = getDirectoryCode(directory);
return directoryCode ? [directoryCode] : [];
}),
[communitiesStats, directories, loadingStartTimestamps, nowSeconds],
getUniqueAddresses(
directories.flatMap((directory) => {
const directoryCode = getDirectoryCode(directory);
return directoryCode ? [directoryCode] : [];
}),
),
[directories],
);
const { listsByCode } = useDirectoryLists(fallbackDirectoryCodes);
const { listsByCode } = useDirectoryLists(statsScope === 'all' ? allDirectoryCodes : EMPTY_STATS_LIST);
const { collectorAddresses, totalPosts, currentUsers, boardsTracked, allDirectoryStatsLoaded } = useMemo(() => {
const collectorAddressSet = new Set(defaultDirectoryAddresses);
const collectorAddresses = useMemo(() => {
if (statsScope === 'directory') {
return defaultDirectoryAddresses;
}
return getUniqueAddresses(
directories.flatMap((directory) => {
const directoryCode = getDirectoryCode(directory);
const directoryList = directoryCode ? listsByCode[directoryCode] : null;
const listAddresses = directoryList ? sortDirectoryBoardsByRank(directoryList.boards).map((board) => board.address) : [];
return listAddresses.length > 0 ? listAddresses : [directory.address];
}),
);
}, [defaultDirectoryAddresses, directories, listsByCode, statsScope]);
const { totalPosts, currentUsers, boardsLoaded, boardsWithStats } = useMemo(() => {
let totalPosts = 0;
let currentUsers = 0;
let boardsTracked = 0;
let allDirectoryStatsLoaded = directories.length > 0;
let boardsLoaded = 0;
let boardsWithStats = 0;
for (const directory of directories) {
const directoryCode = getDirectoryCode(directory);
const directoryList = directoryCode ? listsByCode[directoryCode] : null;
const rankedAddresses = directoryList ? sortDirectoryBoardsByRank(directoryList.boards).map((board) => board.address) : [directory.address];
const candidateAddresses = getUniqueAddresses(rankedAddresses.length > 0 ? rankedAddresses : [directory.address]);
candidateAddresses.forEach((address) => collectorAddressSet.add(address));
let stat: HomepageStats | undefined;
let failedStat: HomepageStats | undefined;
let allCandidateStatsResolved = candidateAddresses.length > 0;
for (const address of candidateAddresses) {
const candidateStats = communitiesStats[address];
if (hasLoadedStats(candidateStats)) {
stat = candidateStats;
break;
}
if (hasFailedStats(candidateStats)) {
failedStat ??= candidateStats;
continue;
}
allCandidateStatsResolved = false;
}
if (!stat && allCandidateStatsResolved) {
stat = failedStat;
}
if (!stat || (!hasLoadedStats(stat) && !hasFailedStats(stat))) {
allDirectoryStatsLoaded = false;
for (const address of collectorAddresses) {
const stats = communitiesStats[address];
if (!hasResolvedStats(stats)) {
continue;
}
totalPosts += stat.allPostCount || 0;
currentUsers += stat.weekActiveUserCount || 0;
boardsTracked++;
boardsLoaded++;
if (hasLoadedStats(stats)) {
boardsWithStats++;
totalPosts += stats.allPostCount || 0;
currentUsers += stats.weekActiveUserCount || 0;
}
}
return {
collectorAddresses: [...collectorAddressSet],
totalPosts,
currentUsers,
boardsTracked,
allDirectoryStatsLoaded,
boardsLoaded,
boardsWithStats,
};
}, [communitiesStats, defaultDirectoryAddresses, directories, listsByCode]);
}, [collectorAddresses, communitiesStats]);
const hasDisplayableStats = boardsWithStats > 0 || (collectorAddresses.length > 0 && boardsLoaded === collectorAddresses.length);
const isStatsLoading = !hasDisplayableStats || boardsLoaded < collectorAddresses.length;
const loadingStateString = useFeedStateString(isStatsLoading ? collectorAddresses : EMPTY_STATS_LIST) || t('loading');
return (
<>
@@ -212,10 +261,20 @@ const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
))}
<div className={styles.box}>
<div className={`${styles.boxBar} ${styles.color2ColorBar}`}>
<h2 className='capitalize'>{t('stats')}</h2>
<h2 className={styles.statsTitle}>
{t('stats')}
{isStatsLoading && (
<span className={styles.statsLoadingIconWrapper}>
<Tooltip content={loadingStateString}>
<span className={`${styles.statsLoadingIcon} yellowOfflineIcon`} />
</Tooltip>
</span>
)}
</h2>
<StatsOptionsModal />
</div>
<div className={`${styles.boxContent} ${styles.stats}`}>
{allDirectoryStatsLoaded ? (
{hasDisplayableStats ? (
<>
<div className={styles.stat}>
<b>{t('total_posts')}</b> {totalPosts}
@@ -224,11 +283,11 @@ const Stats = ({ directories }: { directories: DirectoryCommunity[] }) => {
<b>{t('current_users')}</b> {currentUsers}
</div>
<div className={styles.stat}>
<b>{t('boards_tracked')}</b> {boardsTracked}
<b>{t('boards_loaded')}</b> {boardsLoaded}
</div>
</>
) : (
<StatsLoading communityAddresses={collectorAddresses} />
<LoadingEllipsis string={loadingStateString} />
)}
</div>
</div>