mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(home stats): load stats progressively
This commit is contained in:
@@ -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;
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user