mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix(catalog): hide threads across board catalogs
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
>
|
||||
— 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) && (
|
||||
<>
|
||||
{' '}
|
||||
|
||||
Reference in New Issue
Block a user