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:
+12
-20
@@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Link, MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
(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>;
|
||||
|
||||
@@ -266,11 +266,10 @@ const LocationProbe = () => {
|
||||
|
||||
const flushEffects = async (count = 8) => {
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
await Promise.resolve();
|
||||
await new Promise<void>((resolve) => queueMicrotask(resolve));
|
||||
}
|
||||
act(() => {});
|
||||
};
|
||||
|
||||
const renderApp = async (initialEntry: string) => {
|
||||
@@ -279,7 +278,7 @@ const renderApp = async (initialEntry: string) => {
|
||||
}
|
||||
|
||||
latestLocation = initialEntry;
|
||||
await act(async () => {
|
||||
act(() => {
|
||||
root.render(createElement(MemoryRouter, { initialEntries: [initialEntry] }, createElement(App!), createElement(LocationProbe)));
|
||||
});
|
||||
await flushEffects();
|
||||
@@ -318,6 +317,10 @@ const dispatchTextInput = async (element: HTMLTextAreaElement, value: string) =>
|
||||
};
|
||||
|
||||
describe('App', () => {
|
||||
beforeAll(async () => {
|
||||
App = (await import('../app')).default;
|
||||
}, 30000);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
latestLocation = '';
|
||||
@@ -353,19 +356,8 @@ describe('App', () => {
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('renders board layout chrome, settings modal, and reply modal wiring on settings routes', async () => {
|
||||
testState.replyModalState = {
|
||||
activeCid: 'parent-cid',
|
||||
closeModal: vi.fn(),
|
||||
parentNumber: 12,
|
||||
scrollY: 32,
|
||||
showReplyModal: true,
|
||||
communityAddress: 'music-posting.eth',
|
||||
threadCid: 'thread-cid',
|
||||
threadNumber: 99,
|
||||
} as ReplyModalShape;
|
||||
|
||||
await renderApp('/all/settings');
|
||||
it('renders board layout chrome on multiboard routes', async () => {
|
||||
await renderApp('/all');
|
||||
|
||||
expect(container.querySelector('[data-testid="boards-bar"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="board-header"]')).toBeTruthy();
|
||||
@@ -373,7 +365,7 @@ describe('App', () => {
|
||||
expect(container.querySelector('[data-testid="feed-cache-container"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="desktop-board-buttons"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="board-blotter"]')).toBeTruthy();
|
||||
expect(latestLocation).toBe('/all/settings');
|
||||
expect(latestLocation).toBe('/all');
|
||||
});
|
||||
|
||||
it('keeps an open post form and its draft when settings opens from a trailing-slash board route', async () => {
|
||||
|
||||
+18
-3
@@ -23,6 +23,7 @@ import {
|
||||
isBoardModRoute,
|
||||
isDirectoryBoard,
|
||||
isArchiveRoute,
|
||||
isDirectoryListRoute,
|
||||
isLegacyBoardModQueueRoute,
|
||||
isPostRoute,
|
||||
isPendingPostRoute,
|
||||
@@ -36,6 +37,7 @@ import Blotter from './views/blotter';
|
||||
import FAQ from './views/faq';
|
||||
import Home from './views/home';
|
||||
import Archive from './views/archive/archive';
|
||||
import Directory from './views/directory/directory';
|
||||
import ModQueueView from './views/mod-queue';
|
||||
import NotAllowed from './views/not-allowed';
|
||||
import NotFound from './views/not-found';
|
||||
@@ -83,8 +85,9 @@ const BoardLayout = () => {
|
||||
const isOnPendingPostRoute = isPendingPostRoute(pathname);
|
||||
const isOnModQueueRoute = isModQueueRoute(pathname);
|
||||
const isOnArchiveRoute = isArchiveRoute(pathname);
|
||||
const shouldRenderOutlet = isOnPostRoute || isOnPendingPostRoute || isOnModQueueRoute || isOnArchiveRoute;
|
||||
const shouldRenderBoardBlotter = !isOnArchiveRoute && !isOnModQueueRoute;
|
||||
const isOnDirectoryRoute = isDirectoryListRoute(pathname);
|
||||
const shouldRenderOutlet = isOnPostRoute || isOnPendingPostRoute || isOnModQueueRoute || isOnArchiveRoute || isOnDirectoryRoute;
|
||||
const shouldRenderBoardBlotter = !isOnArchiveRoute && !isOnDirectoryRoute && !isOnModQueueRoute;
|
||||
const isInCatalogView = isCatalogView(pathname, params);
|
||||
// Christmas theme
|
||||
const { isEnabled: isSpecialEnabled } = useSpecialThemeStore();
|
||||
@@ -151,6 +154,7 @@ const BoardLayout = () => {
|
||||
{isMobile
|
||||
? (communityAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPostCommunityAddress || isOnModQueueRoute) &&
|
||||
!isOnArchiveRoute &&
|
||||
!isOnDirectoryRoute &&
|
||||
(isInCatalogView ? (
|
||||
<>
|
||||
<PostForm key={key} />
|
||||
@@ -164,7 +168,8 @@ const BoardLayout = () => {
|
||||
</>
|
||||
))
|
||||
: (communityAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPostCommunityAddress || isOnModQueueRoute) &&
|
||||
!isOnArchiveRoute && (
|
||||
!isOnArchiveRoute &&
|
||||
!isOnDirectoryRoute && (
|
||||
<>
|
||||
<PostForm key={key} />
|
||||
{shouldRenderBoardBlotter ? <BoardBlotter /> : null}
|
||||
@@ -314,6 +319,14 @@ const App = () => {
|
||||
<Route path='/subs/archive/settings' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/mod/archive' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/mod/archive/settings' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/all/directory' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/all/directory/settings' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/subs/directory' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/subs/directory/settings' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/mod/directory' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/mod/directory/settings' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/directory' element={<Navigate to='/not-found' replace />} />
|
||||
<Route path='/directory/settings' element={<Navigate to='/not-found' replace />} />
|
||||
|
||||
{/* Invalid subpaths: old URLs and unknown paths -> not-found */}
|
||||
<Route path='/mod/modqueue' element={<Navigate to='/not-found' replace />} />
|
||||
@@ -330,6 +343,8 @@ const App = () => {
|
||||
<Route path='/:boardIdentifier/catalog/settings' element={catalogFeedElement} />
|
||||
<Route path='/:boardIdentifier/archive' element={<Archive />} />
|
||||
<Route path='/:boardIdentifier/archive/settings' element={<Archive />} />
|
||||
<Route path='/:boardIdentifier/directory' element={<Directory />} />
|
||||
<Route path='/:boardIdentifier/directory/settings' element={<Directory />} />
|
||||
|
||||
<Route path='/:boardIdentifier/mod/queue' element={<ModQueueRoute />} />
|
||||
<Route path='/:boardIdentifier/mod/queue/settings' element={<ModQueueRoute />} />
|
||||
|
||||
@@ -323,13 +323,14 @@ describe('BoardButtons', () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('renders desktop board actions for browsing boards, then searches OPs and triggers refresh, vote, subscribe, and archive flows', async () => {
|
||||
it('renders desktop board actions for browsing boards, then searches OPs and triggers refresh, directory, subscribe, and archive flows', async () => {
|
||||
await renderWithRoute(createElement(DesktopBoardButtons), '/mu');
|
||||
|
||||
expect(container.querySelector('[data-testid="mod-queue-button"]')?.textContent).toBe('mu');
|
||||
expect(container.textContent).toContain('subscribe');
|
||||
expect(container.textContent).toContain('vote');
|
||||
expect(container.textContent).toContain('directory');
|
||||
expect(findButtonLink('catalog')?.getAttribute('href')).toBe('/mu/catalog');
|
||||
expect(findButtonLink('directory')?.getAttribute('href')).toBe('/mu/directory');
|
||||
|
||||
const searchInput = container.querySelector<HTMLInputElement>('input[type="text"]');
|
||||
expect(searchInput).toBeTruthy();
|
||||
@@ -345,14 +346,18 @@ describe('BoardButtons', () => {
|
||||
|
||||
await clickButton('refresh');
|
||||
await clickButton('subscribe');
|
||||
await clickButton('vote');
|
||||
await clickButton('archive');
|
||||
|
||||
expect(testState.resetMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.subscribeMock).toHaveBeenCalledTimes(1);
|
||||
expect(testState.navigateMock).toHaveBeenCalledWith('/mu/archive');
|
||||
expect(globalThis.alert).toHaveBeenNthCalledWith(1, 'vote_button_unavailable_intro\n\nvote_button_unavailable_outro');
|
||||
expect(globalThis.alert).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.alert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not render the directory button on a full board address route', async () => {
|
||||
await renderWithRoute(createElement(DesktopBoardButtons), '/music-posting.eth');
|
||||
|
||||
expect(container.textContent).not.toContain('directory');
|
||||
});
|
||||
|
||||
it('renders desktop catalog controls and wires sort, style, filter, and refresh updates', async () => {
|
||||
|
||||
@@ -30,10 +30,6 @@
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.disabledButton:hover {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
|
||||
.mobileBoardButtons a:not([class~='button']),
|
||||
.desktopBoardButtons a:not([class~='button']) {
|
||||
all: unset;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { usePostPageNumber } from '../../hooks/use-post-page-number';
|
||||
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
|
||||
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
|
||||
import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-directory-addresses';
|
||||
import { getBoardPath, isDirectoryBoard } from '../../lib/utils/route-utils';
|
||||
import { getBoardPath, isDirectoryRoute } from '../../lib/utils/route-utils';
|
||||
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
||||
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
|
||||
import useSafeAccountComment from '../../hooks/use-safe-account-comment';
|
||||
@@ -165,26 +165,20 @@ export const ReturnButton = ({ address, isInAllView, isInSubscriptionsView, isIn
|
||||
);
|
||||
};
|
||||
|
||||
const VoteButton = () => {
|
||||
const DirectoryButton = () => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const directories = useDirectories();
|
||||
|
||||
// Get the boardIdentifier from params (try boardIdentifier first, then communityAddress for backward compatibility)
|
||||
const boardIdentifier = params.boardIdentifier;
|
||||
|
||||
// Only render the vote button if we're on a directory board route
|
||||
if (!boardIdentifier || !isDirectoryBoard(boardIdentifier, directories)) {
|
||||
if (!boardIdentifier || !isDirectoryRoute(boardIdentifier, directories)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const values = { boardIdentifier };
|
||||
const message = `${t('vote_button_unavailable_intro', values)}\n\n${t('vote_button_unavailable_outro', values)}`;
|
||||
|
||||
return (
|
||||
<button className={`button ${styles.disabledButton}`} onClick={() => window.alert(message)}>
|
||||
{t('vote')}
|
||||
</button>
|
||||
<Link className='button' to={`/${boardIdentifier}/directory`}>
|
||||
{t('directory')}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -508,7 +502,6 @@ export const MobileAllFeedFilter = () => (
|
||||
);
|
||||
|
||||
export const MobileBoardButtons = () => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const isInAllView = isAllView(location.pathname);
|
||||
@@ -529,10 +522,9 @@ export const MobileBoardButtons = () => {
|
||||
const effectiveInfiniteScroll = isMultiboard || enableInfiniteScroll;
|
||||
const showBottomButton = !effectiveInfiniteScroll;
|
||||
|
||||
// Check if we should show the vote button (only for directory boards)
|
||||
const directories = useDirectories();
|
||||
const boardIdentifier = params.boardIdentifier;
|
||||
const showVoteButton = boardIdentifier && isDirectoryBoard(boardIdentifier, directories);
|
||||
const showDirectoryButton = boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
|
||||
|
||||
return (
|
||||
<div className={`${styles.mobileBoardButtons} ${!isInCatalogView ? styles.addMargin : ''}`}>
|
||||
@@ -602,7 +594,7 @@ export const MobileBoardButtons = () => {
|
||||
<CatalogButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
|
||||
<RefreshButton />
|
||||
<div className={styles.secondRow}>
|
||||
{showVoteButton && <VoteButton />}
|
||||
{showDirectoryButton && <DirectoryButton />}
|
||||
{!(isInAllView || isInSubscriptionsView || isInModView) && <SubscribeButton address={communityAddress} />}
|
||||
{!(isInAllView || isInSubscriptionsView) && <ModQueueButton boardIdentifier={boardIdentifier} isMobile={true} />}
|
||||
</div>
|
||||
@@ -696,7 +688,6 @@ export const CatalogSearchResultsLabel = () => {
|
||||
};
|
||||
|
||||
export const DesktopBoardButtons = () => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const accountComment = useSafeAccountComment({ commentIndex: params?.accountCommentIndex });
|
||||
@@ -716,10 +707,9 @@ export const DesktopBoardButtons = () => {
|
||||
const effectiveInfiniteScroll = isMultiboard || enableInfiniteScroll;
|
||||
const showBottomButton = (isInCatalogView || isInPostView || isInPendingPostPage) && !effectiveInfiniteScroll;
|
||||
|
||||
// Check if we should show the vote button (only for directory boards)
|
||||
const directories = useDirectories();
|
||||
const boardIdentifier = params.boardIdentifier;
|
||||
const showVoteButton = boardIdentifier && isDirectoryBoard(boardIdentifier, directories);
|
||||
const showDirectoryButton = boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -818,12 +808,12 @@ export const DesktopBoardButtons = () => {
|
||||
{showTimeFilter && (
|
||||
<TimeFilter isInAllView={isInAllView} isInCatalogView={isInCatalogView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
|
||||
)}
|
||||
{showVoteButton && (
|
||||
{showDirectoryButton && (
|
||||
<>
|
||||
[<VoteButton />]
|
||||
[<DirectoryButton />]
|
||||
</>
|
||||
)}
|
||||
{showVoteButton && !(isInAllView || isInSubscriptionsView || isInModView) && ' '}
|
||||
{showDirectoryButton && !(isInAllView || isInSubscriptionsView || isInModView) && ' '}
|
||||
{!(isInAllView || isInSubscriptionsView || isInModView) && (
|
||||
<>
|
||||
[<SubscribeButton address={communityAddress} />]
|
||||
|
||||
@@ -7,7 +7,7 @@ import getShortAddress from '../../lib/get-short-address';
|
||||
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
|
||||
import { useStableCommunity } from '../../hooks/use-stable-community';
|
||||
import { isAllView, isSubscriptionsView, isModView } from '../../lib/utils/view-utils';
|
||||
import { isArchiveRoute } from '../../lib/utils/route-utils';
|
||||
import { isArchiveRoute, isDirectoryListRoute } from '../../lib/utils/route-utils';
|
||||
import styles from './board-header.module.css';
|
||||
import { useDirectoriesMetadata, useDirectories } from '../../hooks/use-directories';
|
||||
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
||||
@@ -54,6 +54,7 @@ const BoardHeader = () => {
|
||||
const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams());
|
||||
const isInModView = isModView(location.pathname);
|
||||
const isInArchiveView = isArchiveRoute(location.pathname);
|
||||
const isInDirectoryListView = isDirectoryListRoute(location.pathname);
|
||||
const accountComment = useSafeAccountComment({ commentIndex: params?.accountCommentIndex });
|
||||
const resolvedAddress = useResolvedCommunityAddress();
|
||||
const communityAddress = resolvedAddress || accountComment?.communityAddress;
|
||||
@@ -83,7 +84,15 @@ const BoardHeader = () => {
|
||||
: isInModView
|
||||
? startCase(t('boards_you_moderate'))
|
||||
: defaultCommunity?.title || stableCommunity?.title;
|
||||
const subtitle = isInAllView ? '' : isInSubscriptionsView ? subscriptionsSubtitle : isInModView ? '/mod/' : `${address || communityAddress || ''}`;
|
||||
const subtitle = isInAllView
|
||||
? ''
|
||||
: isInSubscriptionsView
|
||||
? subscriptionsSubtitle
|
||||
: isInModView
|
||||
? '/mod/'
|
||||
: isInDirectoryListView
|
||||
? t('directory_subtitle', { boardIdentifier: params.boardIdentifier })
|
||||
: `${address || communityAddress || ''}`;
|
||||
|
||||
return (
|
||||
<div className={`${styles.content} ${shouldShowSnow() ? styles.garland : ''}`}>
|
||||
@@ -99,7 +108,7 @@ const BoardHeader = () => {
|
||||
? shortAddress.slice(0, -4)
|
||||
: shortAddress
|
||||
: communityAddress && getShortAddress(communityAddress))}
|
||||
{!isInAllView && !isInSubscriptionsView && !isInModView && <OfflineIndicator communityAddress={communityAddress} />}
|
||||
{!isInAllView && !isInSubscriptionsView && !isInModView && !isInDirectoryListView && <OfflineIndicator communityAddress={communityAddress} />}
|
||||
</div>
|
||||
<div className={styles.boardSubtitle}>
|
||||
{isInSubscriptionsView ? (
|
||||
@@ -117,6 +126,8 @@ const BoardHeader = () => {
|
||||
>
|
||||
{subtitle}
|
||||
</span>
|
||||
) : isInDirectoryListView ? (
|
||||
<span>{subtitle}</span>
|
||||
) : !isInAllView && !isInModView && subtitle ? (
|
||||
<span title={t('board_address_tooltip')}>{subtitle}</span>
|
||||
) : (
|
||||
|
||||
@@ -36,9 +36,9 @@ const CreateBoardModal = () => {
|
||||
<div className={styles.section}>
|
||||
<h3>Creating Your Board</h3>
|
||||
<p>
|
||||
Create a board using the CLI:
|
||||
<a href='https://github.com/bitsocialnet/bitsocial-cli' target='_blank' rel='noopener noreferrer'>
|
||||
bitsocial-cli
|
||||
Create a board using{' '}
|
||||
<a href='https://github.com/bitsocialnet/5chan-board-manager' target='_blank' rel='noopener noreferrer'>
|
||||
5chan Board Manager
|
||||
</a>
|
||||
. <strong>Build a following:</strong> Users can subscribe to your board via the "[Subscribe]" button, which adds it to their top bar. You can gain
|
||||
subscribers through direct links, word of mouth, or search, no directory assignment or dev approval needed.
|
||||
|
||||
@@ -43,9 +43,9 @@ const DirectoryModal = () => {
|
||||
<div className={styles.section}>
|
||||
<h3>Creating Your Board</h3>
|
||||
<p>
|
||||
<strong>Anyone can create a board</strong> using the official CLI:{' '}
|
||||
<a href='https://github.com/bitsocialnet/bitsocial-cli' target='_blank' rel='noopener noreferrer'>
|
||||
bitsocial-cli
|
||||
<strong>Anyone can create a board</strong> using{' '}
|
||||
<a href='https://github.com/bitsocialnet/5chan-board-manager' target='_blank' rel='noopener noreferrer'>
|
||||
5chan Board Manager
|
||||
</a>
|
||||
. Users can access it anytime via the search bar, direct links, or by subscribing with the "[Subscribe]" button;{' '}
|
||||
<strong>no directory assignment or dev approval needed</strong>. Directory boards are simply featured in homepage categories (like "Anime &
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
appUpdateEnabled: true,
|
||||
capacitorPlatform: 'web',
|
||||
browserOpenMock: vi.fn(),
|
||||
electronDownloadAndInstallUpdateMock: vi.fn(),
|
||||
@@ -21,6 +22,12 @@ vi.mock('@capacitor/browser', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../app-distribution', () => ({
|
||||
get isAppUpdateEnabled() {
|
||||
return testState.appUpdateEnabled;
|
||||
},
|
||||
}));
|
||||
|
||||
const createFetchResponse = (body: unknown, ok = true, status = 200) => ({
|
||||
ok,
|
||||
status,
|
||||
@@ -39,6 +46,7 @@ const loadModule = async () => {
|
||||
describe('app-update', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.appUpdateEnabled = true;
|
||||
testState.capacitorPlatform = 'web';
|
||||
testState.browserOpenMock.mockReset();
|
||||
testState.electronDownloadAndInstallUpdateMock.mockReset();
|
||||
@@ -97,7 +105,7 @@ describe('app-update', () => {
|
||||
});
|
||||
|
||||
it('disables update checks for F-Droid builds', async () => {
|
||||
vi.stubEnv('VITE_APP_DISTRIBUTION', 'fdroid');
|
||||
testState.appUpdateEnabled = false;
|
||||
testState.capacitorPlatform = 'android';
|
||||
|
||||
const { applyAvailableAppUpdate, isAppUpdateEnabled, resolveAvailableAppUpdate } = await loadModule();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { KNOWN_5CHAN_DEVELOPER_ENTRIES, getAuthorBadge, isKnown5chanDeveloper } from '../author-display-utils';
|
||||
import { KNOWN_5CHAN_DEVELOPER_ENTRIES, get5chanDeveloperBadge, getAuthorBadge, isKnown5chanDeveloper } from '../author-display-utils';
|
||||
|
||||
describe('author display utils', () => {
|
||||
it('recognizes the hardcoded 5chan developer addresses', () => {
|
||||
@@ -38,6 +38,15 @@ describe('author display utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns only the 5chan Dev badge for known developers', () => {
|
||||
expect(get5chanDeveloperBadge('plebeius.bso')).toEqual({
|
||||
icon: 'admin',
|
||||
label: '5chan Dev',
|
||||
title: '5chan Dev',
|
||||
});
|
||||
expect(get5chanDeveloperBadge('other.bso')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps board role labels for non-developers', () => {
|
||||
expect(getAuthorBadge({ address: 'other.bso', role: 'moderator' })).toEqual({
|
||||
capitalizeLabel: true,
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
isArchiveRoute,
|
||||
isBoardModRoute,
|
||||
isDirectoryBoard,
|
||||
isDirectoryListRoute,
|
||||
isDirectoryRoute,
|
||||
isFeedRoute,
|
||||
isLegacyBoardModQueueRoute,
|
||||
isModQueueRoute,
|
||||
@@ -60,6 +62,8 @@ describe('directory mapping helpers', () => {
|
||||
expect(areSameBoardAddress('music-posting.eth', 'business.eth')).toBe(false);
|
||||
expect(areSameBoardAddress(undefined, 'business.eth')).toBe(false);
|
||||
|
||||
expect(isDirectoryRoute('biz', communities)).toBe(true);
|
||||
expect(isDirectoryRoute('business.eth', communities)).toBe(false);
|
||||
expect(isDirectoryBoard('biz', communities)).toBe(true);
|
||||
expect(isDirectoryBoard('business.eth', communities)).toBe(false);
|
||||
});
|
||||
@@ -118,6 +122,15 @@ describe('isFeedRoute', () => {
|
||||
expect(isArchiveRoute('/biz/archive/settings')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for board directory paths', () => {
|
||||
expect(isFeedRoute('/biz/directory')).toBe(false);
|
||||
expect(isFeedRoute('/biz/directory/settings')).toBe(false);
|
||||
expect(isDirectoryListRoute('/biz/directory')).toBe(true);
|
||||
expect(isDirectoryListRoute('/biz/directory/settings')).toBe(true);
|
||||
expect(isDirectoryListRoute('/biz/archive')).toBe(false);
|
||||
expect(isDirectoryListRoute('/biz')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for posts and pending items', () => {
|
||||
expect(isFeedRoute('/biz/thread/abc')).toBe(false);
|
||||
expect(isFeedRoute('/pending/4')).toBe(false);
|
||||
|
||||
@@ -7,7 +7,6 @@ interface Known5chanDeveloperEntry {
|
||||
}
|
||||
|
||||
export const KNOWN_5CHAN_DEVELOPER_ENTRIES = known5chanDeveloperEntries as readonly Known5chanDeveloperEntry[];
|
||||
export const KNOWN_5CHAN_DEVELOPER_ADDRESSES = KNOWN_5CHAN_DEVELOPER_ENTRIES.map(({ address }) => address);
|
||||
|
||||
type AuthorBadgeIcon = 'admin' | 'mod';
|
||||
|
||||
@@ -27,6 +26,9 @@ const normalizeBoardRole = (role?: string): string | undefined => {
|
||||
export const isKnown5chanDeveloper = (address?: string): boolean =>
|
||||
typeof address === 'string' && KNOWN_5CHAN_DEVELOPER_ENTRIES.some((developer) => developer.address === address);
|
||||
|
||||
/** 5chan Dev capcode only — never board owner/mod badges. */
|
||||
export const get5chanDeveloperBadge = (address?: string): AuthorBadge | undefined => (isKnown5chanDeveloper(address) ? getAuthorBadge({ address }) : undefined);
|
||||
|
||||
export const getAuthorBadge = ({ address, role }: { address?: string; role?: string }): AuthorBadge | undefined => {
|
||||
const boardRole = normalizeBoardRole(role);
|
||||
const isDeveloper = isKnown5chanDeveloper(address);
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const COMMUNITY_OFFLINE_THRESHOLD_SECONDS = 30 * 60;
|
||||
|
||||
export interface CommunityFreshnessState {
|
||||
state?: string;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export const isCommunityUpdateStale = (updatedAt: number | undefined, nowSeconds: number): boolean =>
|
||||
updatedAt !== undefined && nowSeconds - updatedAt >= COMMUNITY_OFFLINE_THRESHOLD_SECONDS;
|
||||
|
||||
export const isCommunityKnownOffline = (communityState: CommunityFreshnessState | undefined, nowSeconds: number): boolean => {
|
||||
if (!communityState) return false;
|
||||
if (communityState.state === 'failed') return true;
|
||||
return isCommunityUpdateStale(communityState.updatedAt, nowSeconds);
|
||||
};
|
||||
@@ -115,24 +115,33 @@ export const areSameBoardAddress = (a: string | undefined, b: string | undefined
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if an identifier is a directory short code
|
||||
* True when the URL board segment is a directory short code (e.g. /biz), not a full board address (e.g. /board.bso).
|
||||
*/
|
||||
export const isDirectoryBoard = (identifier: string, communities: DirectoryCommunity[]): boolean => {
|
||||
export const isDirectoryRoute = (boardIdentifier: string, communities: DirectoryCommunity[]): boolean => {
|
||||
const directoryToAddress = getDirectoryToAddressMap(communities);
|
||||
return directoryToAddress.has(identifier);
|
||||
return directoryToAddress.has(boardIdentifier);
|
||||
};
|
||||
|
||||
/** @deprecated Use {@link isDirectoryRoute} */
|
||||
export const isDirectoryBoard = isDirectoryRoute;
|
||||
|
||||
export const isArchiveRoute = (pathname: string): boolean => {
|
||||
const normalizedPath = pathname.replace(/\/settings$/, '').replace(/\/$/, '');
|
||||
return normalizedPath.endsWith('/archive');
|
||||
};
|
||||
|
||||
export const isDirectoryListRoute = (pathname: string): boolean => {
|
||||
const normalizedPath = pathname.replace(/\/settings$/, '').replace(/\/$/, '');
|
||||
return normalizedPath.endsWith('/directory');
|
||||
};
|
||||
|
||||
export const isFeedRoute = (pathname: string): boolean => {
|
||||
const normalizedPath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname;
|
||||
|
||||
if (normalizedPath.includes('/thread/')) return false;
|
||||
if (normalizedPath.startsWith('/pending/')) return false;
|
||||
if (isArchiveRoute(normalizedPath)) return false;
|
||||
if (isDirectoryListRoute(normalizedPath)) return false;
|
||||
if (isBoardModRoute(normalizedPath) || isModQueueRoute(normalizedPath)) return false;
|
||||
|
||||
const pathWithoutSettings = normalizedPath.replace(/\/settings$/, '');
|
||||
@@ -270,7 +279,7 @@ export const getFeedCacheKey = (pathname: string, search = ''): string | null =>
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isArchiveRoute(normalizedPath) || isBoardModRoute(normalizedPath) || isModQueueRoute(normalizedPath)) {
|
||||
if (isArchiveRoute(normalizedPath) || isDirectoryListRoute(normalizedPath) || isBoardModRoute(normalizedPath) || isModQueueRoute(normalizedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import * as React from 'react';
|
||||
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 Directory from '../directory';
|
||||
|
||||
(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: 'a' as string | undefined,
|
||||
communities: {} as Record<string, { address: string; name?: string; state?: string; updatedAt?: number }>,
|
||||
communityIdentifierRequests: [] as Array<string | undefined>,
|
||||
directoryListLoading: false,
|
||||
directoryBoards: [
|
||||
{
|
||||
address: 'anime-and-manga.bso',
|
||||
score: 12,
|
||||
managedByDevs: false,
|
||||
},
|
||||
],
|
||||
directories: [
|
||||
{
|
||||
address: 'anime-and-manga.bso',
|
||||
directoryCode: 'a',
|
||||
title: '/a/ - Anime & Manga',
|
||||
},
|
||||
],
|
||||
offlineHookRequests: [] as Array<{ address?: string; communityAddressHint?: string }>,
|
||||
offlineHookValue: {
|
||||
isOffline: false,
|
||||
isOnlineStatusLoading: false,
|
||||
offlineIconClass: '',
|
||||
offlineTitle: false as string | false,
|
||||
},
|
||||
offlineStates: {} as Record<string, { state?: string; updatedAt?: number }>,
|
||||
nowSeconds: 1_704_067_210,
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
Trans: ({ i18nKey }: { i18nKey: string }) => createElement(React.Fragment, null, i18nKey),
|
||||
useTranslation: () => ({
|
||||
t: (key: string, values?: Record<string, unknown>) => {
|
||||
if (key === 'directory_status_online') return 'online';
|
||||
if (key === 'directory_status_offline') return 'offline';
|
||||
if (key === 'directory_heading') return `${values?.boardIdentifier} directory`;
|
||||
if (key === 'view') return 'View';
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
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('@bitsocial/bitsocial-react-hooks', () => ({
|
||||
useCommunity: (options?: { community?: { name?: string; publicKey?: string } }) => {
|
||||
const communityAddress = options?.community?.name ?? options?.community?.publicKey;
|
||||
return communityAddress ? testState.communities[communityAddress] : undefined;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/board-buttons/board-buttons', () => ({
|
||||
BottomButton: () => createElement('button', { type: 'button' }, 'bottom'),
|
||||
CatalogButton: () => createElement('a', null, 'catalog'),
|
||||
ReturnButton: () => createElement('a', null, 'return'),
|
||||
TopButton: () => createElement('button', { type: 'button' }, 'top'),
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/footer', () => ({
|
||||
PageFooterDesktop: ({ firstRow, styleRow }: { firstRow: React.ReactNode; styleRow: React.ReactNode }) =>
|
||||
createElement('footer', { 'data-testid': 'desktop-footer' }, firstRow, styleRow),
|
||||
PageFooterMobile: ({ children }: { children: React.ReactNode }) => createElement('footer', { 'data-testid': 'mobile-footer' }, children),
|
||||
ThreadFooterStyleRow: () => createElement('div', null, 'style'),
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/loading-ellipsis', () => ({
|
||||
default: ({ string }: { string: string }) => createElement('span', null, string),
|
||||
}));
|
||||
|
||||
vi.mock('../../../components/tooltip', () => ({
|
||||
default: ({ content, children }: { content: React.ReactNode; children: React.ReactNode }) =>
|
||||
createElement('span', { title: typeof content === 'string' ? content : undefined }, children),
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-directories', () => ({
|
||||
useDirectories: () => testState.directories,
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-directory-list', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../hooks/use-directory-list')>('../../../hooks/use-directory-list');
|
||||
return {
|
||||
...actual,
|
||||
useDirectoryList: () => ({
|
||||
list: {
|
||||
directoryCode: testState.boardIdentifier,
|
||||
title: '/a/ - Anime & Manga',
|
||||
boards: testState.directoryBoards,
|
||||
},
|
||||
loading: testState.directoryListLoading,
|
||||
error: null,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../hooks/use-resolved-community-address', () => ({
|
||||
useResolvedCommunityAddress: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-community-identifiers', () => ({
|
||||
useCommunityIdentifier: (address?: string) => {
|
||||
testState.communityIdentifierRequests.push(address);
|
||||
return address ? { name: address } : undefined;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-is-community-offline', () => ({
|
||||
default: (community?: { address?: string }, communityAddressHint?: string) => {
|
||||
testState.offlineHookRequests.push({ address: community?.address, communityAddressHint });
|
||||
return testState.offlineHookValue;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../hooks/use-now-seconds', () => ({
|
||||
useNowSeconds: () => testState.nowSeconds,
|
||||
}));
|
||||
|
||||
vi.mock('../../../stores/use-community-offline-store', () => ({
|
||||
default: <T,>(selector: (state: { communityOfflineState: typeof testState.offlineStates }) => T) =>
|
||||
selector({
|
||||
communityOfflineState: testState.offlineStates,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/snow', () => ({
|
||||
shouldShowSnow: () => false,
|
||||
}));
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let originalAlert: typeof window.alert;
|
||||
let root: Root;
|
||||
|
||||
const renderDirectory = async () => {
|
||||
await act(async () => {
|
||||
root.render(createElement(MemoryRouter, {}, createElement(Directory)));
|
||||
});
|
||||
};
|
||||
|
||||
const createDirectoryBoard = (address: string, score = 12) => ({
|
||||
address,
|
||||
score,
|
||||
managedByDevs: false,
|
||||
});
|
||||
|
||||
const createCommunity = (address: string, updatedAt = testState.nowSeconds - 60) => ({
|
||||
address,
|
||||
name: address,
|
||||
state: 'started',
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
const getDirectoryRow = (address = 'anime-and-manga.bso') => Array.from(container.querySelectorAll('tbody tr')).find((row) => row.textContent?.includes(address));
|
||||
|
||||
describe('Directory', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.boardIdentifier = 'a';
|
||||
testState.communities = {
|
||||
'anime-and-manga.bso': {
|
||||
address: 'anime-and-manga.bso',
|
||||
name: 'anime-and-manga.bso',
|
||||
state: 'started',
|
||||
updatedAt: testState.nowSeconds - 60,
|
||||
},
|
||||
};
|
||||
testState.communityIdentifierRequests = [];
|
||||
testState.directoryListLoading = false;
|
||||
testState.directoryBoards = [createDirectoryBoard('anime-and-manga.bso')];
|
||||
testState.directories = [
|
||||
{
|
||||
address: 'anime-and-manga.bso',
|
||||
directoryCode: 'a',
|
||||
title: '/a/ - Anime & Manga',
|
||||
},
|
||||
];
|
||||
testState.offlineHookRequests = [];
|
||||
testState.offlineHookValue = {
|
||||
isOffline: false,
|
||||
isOnlineStatusLoading: false,
|
||||
offlineIconClass: '',
|
||||
offlineTitle: false,
|
||||
};
|
||||
testState.offlineStates = {};
|
||||
testState.nowSeconds = 1_704_067_210;
|
||||
originalAlert = window.alert;
|
||||
window.alert = vi.fn();
|
||||
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
window.alert = originalAlert;
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('shows online status for a listed board after loading its community', async () => {
|
||||
await renderDirectory();
|
||||
|
||||
const cells = Array.from(getDirectoryRow()?.querySelectorAll('td') ?? []).map((cell) => cell.textContent?.replace(/\s+/g, ' ').trim());
|
||||
expect(cells.slice(0, 5)).toEqual(['1', 'anime-and-manga.bso', 'directory_owner_anonymous', 'online', '12']);
|
||||
expect(cells[5]).toContain('+1');
|
||||
expect(cells[5]).toContain('-1');
|
||||
expect(cells[5]).toContain('View');
|
||||
expect(getDirectoryRow()?.querySelector('td:nth-child(2) a')).toBeNull();
|
||||
expect(getDirectoryRow()?.querySelector('td:nth-child(2) span')).toBeNull();
|
||||
expect(testState.communityIdentifierRequests).toContain('anime-and-manga.bso');
|
||||
expect(testState.offlineHookRequests).toContainEqual({
|
||||
address: 'anime-and-manga.bso',
|
||||
communityAddressHint: 'anime-and-manga.bso',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading status while the listed board status is loading', async () => {
|
||||
testState.communities = {};
|
||||
testState.offlineHookValue = {
|
||||
isOffline: false,
|
||||
isOnlineStatusLoading: true,
|
||||
offlineIconClass: 'yellowOfflineIcon',
|
||||
offlineTitle: 'downloading board...',
|
||||
};
|
||||
|
||||
await renderDirectory();
|
||||
|
||||
expect(getDirectoryRow()?.textContent).toContain('loading');
|
||||
});
|
||||
|
||||
it('shows a placeholder when listed board status is unknown', async () => {
|
||||
testState.communities = {};
|
||||
|
||||
await renderDirectory();
|
||||
|
||||
const cells = Array.from(getDirectoryRow()?.querySelectorAll('td') ?? []).map((cell) => cell.textContent?.replace(/\s+/g, ' ').trim());
|
||||
expect(cells[3]).toBe('—');
|
||||
});
|
||||
|
||||
it('shows offline status when the listed board community is stale', async () => {
|
||||
testState.communities['anime-and-manga.bso'] = {
|
||||
address: 'anime-and-manga.bso',
|
||||
name: 'anime-and-manga.bso',
|
||||
state: 'started',
|
||||
updatedAt: testState.nowSeconds - 31 * 60,
|
||||
};
|
||||
|
||||
await renderDirectory();
|
||||
|
||||
expect(getDirectoryRow()?.textContent).toContain('offline');
|
||||
});
|
||||
|
||||
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;
|
||||
testState.communities = Object.fromEntries(boards.map((board) => [board.address, createCommunity(board.address)]));
|
||||
|
||||
await renderDirectory();
|
||||
|
||||
for (const board of boards.slice(0, 5)) {
|
||||
expect(testState.communityIdentifierRequests).toContain(board.address);
|
||||
expect(testState.offlineHookRequests).toContainEqual({
|
||||
address: board.address,
|
||||
communityAddressHint: board.address,
|
||||
});
|
||||
}
|
||||
|
||||
expect(testState.communityIdentifierRequests).not.toContain('board-6.bso');
|
||||
expect(testState.offlineHookRequests).not.toContainEqual({
|
||||
address: 'board-6.bso',
|
||||
communityAddressHint: 'board-6.bso',
|
||||
});
|
||||
|
||||
const cells = Array.from(getDirectoryRow('board-6.bso')?.querySelectorAll('td') ?? []).map((cell) => cell.textContent?.replace(/\s+/g, ' ').trim());
|
||||
expect(cells[3]).toBe('—?');
|
||||
expect(getDirectoryRow('board-6.bso')?.querySelector('sup')?.closest('span')?.getAttribute('title')).toBe('directory_status_unavailable_reason');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
.page {
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
padding: 0 0 24px;
|
||||
color: var(--body-font-color);
|
||||
font-family: var(--body-font-family);
|
||||
font-size: var(--body-font-size);
|
||||
}
|
||||
|
||||
.desktopDivider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.desktopNavLinks {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.desktopNavLinks a:not([class~='button']),
|
||||
.desktopFooterButtons a:not([class~='button']) {
|
||||
all: unset;
|
||||
}
|
||||
|
||||
.mobileNavLinks {
|
||||
display: none;
|
||||
text-align: center;
|
||||
text-transform: capitalize;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.mobileNavLinks button {
|
||||
text-transform: capitalize;
|
||||
margin: 5px 2px;
|
||||
}
|
||||
|
||||
.mobileNavLinks a:not([class~='button']),
|
||||
.mobileFooterButtons a:not([class~='button']) {
|
||||
all: unset;
|
||||
}
|
||||
|
||||
.desktopFooterButtons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.mobileFooterButtons {
|
||||
text-align: center;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.mobileFooterButtons button {
|
||||
text-transform: capitalize;
|
||||
margin: 5px 2px;
|
||||
}
|
||||
|
||||
.directorySummary {
|
||||
margin: 0 0 12px;
|
||||
text-align: center;
|
||||
font-size: 10pt;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.directoryIntro {
|
||||
width: 80%;
|
||||
max-width: 810px;
|
||||
margin: 0 auto 8px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.directoryIntro a {
|
||||
color: var(--post-link-text-color);
|
||||
text-decoration: var(--post-link-text-decoration);
|
||||
}
|
||||
|
||||
.directoryIntro a:hover {
|
||||
color: var(--post-link-text-color-hover);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.flashListing {
|
||||
width: 80%;
|
||||
max-width: 810px;
|
||||
margin: 10px auto 0;
|
||||
border-collapse: separate;
|
||||
border-spacing: 1px;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.flashListing td {
|
||||
padding: 2px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.flashListing thead td {
|
||||
background: #98e;
|
||||
border: 1px solid #000;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dirRow {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.numberCell,
|
||||
.scoreCell,
|
||||
.ownerCell,
|
||||
.statusCell,
|
||||
.actionsCell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.numberCell,
|
||||
.scoreCell,
|
||||
.actionsCell {
|
||||
width: 1%;
|
||||
}
|
||||
|
||||
.postblock {
|
||||
padding: 5px !important;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.boardCol {
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.ownerCell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ownerName {
|
||||
font-weight: var(--post-name-font-weight, 700);
|
||||
}
|
||||
|
||||
.viewLink {
|
||||
color: var(--post-link-text-color);
|
||||
text-decoration: var(--post-link-text-decoration);
|
||||
}
|
||||
|
||||
.viewLink:hover {
|
||||
color: var(--post-link-text-color-hover);
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
color: var(--post-link-text-color);
|
||||
text-decoration: var(--post-link-text-decoration);
|
||||
}
|
||||
|
||||
.actionButton:hover,
|
||||
.actionButton:focus-visible {
|
||||
color: var(--post-link-text-color-hover);
|
||||
}
|
||||
|
||||
.actionButton:focus-visible {
|
||||
outline: 1px dotted currentcolor;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.scoreValue {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.statusOnline {
|
||||
color: green;
|
||||
}
|
||||
|
||||
.statusOffline {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.statusUnavailable {
|
||||
color: var(--body-font-color);
|
||||
}
|
||||
|
||||
.statusUnavailableHelp {
|
||||
margin-left: 2px;
|
||||
font-weight: 700;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.directoryFootnote {
|
||||
width: 80%;
|
||||
max-width: 810px;
|
||||
margin: 14px auto 0;
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.directoryFootnote a {
|
||||
color: var(--post-link-text-color);
|
||||
text-decoration: var(--post-link-text-decoration);
|
||||
}
|
||||
|
||||
.directoryFootnote a:hover {
|
||||
color: var(--post-link-text-color-hover);
|
||||
}
|
||||
|
||||
.footerState {
|
||||
margin: 8px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.garland {
|
||||
border-image-slice: 50 0 50 0;
|
||||
border-image-width: 40px 0px 0px 0px;
|
||||
border-image-outset: 0px 0px 0px 0px;
|
||||
border-image-repeat: repeat repeat;
|
||||
border-image-source: url('/assets/garland.png');
|
||||
border-style: solid;
|
||||
padding-top: 50px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.desktopDivider,
|
||||
.desktopNavLinks {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobileNavLinks {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.flashListing,
|
||||
.directoryIntro,
|
||||
.directoryFootnote {
|
||||
width: calc(100% - 10px);
|
||||
max-width: calc(100% - 10px);
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.flashListing {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.actionsCell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
:global(body.yotsuba) .rowOdd td {
|
||||
background: #ede2d4;
|
||||
}
|
||||
|
||||
:global(body.yotsuba) .flashListing thead td {
|
||||
background: #ea8;
|
||||
}
|
||||
|
||||
:global(body.yotsuba-b) .rowOdd td {
|
||||
background: #e0e5f6;
|
||||
}
|
||||
|
||||
:global(body.futaba) .rowOdd td {
|
||||
background: #ede2d4;
|
||||
}
|
||||
|
||||
:global(body.futaba) .flashListing thead td {
|
||||
background: #f0e0d6;
|
||||
}
|
||||
|
||||
:global(body.burichan) .rowOdd td {
|
||||
background: #e0e5f6;
|
||||
}
|
||||
|
||||
:global(body.burichan) .flashListing thead td {
|
||||
background: #c3c9e9;
|
||||
}
|
||||
|
||||
:global(body.tomorrow) .rowOdd td {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:global(body.tomorrow) .flashListing thead td {
|
||||
background: #b294bb;
|
||||
}
|
||||
|
||||
:global(body.photon) .rowOdd td {
|
||||
background: #888;
|
||||
}
|
||||
|
||||
:global(body.photon) .flashListing thead td {
|
||||
background: #ddd;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Link, Navigate, useParams } from 'react-router-dom';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useCommunity } from '@bitsocial/bitsocial-react-hooks';
|
||||
import { shouldShowSnow } from '../../lib/snow';
|
||||
import { BottomButton, CatalogButton, ReturnButton, TopButton } from '../../components/board-buttons/board-buttons';
|
||||
import { PageFooterDesktop, PageFooterMobile, ThreadFooterStyleRow } from '../../components/footer';
|
||||
import LoadingEllipsis from '../../components/loading-ellipsis';
|
||||
import Tooltip from '../../components/tooltip';
|
||||
import { useDirectories } from '../../hooks/use-directories';
|
||||
import { useCommunityIdentifier } from '../../hooks/use-community-identifiers';
|
||||
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
||||
import { isDirectoryRoute } from '../../lib/utils/route-utils';
|
||||
import { DirectoryListBoard, sortDirectoryBoardsByRank, useDirectoryList } from '../../hooks/use-directory-list';
|
||||
import { type CommunityFreshnessState, isCommunityKnownOffline } from '../../lib/utils/community-freshness-utils';
|
||||
import getShortAddress from '../../lib/get-short-address';
|
||||
import { get5chanDeveloperBadge } from '../../lib/utils/author-display-utils';
|
||||
import useCommunityOfflineStore from '../../stores/use-community-offline-store';
|
||||
import useIsCommunityOffline from '../../hooks/use-is-community-offline';
|
||||
import { useNowSeconds } from '../../hooks/use-now-seconds';
|
||||
import postStyles from '../post/post.module.css';
|
||||
import styles from './directory.module.css';
|
||||
|
||||
const DIRECTORY_STATUS_CHECK_LIMIT = 5;
|
||||
const DIRECTORY_STATUS_UNAVAILABLE_MARKER = '\u2014';
|
||||
|
||||
const computeBoardStatus = (
|
||||
communityState: CommunityFreshnessState | undefined,
|
||||
offlineState: CommunityFreshnessState | undefined,
|
||||
nowSeconds: number,
|
||||
isOffline: boolean,
|
||||
isOnlineStatusLoading: boolean,
|
||||
): 'online' | 'offline' | 'loading' | 'unknown' => {
|
||||
const freshnessState = {
|
||||
state: communityState?.state ?? offlineState?.state,
|
||||
updatedAt: communityState?.updatedAt ?? offlineState?.updatedAt,
|
||||
};
|
||||
|
||||
if (isOffline || isCommunityKnownOffline(freshnessState, nowSeconds)) return 'offline';
|
||||
if (isOnlineStatusLoading) return 'loading';
|
||||
if (!freshnessState.updatedAt) return 'unknown';
|
||||
return 'online';
|
||||
};
|
||||
|
||||
const PASS_LINK = '/pass';
|
||||
|
||||
const DirectoryDesktopTopControls = ({ communityAddress }: { communityAddress: string | undefined }) => (
|
||||
<div className={styles.desktopNavLinks}>
|
||||
<span>
|
||||
[<ReturnButton address={communityAddress} />]
|
||||
</span>
|
||||
<span>
|
||||
[<CatalogButton address={communityAddress} />]
|
||||
</span>
|
||||
<span>
|
||||
[<BottomButton />]
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DirectoryDesktopFooterControls = ({ communityAddress }: { communityAddress: string | undefined }) => (
|
||||
<div className={styles.desktopFooterButtons}>
|
||||
<span>
|
||||
[<ReturnButton address={communityAddress} />]
|
||||
</span>
|
||||
<span>
|
||||
[<CatalogButton address={communityAddress} />]
|
||||
</span>
|
||||
<span>
|
||||
[<TopButton />]
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const DirectoryMobileTopControls = ({ communityAddress }: { communityAddress: string | undefined }) => (
|
||||
<div className={styles.mobileNavLinks}>
|
||||
<ReturnButton address={communityAddress} />
|
||||
<CatalogButton address={communityAddress} />
|
||||
<BottomButton />
|
||||
</div>
|
||||
);
|
||||
|
||||
const DirectoryMobileFooterControls = ({ communityAddress }: { communityAddress: string | undefined }) => (
|
||||
<div className={styles.mobileFooterButtons}>
|
||||
<ReturnButton address={communityAddress} />
|
||||
<CatalogButton address={communityAddress} />
|
||||
<TopButton />
|
||||
</div>
|
||||
);
|
||||
|
||||
interface DirectoryRowProps {
|
||||
board: DirectoryListBoard;
|
||||
nowSeconds: number;
|
||||
rank: number;
|
||||
onVote: () => void;
|
||||
}
|
||||
|
||||
const DirectoryRow = ({ board, nowSeconds, rank, onVote }: DirectoryRowProps) => {
|
||||
const { t } = useTranslation();
|
||||
const statusUnavailableReason = t('directory_status_unavailable_reason');
|
||||
const ownerAddress = board.owner;
|
||||
const ownerDisplay = ownerAddress ? getShortAddress(ownerAddress) || ownerAddress : undefined;
|
||||
const developerBadge = get5chanDeveloperBadge(ownerAddress);
|
||||
const shouldCheckStatus = rank <= DIRECTORY_STATUS_CHECK_LIMIT;
|
||||
const communityIdentifier = useCommunityIdentifier(shouldCheckStatus ? board.address : undefined);
|
||||
const community = useCommunity(shouldCheckStatus && communityIdentifier ? { community: communityIdentifier } : undefined);
|
||||
const { isOffline, isOnlineStatusLoading } = useIsCommunityOffline(community, shouldCheckStatus ? board.address : undefined);
|
||||
const offlineState = useCommunityOfflineStore((state) => (shouldCheckStatus ? state.communityOfflineState[board.address] : undefined));
|
||||
const status = shouldCheckStatus ? computeBoardStatus(community, offlineState, nowSeconds, isOffline, isOnlineStatusLoading) : 'unavailable';
|
||||
const boardLink = `/${board.address}`;
|
||||
|
||||
return (
|
||||
<tr className={`${styles.dirRow} ${rank % 2 === 1 ? styles.rowOdd : ''}`}>
|
||||
<td className={styles.numberCell}>{rank}</td>
|
||||
<td className={styles.boardCol}>{board.address}</td>
|
||||
<td className={styles.ownerCell}>
|
||||
<span className={developerBadge ? `${styles.ownerName} ${postStyles.capcodeAdmin}` : undefined}>
|
||||
{ownerDisplay ?? t('directory_owner_anonymous')}
|
||||
{developerBadge && (
|
||||
<>
|
||||
{' ## '}
|
||||
{developerBadge.label} <span className={`${postStyles.capcodeIcon} ${postStyles.capcodeAdminIcon}`} title={developerBadge.title} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={styles.statusCell}>
|
||||
{status === 'unavailable' ? (
|
||||
<span className={styles.statusUnavailable}>
|
||||
{DIRECTORY_STATUS_UNAVAILABLE_MARKER}
|
||||
<Tooltip content={statusUnavailableReason}>
|
||||
<sup className={styles.statusUnavailableHelp} aria-label={statusUnavailableReason} tabIndex={0}>
|
||||
?
|
||||
</sup>
|
||||
</Tooltip>
|
||||
</span>
|
||||
) : status === 'loading' ? (
|
||||
<LoadingEllipsis string={t('loading')} />
|
||||
) : status === 'unknown' ? (
|
||||
<span className={styles.statusUnavailable}>{DIRECTORY_STATUS_UNAVAILABLE_MARKER}</span>
|
||||
) : (
|
||||
<span className={status === 'offline' ? styles.statusOffline : styles.statusOnline}>
|
||||
{t(status === 'offline' ? 'directory_status_offline' : 'directory_status_online')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={styles.scoreCell}>
|
||||
<span className={styles.scoreValue}>{board.score}</span>
|
||||
</td>
|
||||
<td className={styles.actionsCell}>
|
||||
[
|
||||
<button type='button' className={styles.actionButton} onClick={onVote} aria-label={t('upvote')} title={t('upvote')}>
|
||||
+1
|
||||
</button>
|
||||
] [
|
||||
<button type='button' className={styles.actionButton} onClick={onVote} aria-label={t('downvote')} title={t('downvote')}>
|
||||
-1
|
||||
</button>
|
||||
] [
|
||||
<Link to={boardLink} className={styles.viewLink}>
|
||||
{t('view')}
|
||||
</Link>
|
||||
]
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
const getRepoEditUrl = (directoryCode: string) => `https://github.com/bitsocialnet/lists/edit/master/5chan-${directoryCode}-directory.json`;
|
||||
|
||||
const Directory = () => {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const boardIdentifier = params.boardIdentifier;
|
||||
const directories = useDirectories();
|
||||
const isValidDirectoryCode = !!boardIdentifier && isDirectoryRoute(boardIdentifier, directories);
|
||||
const { list, loading } = useDirectoryList(isValidDirectoryCode ? boardIdentifier : undefined);
|
||||
const communityAddress = useResolvedCommunityAddress();
|
||||
const nowSeconds = useNowSeconds();
|
||||
|
||||
const ranked = useMemo(() => (list ? sortDirectoryBoardsByRank(list.boards) : []), [list]);
|
||||
const directoryTitle = list?.title || (boardIdentifier ? `/${boardIdentifier}/ - ${t('directory')}` : t('directory'));
|
||||
|
||||
useEffect(() => {
|
||||
if (!isValidDirectoryCode) return;
|
||||
document.title = `${directoryTitle} - 5chan`;
|
||||
}, [directoryTitle, isValidDirectoryCode]);
|
||||
|
||||
if (!isValidDirectoryCode) {
|
||||
return <Navigate to='/not-found' replace />;
|
||||
}
|
||||
|
||||
const handleVoteUnavailable = () => {
|
||||
const values = { boardIdentifier };
|
||||
window.alert(`${t('directory_voting_unavailable_intro', values)}\n\n${t('directory_voting_unavailable_outro', values)}`);
|
||||
};
|
||||
|
||||
const isLoadingShell = loading && ranked.length === 0;
|
||||
const boardCount = ranked.length;
|
||||
const repoEditUrl = getRepoEditUrl(boardIdentifier!);
|
||||
|
||||
return (
|
||||
<div id='top' className={`${styles.page} ${shouldShowSnow() ? styles.garland : ''}`}>
|
||||
<DirectoryMobileTopControls communityAddress={communityAddress} />
|
||||
<hr className={styles.desktopDivider} />
|
||||
<DirectoryDesktopTopControls communityAddress={communityAddress} />
|
||||
<hr className={styles.divider} />
|
||||
{isLoadingShell ? (
|
||||
<h4 className={styles.directorySummary}>
|
||||
<LoadingEllipsis string={t('loading_directory')} />
|
||||
</h4>
|
||||
) : ranked.length === 0 ? (
|
||||
<h4 className={styles.directorySummary}>{t('directory_empty')}</h4>
|
||||
) : (
|
||||
<h4 className={styles.directorySummary}>{t('directory_heading', { boardIdentifier, count: boardCount })}</h4>
|
||||
)}
|
||||
|
||||
{!isLoadingShell && ranked.length > 0 && (
|
||||
<>
|
||||
<table className={styles.flashListing}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.postblock} scope='col'>
|
||||
No.
|
||||
</th>
|
||||
<th className={styles.postblock} scope='col'>
|
||||
{t('directory_board')}
|
||||
</th>
|
||||
<th className={styles.postblock} scope='col'>
|
||||
{t('directory_owner')}
|
||||
</th>
|
||||
<th className={styles.postblock} scope='col'>
|
||||
{t('directory_status')}
|
||||
</th>
|
||||
<th className={styles.postblock} scope='col'>
|
||||
{t('directory_score')}
|
||||
</th>
|
||||
<th className={styles.postblock} scope='col'>
|
||||
{t('directory_vote')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ranked.map((board, index) => (
|
||||
<DirectoryRow key={board.address} board={board} nowSeconds={nowSeconds} rank={index + 1} onVote={handleVoteUnavailable} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className={styles.directoryFootnote}>
|
||||
<Trans
|
||||
i18nKey='directory_footnote'
|
||||
values={{ boardIdentifier }}
|
||||
components={{
|
||||
passLink: <Link to={PASS_LINK} />,
|
||||
repoLink: <a href={repoEditUrl} target='_blank' rel='noreferrer noopener' />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<PageFooterDesktop firstRow={<DirectoryDesktopFooterControls communityAddress={communityAddress} />} styleRow={<ThreadFooterStyleRow />} />
|
||||
<PageFooterMobile>
|
||||
<DirectoryMobileFooterControls communityAddress={communityAddress} />
|
||||
</PageFooterMobile>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Directory;
|
||||
+4
-11
@@ -316,15 +316,12 @@ const FAQ_SECTIONS: FAQSection[] = [
|
||||
question: 'Can I create my own board?',
|
||||
answer: (
|
||||
<>
|
||||
Yes. A 5chan board is a Bitsocial community. Today the practical route is to run{' '}
|
||||
<a href='https://github.com/bitsocialnet/bitsocial-cli' {...externalLinkProps}>
|
||||
bitsocial-cli
|
||||
</a>{' '}
|
||||
as a node and use{' '}
|
||||
Yes. A 5chan board is a Bitsocial community. Today the practical route is to use{' '}
|
||||
<a href='https://github.com/bitsocialnet/5chan-board-manager' {...externalLinkProps}>
|
||||
5chan Board Manager
|
||||
</a>{' '}
|
||||
for imageboard lifecycle rules such as thread limits, bump limits, archived-thread retention, and purging of author-deleted content.
|
||||
to run the board with imageboard lifecycle rules such as thread limits, bump limits, archived-thread retention, and purging of author-deleted content. Its
|
||||
Docker setup can start the required Bitsocial node for you or connect to one you already operate.
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -557,14 +554,10 @@ const FAQ_SECTIONS: FAQSection[] = [
|
||||
answer: (
|
||||
<>
|
||||
The 5chan client is free and open-source software under GPL-3.0-or-later. Boards are Bitsocial communities, typically run with{' '}
|
||||
<a href='https://github.com/bitsocialnet/bitsocial-cli' {...externalLinkProps}>
|
||||
bitsocial-cli
|
||||
</a>{' '}
|
||||
and, for imageboard-specific behavior,{' '}
|
||||
<a href='https://github.com/bitsocialnet/5chan-board-manager' {...externalLinkProps}>
|
||||
5chan Board Manager
|
||||
</a>
|
||||
.
|
||||
, which connects to the Bitsocial network and applies 5chan-specific board behavior.
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user