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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user