fix(catalog): hide threads across board catalogs

This commit is contained in:
Tommaso Casaburi
2026-05-14 16:59:55 +07:00
parent d1fbc7f46a
commit 8217bad735
17 changed files with 1544 additions and 123 deletions
@@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DesktopBoardButtons, MobileBoardButtons } from '../board-buttons';
import useThreadLiveUpdatesStore from '../../../stores/use-thread-live-updates-store';
import useHiddenCatalogThreadsStore from '../../../stores/use-hidden-catalog-threads-store';
import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../../../lib/utils/time-filter-utils';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -17,6 +18,8 @@ type DirectoryEntry = {
};
const testState = vi.hoisted(() => ({
account: { blockedCids: {} as Record<string, boolean>, subscriptions: [] as string[] },
accountCommunityAddresses: [] as string[],
accountComment: undefined as { communityAddress?: string } | undefined,
alertThresholdUnit: 'minutes' as 'hours' | 'minutes',
alertThresholdValue: 5,
@@ -28,6 +31,8 @@ const testState = vi.hoisted(() => ({
enableInfiniteScroll: false,
filter: 'all' as 'all' | 'nsfw' | 'sfw',
filteredCount: 0,
filteredDirectoryAddresses: ['music-posting.eth', 'tech-posting.eth'] as string[],
hiddenThreadsByScope: {} as Record<string, Array<{ cid: string }>>,
imageSize: 'Small' as 'Small' | 'Large',
isMobile: true,
linkCount: 3,
@@ -73,7 +78,7 @@ vi.mock('react-router-dom', async () => {
});
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useAccount: () => undefined,
useAccount: () => testState.account,
useAccountComment: () => testState.accountComment,
useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.commentsByCid[commentCid] : undefined),
useSubscribe: () => ({
@@ -83,6 +88,26 @@ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
}),
}));
vi.mock('../../../hooks/use-account-community-addresses', () => ({
useAccountCommunityAddresses: () => testState.accountCommunityAddresses,
}));
vi.mock('../../../hooks/use-filtered-directory-addresses', () => ({
useFilteredDirectoryAddresses: () => testState.filteredDirectoryAddresses,
}));
vi.mock('../../../hooks/use-hidden-catalog-threads', () => ({
default: ({ communityAddresses }: { communityAddresses: string[] }) => {
const scopeKey = communityAddresses.filter(Boolean).slice().sort().join('\u0000');
return {
hiddenCatalogThreads: testState.hiddenThreadsByScope[scopeKey] || [],
hiddenThreadCandidates: testState.hiddenThreadsByScope[scopeKey] || [],
isLoadingHiddenCatalogThreads: false,
scopeKey,
};
},
}));
vi.mock('../../../hooks/use-post-page-number', () => ({
usePostPageNumber: () => testState.pageNumber,
}));
@@ -117,10 +142,13 @@ vi.mock('../../../stores/use-feed-reset-store', () => ({
}));
vi.mock('../../../stores/use-sorting-store', () => ({
default: () => ({
setSortType: testState.setSortTypeMock,
sortType: testState.sortType,
}),
default: (selector?: (state: { setSortType: typeof testState.setSortTypeMock; sortType: typeof testState.sortType }) => unknown) => {
const state = {
setSortType: testState.setSortTypeMock,
sortType: testState.sortType,
};
return selector ? selector(state) : state;
},
}));
vi.mock('../../../stores/use-all-feed-filter-store', () => ({
@@ -223,9 +251,13 @@ const setTrackedInputValue = (input: HTMLInputElement, value: string) => {
descriptor?.set?.call(input, value);
};
const getScopeKey = (communityAddresses: string[]) => communityAddresses.filter(Boolean).slice().sort().join('\u0000');
describe('BoardButtons', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.account = { blockedCids: {}, subscriptions: [] };
testState.accountCommunityAddresses = [];
testState.accountComment = undefined;
testState.alertThresholdUnit = 'minutes';
testState.alertThresholdValue = 5;
@@ -237,6 +269,8 @@ describe('BoardButtons', () => {
testState.enableInfiniteScroll = false;
testState.filter = 'all';
testState.filteredCount = 0;
testState.filteredDirectoryAddresses = ['music-posting.eth', 'tech-posting.eth'];
testState.hiddenThreadsByScope = {};
testState.imageSize = 'Small';
testState.isMobile = true;
testState.linkCount = 3;
@@ -248,6 +282,7 @@ describe('BoardButtons', () => {
testState.subscribed = false;
testState.viewMode = 'compact';
useThreadLiveUpdatesStore.getState().resetState();
useHiddenCatalogThreadsStore.setState({ hiddenCommentsByCid: {}, scopeHiddenThreadsCounts: {}, shownScopeKey: null });
clearStableLastVisitTimeFilterName();
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now()));
Object.defineProperty(globalThis, 'alert', {
@@ -274,6 +309,7 @@ describe('BoardButtons', () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
useHiddenCatalogThreadsStore.setState({ hiddenCommentsByCid: {}, scopeHiddenThreadsCounts: {}, shownScopeKey: null });
clearStableLastVisitTimeFilterName();
localStorage.clear();
});
@@ -340,6 +376,51 @@ describe('BoardButtons', () => {
expect(testState.resetMock).toHaveBeenCalledTimes(1);
});
it('renders the desktop hidden-thread catalog control immediately after refresh', async () => {
testState.hiddenThreadsByScope[getScopeKey(['music-posting.eth'])] = [{ cid: 'hidden-thread' }];
await renderWithRoute(createElement(DesktopBoardButtons), '/mu/catalog');
const control = container.querySelector<HTMLElement>('[data-testid="hidden-threads-control"]');
expect(control?.dataset.placement).toBe('desktop');
expect(control?.textContent).toContain('Hidden threads: 1');
expect(control?.querySelector('strong')?.textContent).toBe('1');
expect(container.textContent?.indexOf('refresh')).toBeLessThan(container.textContent?.indexOf('Hidden threads') ?? -1);
await clickButton('Show');
expect(useHiddenCatalogThreadsStore.getState().shownScopeKey).toBe(getScopeKey(['music-posting.eth']));
expect(container.querySelector<HTMLElement>('[data-testid="hidden-threads-control"]')?.textContent).toContain('Back');
});
it('renders the mobile hidden-thread catalog control below the refresh row', async () => {
testState.hiddenThreadsByScope[getScopeKey(['music-posting.eth'])] = [{ cid: 'hidden-thread' }, { cid: 'second-hidden-thread' }];
await renderWithRoute(createElement(MobileBoardButtons), '/mu/catalog');
const control = container.querySelector<HTMLElement>('[data-testid="hidden-threads-control"]');
expect(control?.dataset.placement).toBe('mobile');
expect(control?.className).toContain('mobileHiddenCatalogThreadsToggle');
expect(control?.textContent).toContain('Hidden threads: 2');
expect(container.textContent?.indexOf('refresh')).toBeLessThan(container.textContent?.indexOf('Hidden threads') ?? -1);
});
it('counts hidden threads for every board in the all catalog scope', async () => {
testState.hiddenThreadsByScope[getScopeKey(['music-posting.eth', 'tech-posting.eth'])] = [{ cid: 'hidden-music' }, { cid: 'hidden-tech' }];
await renderWithRoute(createElement(DesktopBoardButtons), '/all/catalog?t=24h');
expect(container.querySelector<HTMLElement>('[data-testid="hidden-threads-control"]')?.textContent).toContain('Hidden threads: 2');
});
it('uses the catalog-provided hidden count when the blocked cid lookup has not resolved yet', async () => {
useHiddenCatalogThreadsStore.getState().setScopeHiddenThreadsCount(getScopeKey(['music-posting.eth']), 1);
await renderWithRoute(createElement(DesktopBoardButtons), '/mu/catalog');
expect(container.querySelector<HTMLElement>('[data-testid="hidden-threads-control"]')?.textContent).toContain('Hidden threads: 1');
});
it('preserves the current multiboard time filter when searching OPs', async () => {
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now() - 3 * 24 * 60 * 60 * 1000));
@@ -34,7 +34,8 @@
cursor: not-allowed !important;
}
.mobileBoardButtons a:not([class~='button']), .desktopBoardButtons a:not([class~='button']) {
.mobileBoardButtons a:not([class~='button']),
.desktopBoardButtons a:not([class~='button']) {
all: unset;
}
@@ -60,7 +61,7 @@
}
.desktopBoardButtons::after {
content: "";
content: '';
display: table;
clear: both;
}
@@ -107,6 +108,30 @@
text-transform: none !important;
}
.hiddenCatalogThreadsToggle {
text-transform: none;
}
.desktopHiddenCatalogThreadsToggle {
display: inline;
}
.hiddenCatalogThreadsToggleAction {
all: unset;
color: var(--button-desktop-text-color);
cursor: pointer;
}
.hiddenCatalogThreadsToggleAction:hover {
color: var(--button-desktop-text-color-hover);
}
.mobileHiddenCatalogThreadsToggle {
display: block;
margin-top: 3px;
margin-bottom: 15px;
}
.mobileBoardButtons {
text-transform: capitalize;
}
+82 -1
View File
@@ -1,15 +1,20 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { useComment, useSubscribe } from '@bitsocial/bitsocial-react-hooks';
import { useAccount, useComment, useSubscribe } from '@bitsocial/bitsocial-react-hooks';
import { isAllView, isCatalogView, isModView, isModQueueView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
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 { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
import useSafeAccountComment from '../../hooks/use-safe-account-comment';
import useHiddenCatalogThreads from '../../hooks/use-hidden-catalog-threads';
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
import useCatalogStyleStore from '../../stores/use-catalog-style-store';
import useFeedResetStore from '../../stores/use-feed-reset-store';
import useHiddenCatalogThreadsStore from '../../stores/use-hidden-catalog-threads-store';
import useSortingStore from '../../stores/use-sorting-store';
import useAllFeedFilterStore from '../../stores/use-all-feed-filter-store';
import useModQueueStore from '../../stores/use-mod-queue-store';
@@ -37,6 +42,8 @@ interface BoardButtonsProps {
isTopbar?: boolean;
}
const EMPTY_COMMUNITY_ADDRESSES: string[] = [];
const getMultiboardPath = ({
isInAllView,
isInCatalogView,
@@ -190,6 +197,60 @@ export const RefreshButton = () => {
);
};
const HiddenCatalogThreadsToggle = ({
address,
isInAllView = false,
isInCatalogView = false,
isInSubscriptionsView = false,
isInModView = false,
isMobilePlacement = false,
}: BoardButtonsProps & { isMobilePlacement?: boolean }) => {
const account = useAccount();
const accountCommunityAddresses = useAccountCommunityAddresses();
const filteredDirectoryAddresses = useFilteredDirectoryAddresses();
const sortType = useSortingStore((state) => state.sortType);
const toggleShownScopeKey = useHiddenCatalogThreadsStore((state) => state.toggleShownScopeKey);
const communityAddresses = useMemo(() => {
if (isInAllView) {
return filteredDirectoryAddresses;
}
if (isInSubscriptionsView) {
return account?.subscriptions?.filter(Boolean) || EMPTY_COMMUNITY_ADDRESSES;
}
if (isInModView) {
return accountCommunityAddresses;
}
return address ? [address] : EMPTY_COMMUNITY_ADDRESSES;
}, [account?.subscriptions, accountCommunityAddresses, address, filteredDirectoryAddresses, isInAllView, isInModView, isInSubscriptionsView]);
const { hiddenCatalogThreads, isLoadingHiddenCatalogThreads, scopeKey } = useHiddenCatalogThreads({
communityAddresses,
sortType: sortType === 'new' ? 'new' : 'active',
});
const storedHiddenThreadsCount = useHiddenCatalogThreadsStore((state) => state.scopeHiddenThreadsCounts[scopeKey] || 0);
const hiddenThreadsCount = Math.max(hiddenCatalogThreads.length, storedHiddenThreadsCount);
const requestedShowHiddenThreads = useHiddenCatalogThreadsStore((state) => state.shownScopeKey === scopeKey);
const showHiddenThreads = requestedShowHiddenThreads && (hiddenThreadsCount > 0 || isLoadingHiddenCatalogThreads);
if (!isInCatalogView || hiddenThreadsCount === 0) {
return null;
}
return (
<span
className={`${styles.hiddenCatalogThreadsToggle} ${isMobilePlacement ? styles.mobileHiddenCatalogThreadsToggle : styles.desktopHiddenCatalogThreadsToggle}`}
data-testid='hidden-threads-control'
data-placement={isMobilePlacement ? 'mobile' : 'desktop'}
>
&mdash; Hidden threads: <strong>{hiddenThreadsCount}</strong> [
<button type='button' className={styles.hiddenCatalogThreadsToggleAction} data-testid='hidden-threads-toggle' onClick={() => toggleShownScopeKey(scopeKey)}>
{showHiddenThreads ? 'Back' : 'Show'}
</button>
]
</span>
);
};
export const UpdateButton = () => {
const { t } = useTranslation();
const requestUpdate = useThreadLiveUpdatesStore((state) => state.requestUpdate);
@@ -504,6 +565,14 @@ export const MobileBoardButtons = () => {
<ArchiveButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
{showBottomButton && <BottomButton />}
<RefreshButton />
<HiddenCatalogThreadsToggle
address={communityAddress}
isInAllView={isInAllView}
isInCatalogView={isInCatalogView}
isInSubscriptionsView={isInSubscriptionsView}
isInModView={isInModView}
isMobilePlacement={true}
/>
{searchText ? (
<span className={styles.filteredThreadsCount}>
{' '}
@@ -705,6 +774,18 @@ export const DesktopBoardButtons = () => {
</>
)}{' '}
[<RefreshButton />]
{isInCatalogView && (
<>
{' '}
<HiddenCatalogThreadsToggle
address={communityAddress}
isInAllView={isInAllView}
isInCatalogView={isInCatalogView}
isInSubscriptionsView={isInSubscriptionsView}
isInModView={isInModView}
/>
</>
)}
{!(isInAllView || isInSubscriptionsView) && (
<>
{' '}