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) && (
<>
{' '}
@@ -492,14 +492,15 @@ describe('CatalogRow', () => {
testState.hiddenCids = new Set(['hidden-1']);
testState.showOPComment = false;
const hiddenPost: TestComment = {
author: { address: 'hidden-author', displayName: 'Ghost' },
cid: 'hidden-1',
content: 'hidden text',
link: 'https://example.com/hidden.png',
communityAddress: 'music-posting.eth',
};
const posts: TestComment[] = [
{
author: { address: 'hidden-author', displayName: 'Ghost' },
cid: 'hidden-1',
content: 'hidden text',
link: 'https://example.com/hidden.png',
communityAddress: 'music-posting.eth',
},
hiddenPost,
{
author: { address: 'text-author', displayName: 'Anon' },
cid: 'text-1',
@@ -516,7 +517,13 @@ describe('CatalogRow', () => {
expect(links).toContain('/mu/thread/hidden-1');
expect(links).toContain('/mu/thread/text-1');
expect(container.textContent).toContain('(hidden)');
expect(container.textContent).not.toContain('hidden text');
expect(container.textContent).toContain('Text title: Plain thread body');
await renderWithRouter(createElement(CatalogRow, { row: [hiddenPost], showHiddenPosts: true }), '/mu/catalog');
expect(container.textContent).toContain('hidden text');
expect(container.textContent).not.toContain('(hidden)');
});
it('preserves literal catalog teaser markers without applying body markdown styles', async () => {
+17 -10
View File
@@ -118,7 +118,7 @@ export const CatalogPostMedia = ({ cid, commentMediaInfo, linkWidth, linkHeight,
// Memoize CatalogPost to prevent rerenders when parent rerenders due to updatingState
const CatalogPost = memo(
({ matchedFilterColor, post }: { matchedFilterColor?: string; post: Comment }) => {
({ matchedFilterColor, post, showHiddenPost = false }: { matchedFilterColor?: string; post: Comment; showHiddenPost?: boolean }) => {
const { t } = useTranslation();
const resolvedPost = useMemo(() => withResolvedCommentCommunityAddress(post), [post]);
const { author, cid, content, link, linkHeight, linkWidth, locked, pinned, replyCount, spoiler, communityAddress, timestamp, title, thumbnailUrl } =
@@ -129,7 +129,8 @@ const CatalogPost = memo(
const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
const { hidden } = useHide({ cid });
const { hidden } = useHide({ cid, comment: resolvedPost });
const shouldMaskPost = hidden && !showHiddenPost;
const location = useLocation();
const params = useParams();
@@ -207,8 +208,8 @@ const CatalogPost = memo(
const lastReplyAuthorBadge = getAuthorBadge({ address: lastReply?.author?.address, role: lastReplyAuthorRole });
const postContent = (
<div className={`${styles.teaser} ${hidden && styles.hidden}`}>
{hidden ? (
<div className={`${styles.teaser} ${shouldMaskPost && styles.hidden}`}>
{shouldMaskPost ? (
<b>({t('hidden')})</b>
) : (
<>
@@ -238,7 +239,7 @@ const CatalogPost = memo(
<>
<div className={`${styles.post} ${imageSize === 'Large' ? styles.large : ''}`} style={CSSProperties}>
<div onMouseOver={() => setHoveredCid(cid)} onMouseLeave={() => setHoveredCid(null)}>
{hidden ? (
{shouldMaskPost ? (
<Link to={postLink}>
<span className={styles.hiddenThumbnail} />
</Link>
@@ -247,7 +248,7 @@ const CatalogPost = memo(
{shouldShowSnow() && hasThumbnail && <img src='assets/xmashat.gif' className={styles.xmasHat} alt='' />}
<Link to={postLink}>
<div
className={`${styles.mediaPaddingWrapper} ${hidden && styles.hidden}`}
className={`${styles.mediaPaddingWrapper} ${shouldMaskPost && styles.hidden}`}
ref={refs.setReference}
onMouseOver={() => (timeoutRef.current = setTimeout(() => setShowPortal(true), 250))}
onMouseLeave={() => {
@@ -352,7 +353,8 @@ const CatalogPost = memo(
prev?.linkWidth === next?.linkWidth &&
prev?.linkHeight === next?.linkHeight &&
prevCommunityAddress === nextCommunityAddress &&
prevProps.matchedFilterColor === nextProps.matchedFilterColor
prevProps.matchedFilterColor === nextProps.matchedFilterColor &&
prevProps.showHiddenPost === nextProps.showHiddenPost
);
},
);
@@ -362,20 +364,25 @@ interface CatalogRowProps {
index?: number;
matchedFilterColors?: Map<string, string>;
row: Comment[];
showHiddenPosts?: boolean;
}
const CatalogRow = memo(
({ estimatedHeight, matchedFilterColors, row }: CatalogRowProps) => {
({ estimatedHeight, matchedFilterColors, row, showHiddenPosts = false }: CatalogRowProps) => {
return (
<div className={styles.row} data-pretext-height={estimatedHeight}>
{row.map((post, index) => (
<CatalogPost key={post?.cid || index} matchedFilterColor={matchedFilterColors?.get(post?.cid || '')} post={post} />
<CatalogPost key={post?.cid || index} matchedFilterColor={matchedFilterColors?.get(post?.cid || '')} post={post} showHiddenPost={showHiddenPosts} />
))}
</div>
);
},
(prevProps, nextProps) => {
if (prevProps.estimatedHeight !== nextProps.estimatedHeight || prevProps.row.length !== nextProps.row.length) {
if (
prevProps.estimatedHeight !== nextProps.estimatedHeight ||
prevProps.row.length !== nextProps.row.length ||
prevProps.showHiddenPosts !== nextProps.showHiddenPosts
) {
return false;
}
@@ -171,7 +171,7 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
const { thumbnail, type, url } = commentMediaInfo || {};
const [menuBtnRotated, setMenuBtnRotated] = useState(false);
const { hidden, unhide, hide } = useHide({ cid: cid || '' });
const { hidden, unhide, hide } = useHide({ cid: cid || '', comment: postMenu.comment });
const location = useLocation();
const params = useParams();
@@ -51,6 +51,7 @@ async function copyUserIdSafe(address: string): Promise<void> {
type HideButtonProps = {
cid?: string;
comment?: Comment;
isReply?: boolean;
postCid?: string;
onClose?: () => void;
@@ -267,9 +268,9 @@ const DeletePostButton = ({ post, onClose }: DeletePostButtonProps) => {
);
};
const HidePostButton = ({ cid, isReply, onClose, postCid }: HideButtonProps) => {
const HidePostButton = ({ cid, comment, isReply, onClose, postCid }: HideButtonProps) => {
const { t } = useTranslation();
const { hide, hidden, unhide } = useHide({ cid: cid || '' });
const { hide, hidden, unhide } = useHide({ cid: cid || '', comment });
const isInPostView = isPostPageView(useLocation().pathname, useParams());
const togglePostHidden = () => {
@@ -365,7 +366,7 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
<FloatingFocusManager context={context} modal={false}>
<div className={styles.postMenu} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}>
<ReportPostButton onClose={handleClose} />
{cid && communityAddress && <HidePostButton cid={cid} isReply={!!parentCid} postCid={postCid} onClose={handleClose} />}
{cid && communityAddress && <HidePostButton cid={cid} comment={postMenu.comment} isReply={!!parentCid} postCid={postCid} onClose={handleClose} />}
{(isAccountCommentAuthor || canAttemptAuthorDelete) && cid && editMenuPost && <DeletePostButton post={editMenuPost} onClose={handleClose} />}
{cid && communityAddress && <CopyLinkButton cid={cid} communityAddress={communityAddress} linkType='thread' onClose={handleClose} />}
{cid && <CopyContentIdButton cid={cid} onClose={handleClose} />}