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} />}
@@ -0,0 +1,156 @@
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 type { Comment } from '@bitsocial/bitsocial-react-hooks';
import useHiddenCatalogThreads from '../use-hidden-catalog-threads';
(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 SPORTS_PUBLIC_KEY = '12D3KooWGJA6zN3Q63FtSgwNhtfA26Skdzdxz5X7A9PFfE4FBMGE';
const testState = vi.hoisted(() => ({
account: { blockedCids: {} as Record<string, boolean> },
commentsByCid: {} as Record<string, Comment>,
directories: [
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
{ address: 'sports-posting.bso', directoryCode: 'sp', publicKey: '12D3KooWGJA6zN3Q63FtSgwNhtfA26Skdzdxz5X7A9PFfE4FBMGE', title: '/sp/ - Sports' },
],
}));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useAccount: () => testState.account,
useComments: ({ commentCids = [] }: { commentCids?: string[] } = {}) => ({
comments: commentCids.map((cid) => testState.commentsByCid[cid]),
state: 'succeeded',
}),
}));
vi.mock('../use-directories', async () => {
const actual = await vi.importActual<typeof import('../use-directories')>('../use-directories');
return {
...actual,
useDirectories: () => testState.directories,
};
});
const Result = ({
candidateComments = [],
communityAddresses,
sortType = 'new',
}: {
candidateComments?: Comment[];
communityAddresses: string[];
sortType?: 'active' | 'new';
}) => {
const { hiddenCatalogThreads, scopeKey } = useHiddenCatalogThreads({ candidateComments, communityAddresses, sortType });
return (
<div>
<span data-testid='hidden-cids'>{hiddenCatalogThreads.map((thread) => thread.cid).join(',')}</span>
<span data-testid='scope-key'>{scopeKey}</span>
</div>
);
};
let container: HTMLDivElement;
let root: Root;
describe('useHiddenCatalogThreads', () => {
beforeEach(() => {
testState.account = { blockedCids: {} };
testState.commentsByCid = {};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('counts hidden threads for a board reached through a directory alias and public key', async () => {
testState.account = { blockedCids: { 'hidden-sp-thread': true, 'hidden-sp-reply': true, 'hidden-mu-thread': true } };
testState.commentsByCid = {
'hidden-mu-thread': {
cid: 'hidden-mu-thread',
communityAddress: 'music-posting.eth',
postCid: 'hidden-mu-thread',
timestamp: 100,
} as Comment,
'hidden-sp-reply': {
cid: 'hidden-sp-reply',
communityAddress: SPORTS_PUBLIC_KEY,
parentCid: 'hidden-sp-thread',
postCid: 'hidden-sp-thread',
timestamp: 101,
} as Comment,
'hidden-sp-thread': {
cid: 'hidden-sp-thread',
communityAddress: SPORTS_PUBLIC_KEY,
postCid: 'hidden-sp-thread',
timestamp: 102,
} as Comment,
};
await act(async () => {
root.render(createElement(Result, { communityAddresses: ['sports-posting.bso'] }));
});
expect(container.querySelector('[data-testid="hidden-cids"]')?.textContent).toBe('hidden-sp-thread');
});
it('includes hidden threads from every board in a multiboard scope', async () => {
testState.account = { blockedCids: { 'hidden-mu-thread': true, 'hidden-other-thread': true, 'hidden-sp-thread': true } };
testState.commentsByCid = {
'hidden-mu-thread': {
cid: 'hidden-mu-thread',
communityAddress: 'music-posting.eth',
postCid: 'hidden-mu-thread',
timestamp: 100,
} as Comment,
'hidden-other-thread': {
cid: 'hidden-other-thread',
communityAddress: 'other-board.eth',
postCid: 'hidden-other-thread',
timestamp: 101,
} as Comment,
'hidden-sp-thread': {
cid: 'hidden-sp-thread',
communityAddress: SPORTS_PUBLIC_KEY,
postCid: 'hidden-sp-thread',
timestamp: 102,
} as Comment,
};
await act(async () => {
root.render(createElement(Result, { communityAddresses: ['music-posting.eth', 'sports-posting.bso'] }));
});
expect(container.querySelector('[data-testid="hidden-cids"]')?.textContent).toBe('hidden-sp-thread,hidden-mu-thread');
});
it('uses candidate comments from the current feed when the blocked cid lookup has not loaded the comment', async () => {
testState.account = { blockedCids: { 'hidden-mu-thread': true } };
testState.commentsByCid = {};
await act(async () => {
root.render(
createElement(Result, {
candidateComments: [
{
cid: 'hidden-mu-thread',
communityAddress: 'music-posting.eth',
postCid: 'hidden-mu-thread',
timestamp: 100,
} as Comment,
],
communityAddresses: ['music-posting.eth'],
}),
);
});
expect(container.querySelector('[data-testid="hidden-cids"]')?.textContent).toBe('hidden-mu-thread');
});
});
+132
View File
@@ -0,0 +1,132 @@
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 useHide from '../use-hide';
import useHiddenCatalogThreadsStore from '../../stores/use-hidden-catalog-threads-store';
(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(() => ({
account: { id: 'account-1', blockedCids: {} as Record<string, boolean> },
blockCidMock: vi.fn(),
unblockCidMock: vi.fn(),
}));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useAccount: () => testState.account,
useBlock: ({ cid }: { cid?: string }) => ({
blocked: Boolean(cid && testState.account.blockedCids[cid]),
error: undefined,
errors: [],
state: 'ready',
}),
}));
vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts', () => ({
default: {
getState: () => ({
accounts: { [testState.account.id]: testState.account },
accountsActions: {
blockCid: testState.blockCidMock,
unblockCid: testState.unblockCidMock,
},
activeAccountId: testState.account.id,
}),
},
}));
const HideButton = ({ cid, comment }: { cid: string; comment?: { cid: string; communityAddress?: string; postCid?: string } }) => {
const { hide, hidden, unhide } = useHide({ cid, comment });
return (
<div>
<button data-hidden={hidden ? 'true' : 'false'} data-testid='hide' type='button' onClick={hide}>
hide {cid}
</button>
<button data-hidden={hidden ? 'true' : 'false'} data-testid='unhide' type='button' onClick={unhide}>
unhide {cid}
</button>
</div>
);
};
let container: HTMLDivElement;
let root: Root;
describe('useHide', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.account = { id: 'account-1', blockedCids: {} };
testState.blockCidMock.mockImplementation(async (cid: string) => {
testState.account = {
...testState.account,
blockedCids: { ...testState.account.blockedCids, [cid]: true },
};
});
testState.unblockCidMock.mockImplementation(async (cid: string) => {
const blockedCids = { ...testState.account.blockedCids };
delete blockedCids[cid];
testState.account = { ...testState.account, blockedCids };
});
useHiddenCatalogThreadsStore.setState({ hiddenCommentsByCid: {}, scopeHiddenThreadsCounts: {}, shownScopeKey: null });
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
useHiddenCatalogThreadsStore.setState({ hiddenCommentsByCid: {}, scopeHiddenThreadsCounts: {}, shownScopeKey: null });
});
it('hides the current cid after a reused component rerenders for another post', async () => {
await act(async () => {
root.render(createElement(HideButton, { cid: 'first-thread' }));
});
await act(async () => {
root.render(createElement(HideButton, { cid: 'second-thread' }));
});
await act(async () => {
container.querySelector<HTMLButtonElement>('[data-testid="hide"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.blockCidMock).toHaveBeenCalledTimes(1);
expect(testState.blockCidMock).toHaveBeenCalledWith('second-thread');
expect(testState.account.blockedCids).toEqual({ 'second-thread': true });
});
it('unhides the current cid and skips duplicate account writes', async () => {
testState.account = { id: 'account-1', blockedCids: { 'hidden-thread': true } };
await act(async () => {
root.render(createElement(HideButton, { cid: 'hidden-thread' }));
});
expect(container.querySelector<HTMLButtonElement>('[data-testid="unhide"]')?.dataset.hidden).toBe('true');
await act(async () => {
container.querySelector<HTMLButtonElement>('[data-testid="hide"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
container.querySelector<HTMLButtonElement>('[data-testid="unhide"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.blockCidMock).not.toHaveBeenCalled();
expect(testState.unblockCidMock).toHaveBeenCalledWith('hidden-thread');
expect(testState.account.blockedCids).toEqual({});
expect(useHiddenCatalogThreadsStore.getState().hiddenCommentsByCid['hidden-thread']).toBeUndefined();
});
it('remembers the hidden comment so catalog counters can resolve it immediately', async () => {
const comment = { cid: 'remembered-thread', communityAddress: 'music-posting.eth', postCid: 'remembered-thread' };
await act(async () => {
root.render(createElement(HideButton, { cid: 'remembered-thread', comment }));
});
await act(async () => {
container.querySelector<HTMLButtonElement>('[data-testid="hide"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(useHiddenCatalogThreadsStore.getState().hiddenCommentsByCid['remembered-thread']).toEqual(comment);
});
});
@@ -0,0 +1,162 @@
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 type { Comment } from '@bitsocial/bitsocial-react-hooks';
import communitiesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities';
import communitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import usePruneHiddenCatalogThreads from '../use-prune-hidden-catalog-threads';
(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(() => ({
account: { id: 'account-1', blockedCids: {} as Record<string, boolean> },
unblockCidMock: vi.fn(),
}));
vi.mock('@bitsocial/bitsocial-react-hooks', async () => {
const actual = await vi.importActual<typeof import('@bitsocial/bitsocial-react-hooks')>('@bitsocial/bitsocial-react-hooks');
return {
...actual,
useAccount: () => testState.account,
};
});
vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts', () => ({
default: {
getState: () => ({
accounts: { [testState.account.id]: testState.account },
accountsActions: {
unblockCid: testState.unblockCidMock,
},
activeAccountId: testState.account.id,
}),
},
}));
vi.mock('../use-directories', async () => {
const actual = await vi.importActual<typeof import('../use-directories')>('../use-directories');
return {
...actual,
useDirectories: () => [{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' }],
};
});
const hiddenThread = {
cid: 'hidden-mu-thread',
communityAddress: 'music-posting.eth',
postCid: 'hidden-mu-thread',
title: 'hidden',
} as Comment;
const visibleThread = {
cid: 'visible-mu-thread',
communityAddress: 'music-posting.eth',
postCid: 'visible-mu-thread',
title: 'visible',
} as Comment;
const PruneHarness = ({ enabled = true, hiddenThreadCandidates = [hiddenThread] }: { enabled?: boolean; hiddenThreadCandidates?: Comment[] }) => {
usePruneHiddenCatalogThreads({
communityAddress: 'music-posting.eth',
enabled,
hiddenThreadCandidates,
sortType: 'new',
});
return null;
};
const setRawBoardPage = (comments: Comment[], nextCid?: string) => {
communitiesStore.setState({
communities: {
'music-posting.eth': {
address: 'music-posting.eth',
posts: {
pageCids: {
new: 'raw-page-1',
},
},
updatedAt: 1,
},
},
});
communitiesPagesStore.setState({
communitiesPages: {
'raw-page-1': {
comments,
nextCid,
},
},
});
};
const flushEffects = async () => {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
};
let container: HTMLDivElement;
let root: Root;
describe('usePruneHiddenCatalogThreads', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.account = { id: 'account-1', blockedCids: { [hiddenThread.cid]: true } };
testState.unblockCidMock.mockImplementation(async (cid: string) => {
const blockedCids = { ...testState.account.blockedCids };
delete blockedCids[cid];
testState.account = { ...testState.account, blockedCids };
});
communitiesStore.setState({ communities: {} });
communitiesPagesStore.setState({ communitiesPages: {}, comments: {} });
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
communitiesStore.setState({ communities: {} });
communitiesPagesStore.setState({ communitiesPages: {}, comments: {} });
});
it('does not unhide a thread just because the visible feed filtered it out', async () => {
setRawBoardPage([hiddenThread, visibleThread]);
await act(async () => {
root.render(createElement(PruneHarness));
});
await flushEffects();
expect(testState.unblockCidMock).not.toHaveBeenCalled();
expect(testState.account.blockedCids).toEqual({ [hiddenThread.cid]: true });
});
it('unhides a stale hidden thread when a fully loaded raw board page proves it is gone', async () => {
setRawBoardPage([visibleThread]);
await act(async () => {
root.render(createElement(PruneHarness));
});
await flushEffects();
expect(testState.unblockCidMock).toHaveBeenCalledWith(hiddenThread.cid);
expect(testState.account.blockedCids).toEqual({});
});
it('waits for the entire raw board page chain before pruning', async () => {
setRawBoardPage([visibleThread], 'raw-page-2');
await act(async () => {
root.render(createElement(PruneHarness));
});
await flushEffects();
expect(testState.unblockCidMock).not.toHaveBeenCalled();
expect(testState.account.blockedCids).toEqual({ [hiddenThread.cid]: true });
});
});
+208
View File
@@ -0,0 +1,208 @@
import { useMemo } from 'react';
import { type Comment, useAccount, useComments } from '@bitsocial/bitsocial-react-hooks';
import { isCommentArchived } from '../lib/utils/comment-moderation-utils';
import { getCommentCommunityAddress } from '../lib/utils/comment-utils';
import useHiddenCatalogThreadsStore from '../stores/use-hidden-catalog-threads-store';
import { findDirectoryByAddress, normalizeBoardAddress, useDirectories, type DirectoryCommunity } from './use-directories';
type HiddenCatalogThreadsOptions = {
candidateComments?: readonly Comment[];
communityAddresses: readonly string[];
sortType: 'active' | 'new';
};
type HiddenCatalogThreadsResult = {
hiddenCatalogThreads: Comment[];
hiddenThreadCandidates: Comment[];
isLoadingHiddenCatalogThreads: boolean;
scopeKey: string;
};
const getHiddenCatalogThreadsScopeKey = (communityAddresses: readonly string[]): string => communityAddresses.filter(Boolean).slice().sort().join('\u0000');
const addAddressKeys = (keys: Set<string>, address: string | undefined) => {
if (!address) {
return;
}
keys.add(address);
keys.add(normalizeBoardAddress(address));
};
export const getBoardAddressKeys = (address: string | undefined, directories: DirectoryCommunity[]): Set<string> => {
const keys = new Set<string>();
addAddressKeys(keys, address);
const directory = findDirectoryByAddress(directories, address);
if (directory) {
addAddressKeys(keys, directory.address);
addAddressKeys(keys, directory.name);
addAddressKeys(keys, directory.publicKey);
addAddressKeys(keys, directory.directoryCode);
}
keys.delete('');
return keys;
};
const getScopeAddressKeys = (communityAddresses: readonly string[], directories: DirectoryCommunity[]): Set<string> => {
const keys = new Set<string>();
for (const communityAddress of communityAddresses) {
for (const key of getBoardAddressKeys(communityAddress, directories)) {
keys.add(key);
}
}
return keys;
};
export const isBoardAddressInScope = (address: string | undefined, scopeAddressKeys: Set<string>, directories: DirectoryCommunity[]): boolean => {
if (!address || scopeAddressKeys.size === 0) {
return false;
}
for (const key of getBoardAddressKeys(address, directories)) {
if (scopeAddressKeys.has(key)) {
return true;
}
}
return false;
};
const getBlockedCidList = (blockedCids: { [cid: string]: boolean | undefined } | undefined): string[] =>
Object.entries(blockedCids || {})
.filter(([, blocked]) => blocked)
.map(([cid]) => cid)
.sort();
const isThreadPost = (comment: Comment): boolean => {
const { cid, parentCid, postCid } = comment || {};
return Boolean(cid && !parentCid && (!postCid || postCid === cid));
};
const getTimestamp = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
const sortHiddenThreads = (threads: Comment[], sortType: 'active' | 'new'): Comment[] =>
[...threads].sort((a, b) => {
if (a.pinned !== b.pinned) {
return a.pinned ? -1 : 1;
}
const activeTimestampDifference = sortType === 'active' ? getTimestamp(b.lastReplyTimestamp || b.timestamp) - getTimestamp(a.lastReplyTimestamp || a.timestamp) : 0;
if (activeTimestampDifference !== 0) {
return activeTimestampDifference;
}
const timestampDifference = getTimestamp(b.timestamp) - getTimestamp(a.timestamp);
if (timestampDifference !== 0) {
return timestampDifference;
}
return String(a.cid || '').localeCompare(String(b.cid || ''));
});
const getHiddenThreadCandidates = ({
blockedCidList,
comments,
communityAddresses,
directories,
}: {
blockedCidList: readonly string[];
comments: readonly (Comment | undefined)[];
communityAddresses: readonly string[];
directories: DirectoryCommunity[];
}): Comment[] => {
if (blockedCidList.length === 0 || communityAddresses.length === 0) {
return [];
}
const blockedCidSet = new Set(blockedCidList);
const scopeAddressKeys = getScopeAddressKeys(communityAddresses, directories);
const seenCids = new Set<string>();
const candidates: Comment[] = [];
for (const comment of comments) {
const cid = comment?.cid;
if (!comment || !cid || seenCids.has(cid) || !blockedCidSet.has(cid)) {
continue;
}
if (!isThreadPost(comment)) {
continue;
}
if (!isBoardAddressInScope(getCommentCommunityAddress(comment), scopeAddressKeys, directories)) {
continue;
}
seenCids.add(cid);
candidates.push(comment);
}
return candidates;
};
const mergeCommentsByCid = (comments: readonly (Comment | undefined)[], candidateComments: readonly Comment[]): (Comment | undefined)[] => {
if (candidateComments.length === 0) {
return [...comments];
}
const mergedComments = [...comments];
const seenCids = new Set(comments.map((comment) => comment?.cid).filter((cid): cid is string => typeof cid === 'string'));
for (const comment of candidateComments) {
const cid = comment?.cid;
if (!cid || seenCids.has(cid)) {
continue;
}
seenCids.add(cid);
mergedComments.push(comment);
}
return mergedComments;
};
const getHiddenCatalogThreads = (hiddenThreadCandidates: readonly Comment[], sortType: 'active' | 'new'): Comment[] =>
sortHiddenThreads(
hiddenThreadCandidates.filter((comment) => !isCommentArchived(comment)),
sortType,
);
const useHiddenCatalogThreads = ({ candidateComments = [], communityAddresses, sortType }: HiddenCatalogThreadsOptions): HiddenCatalogThreadsResult => {
const account = useAccount();
const directories = useDirectories();
const hiddenCommentsByCid = useHiddenCatalogThreadsStore((state) => state.hiddenCommentsByCid);
const blockedCidList = useMemo(() => getBlockedCidList(account?.blockedCids), [account?.blockedCids]);
const { comments, state } = useComments({
autoUpdate: false,
commentCids: blockedCidList,
});
const rememberedHiddenComments = useMemo(
() => blockedCidList.map((cid) => hiddenCommentsByCid[cid]).filter((comment): comment is Comment => Boolean(comment)),
[blockedCidList, hiddenCommentsByCid],
);
const candidateCommentList = useMemo(
() => mergeCommentsByCid(mergeCommentsByCid(comments, rememberedHiddenComments), candidateComments),
[candidateComments, comments, rememberedHiddenComments],
);
const hiddenThreadCandidates = useMemo(
() =>
getHiddenThreadCandidates({
blockedCidList,
comments: candidateCommentList,
communityAddresses,
directories,
}),
[blockedCidList, candidateCommentList, communityAddresses, directories],
);
const hiddenCatalogThreads = useMemo(() => getHiddenCatalogThreads(hiddenThreadCandidates, sortType), [hiddenThreadCandidates, sortType]);
const scopeKey = useMemo(() => getHiddenCatalogThreadsScopeKey(communityAddresses), [communityAddresses]);
return useMemo(
() => ({
hiddenCatalogThreads,
hiddenThreadCandidates,
isLoadingHiddenCatalogThreads: blockedCidList.length > 0 && state !== 'succeeded',
scopeKey,
}),
[blockedCidList.length, hiddenCatalogThreads, hiddenThreadCandidates, scopeKey, state],
);
};
export default useHiddenCatalogThreads;
+80 -48
View File
@@ -1,59 +1,91 @@
import { useCallback, useMemo } from 'react';
import { create } from 'zustand';
import localForageLru from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru/index.js';
import { useAccount, useBlock } from '@bitsocial/bitsocial-react-hooks';
import type { Comment } from '@bitsocial/bitsocial-react-hooks';
import accountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts';
import useHiddenCatalogThreadsStore from '../stores/use-hidden-catalog-threads-store';
interface HideStoreState {
hiddenCids: { [key: string]: boolean };
hide: (cid: string) => void;
unhide: (cid: string) => void;
}
export type HiddenCidLookup = { [cid: string]: boolean | undefined };
const hideStore = localForageLru.createInstance({
name: 'hideStore',
size: 1000,
});
const useHideStore = create<HideStoreState>((set) => ({
hiddenCids: {},
hide: (cid: string) => {
set((state) => ({
hiddenCids: { ...state.hiddenCids, [cid]: true },
}));
hideStore.setItem(cid, true);
},
unhide: (cid: string) => {
set((state) => {
const newHiddenCids = { ...state.hiddenCids };
delete newHiddenCids[cid];
return { hiddenCids: newHiddenCids };
});
hideStore.removeItem(cid);
},
}));
const initializeHideStore = async () => {
const entries: [string, boolean][] = await hideStore.entries();
const hiddenCids: { [key: string]: boolean } = {};
entries.forEach(([key, value]) => {
hiddenCids[key] = value;
});
useHideStore.setState((state) => ({
hiddenCids: { ...hiddenCids, ...state.hiddenCids },
}));
type CommentWithCid = {
cid?: string;
};
initializeHideStore();
export const isCidHidden = (hiddenCids: HiddenCidLookup | undefined, cid?: string): boolean => Boolean(cid && hiddenCids?.[cid]);
const useHide = ({ cid }: { cid: string }) => {
const hidden = useHideStore((state) => !!state.hiddenCids[cid]);
const hide = useHideStore((state) => state.hide);
const unhide = useHideStore((state) => state.unhide);
export const filterHiddenComments = <T extends CommentWithCid>(comments: readonly T[], hiddenCids: HiddenCidLookup | undefined): T[] =>
comments.filter((comment) => !isCidHidden(hiddenCids, comment?.cid));
const hideCallback = useCallback(() => hide(cid), [hide, cid]);
const unhideCallback = useCallback(() => unhide(cid), [unhide, cid]);
export const useHiddenCids = (): HiddenCidLookup => {
const account = useAccount();
return useMemo(() => account?.blockedCids || {}, [account?.blockedCids]);
};
return useMemo(() => ({ hidden, hide: hideCallback, unhide: unhideCallback }), [hidden, hideCallback, unhideCallback]);
const shouldLogHideActionError = (cid: string, expectedHidden: boolean): boolean => {
const { accounts, activeAccountId } = accountsStore.getState();
if (!activeAccountId) {
return true;
}
return Boolean(accounts?.[activeAccountId]?.blockedCids?.[cid]) !== expectedHidden;
};
const getCurrentAccountHiddenState = (cid: string): boolean => {
const { accounts, activeAccountId } = accountsStore.getState();
return Boolean(activeAccountId && accounts?.[activeAccountId]?.blockedCids?.[cid]);
};
const useHide = ({ cid, comment }: { cid: string; comment?: Comment }) => {
const account = useAccount();
const { error, errors, state } = useBlock({ cid: cid || undefined });
const hidden = isCidHidden(account?.blockedCids, cid);
const rememberHiddenComment = useHiddenCatalogThreadsStore((state) => state.rememberHiddenComment);
const forgetHiddenComment = useHiddenCatalogThreadsStore((state) => state.forgetHiddenComment);
const hide = useCallback(() => {
if (!cid) {
return;
}
if (getCurrentAccountHiddenState(cid)) {
rememberHiddenComment(comment);
return;
}
rememberHiddenComment(comment);
void accountsStore
.getState()
.accountsActions.blockCid(cid)
.catch((error: unknown) => {
if (!getCurrentAccountHiddenState(cid)) {
forgetHiddenComment(cid);
}
if (shouldLogHideActionError(cid, true)) {
console.error('Failed to hide post', error);
}
});
}, [cid, comment, forgetHiddenComment, rememberHiddenComment]);
const unhide = useCallback(() => {
if (!cid) {
return;
}
forgetHiddenComment(cid);
if (!getCurrentAccountHiddenState(cid)) {
return;
}
void accountsStore
.getState()
.accountsActions.unblockCid(cid)
.catch((error: unknown) => {
if (shouldLogHideActionError(cid, false)) {
console.error('Failed to unhide post', error);
}
});
}, [cid, forgetHiddenComment]);
return useMemo(() => ({ error, errors, hidden, hide, state, unhide }), [error, errors, hidden, hide, state, unhide]);
};
export default useHide;
@@ -0,0 +1,152 @@
import { useEffect, useMemo, useRef } from 'react';
import { useAccount, type Comment, type CommunitiesPages, type Community } from '@bitsocial/bitsocial-react-hooks';
import accountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts';
import communitiesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities';
import communitiesPagesStore, { getCommunityFirstPageCid, getCommunityPages } from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import { getCommentCommunityAddress } from '../lib/utils/comment-utils';
import { isCommentArchived } from '../lib/utils/comment-moderation-utils';
import { useDirectories } from './use-directories';
import { getBoardAddressKeys, isBoardAddressInScope } from './use-hidden-catalog-threads';
type UsePruneHiddenCatalogThreadsOptions = {
enabled: boolean;
hiddenThreadCandidates: readonly Comment[];
communityAddress: string | undefined;
sortType: 'active' | 'new';
};
type RawBoardCatalogState = {
isFullyLoaded: boolean;
rootThreadCids: Set<string>;
};
const EMPTY_RAW_BOARD_CATALOG_STATE: RawBoardCatalogState = {
isFullyLoaded: false,
rootThreadCids: new Set<string>(),
};
const addRootThreadCids = (cids: Set<string>, comments: readonly Comment[] | undefined) => {
for (const comment of comments || []) {
if (comment?.cid && !comment.parentCid) {
cids.add(comment.cid);
}
}
};
const getRawBoardCatalogState = ({
accountId,
communitiesPages,
community,
sortType,
}: {
accountId: string | undefined;
communitiesPages: CommunitiesPages;
community: Community | undefined;
sortType: 'active' | 'new';
}): RawBoardCatalogState => {
if (!community) {
return EMPTY_RAW_BOARD_CATALOG_STATE;
}
const rootThreadCids = new Set<string>();
const preloadedSortPage = community.posts?.pages?.[sortType];
addRootThreadCids(rootThreadCids, preloadedSortPage?.comments);
const firstPageCid = getCommunityFirstPageCid(community, sortType, 'posts');
const pages = firstPageCid ? getCommunityPages(community, sortType, communitiesPages, 'posts', accountId) : [];
for (const page of pages) {
addRootThreadCids(rootThreadCids, page?.comments);
}
if (pages.length > 0) {
return {
isFullyLoaded: !pages[pages.length - 1]?.nextCid,
rootThreadCids,
};
}
const pageCids = community.posts?.pageCids || {};
const hasPageCids = Object.keys(pageCids).length > 0;
const preloadedPages = Object.values(community.posts?.pages || {}) as Array<{ comments?: Comment[]; nextCid?: string }>;
const hasCompletePreloadedPage = !hasPageCids && preloadedPages.some((page) => Array.isArray(page?.comments)) && preloadedPages.every((page) => !page?.nextCid);
if (hasCompletePreloadedPage) {
for (const page of preloadedPages) {
addRootThreadCids(rootThreadCids, page?.comments);
}
}
return {
isFullyLoaded: hasCompletePreloadedPage,
rootThreadCids,
};
};
const usePruneHiddenCatalogThreads = ({ enabled, hiddenThreadCandidates, communityAddress, sortType }: UsePruneHiddenCatalogThreadsOptions) => {
const account = useAccount();
const directories = useDirectories();
const community = communitiesStore((state) => (communityAddress ? state.communities[communityAddress] : undefined));
const communitiesPages = communitiesPagesStore((state) => state.communitiesPages);
const pendingPruneCidsRef = useRef(new Set<string>());
const boardAddressKeys = useMemo(() => (enabled ? getBoardAddressKeys(communityAddress, directories) : new Set<string>()), [communityAddress, directories, enabled]);
const rawBoardCatalogState = useMemo(
() =>
enabled
? getRawBoardCatalogState({
accountId: account?.id,
communitiesPages,
community,
sortType,
})
: EMPTY_RAW_BOARD_CATALOG_STATE,
[account?.id, communitiesPages, community, enabled, sortType],
);
const removedHiddenThreadCids = useMemo(() => {
if (!enabled || boardAddressKeys.size === 0 || !rawBoardCatalogState.isFullyLoaded) {
return [];
}
return hiddenThreadCandidates
.filter((comment) => {
const cid = comment?.cid;
return (
cid &&
!isCommentArchived(comment) &&
isBoardAddressInScope(getCommentCommunityAddress(comment), boardAddressKeys, directories) &&
!rawBoardCatalogState.rootThreadCids.has(cid)
);
})
.map((comment) => comment.cid)
.filter((cid): cid is string => typeof cid === 'string')
.sort();
}, [boardAddressKeys, directories, enabled, hiddenThreadCandidates, rawBoardCatalogState]);
useEffect(() => {
if (!enabled || removedHiddenThreadCids.length === 0) {
return;
}
for (const cid of removedHiddenThreadCids) {
if (pendingPruneCidsRef.current.has(cid)) {
continue;
}
pendingPruneCidsRef.current.add(cid);
void accountsStore
.getState()
.accountsActions.unblockCid(cid)
.catch((error: unknown) => {
const { accounts, activeAccountId } = accountsStore.getState();
if (activeAccountId && accounts?.[activeAccountId]?.blockedCids?.[cid]) {
console.error('Failed to remove stale hidden thread from account', error);
}
})
.finally(() => {
pendingPruneCidsRef.current.delete(cid);
});
}
}, [enabled, removedHiddenThreadCids]);
};
export default usePruneHiddenCatalogThreads;
+2
View File
@@ -13,6 +13,7 @@ export type PostMenuProps = {
thumbnailUrl?: string;
deleted?: boolean;
removed?: boolean;
comment?: Comment;
};
export const selectPostMenuProps = (post?: Comment): PostMenuProps => {
@@ -30,5 +31,6 @@ export const selectPostMenuProps = (post?: Comment): PostMenuProps => {
thumbnailUrl: post?.thumbnailUrl,
deleted: post?.deleted,
removed: post?.removed,
comment: post,
};
};
@@ -0,0 +1,55 @@
import { create } from 'zustand';
import type { Comment } from '@bitsocial/bitsocial-react-hooks';
interface HiddenCatalogThreadsStore {
hiddenCommentsByCid: Record<string, Comment>;
scopeHiddenThreadsCounts: Record<string, number>;
shownScopeKey: string | null;
forgetHiddenComment: (cid: string) => void;
rememberHiddenComment: (comment: Comment | undefined) => void;
setScopeHiddenThreadsCount: (scopeKey: string, count: number) => void;
setShownScopeKey: (shownScopeKey: string | null) => void;
toggleShownScopeKey: (scopeKey: string) => void;
}
const useHiddenCatalogThreadsStore = create<HiddenCatalogThreadsStore>((set) => ({
hiddenCommentsByCid: {},
forgetHiddenComment: (cid) =>
set((state) => {
if (!cid || !state.hiddenCommentsByCid[cid]) {
return state;
}
const nextHiddenComments = { ...state.hiddenCommentsByCid };
delete nextHiddenComments[cid];
return { hiddenCommentsByCid: nextHiddenComments };
}),
rememberHiddenComment: (comment) =>
set((state) => {
const cid = comment?.cid;
if (!cid) {
return state;
}
return {
hiddenCommentsByCid: {
...state.hiddenCommentsByCid,
[cid]: comment,
},
};
}),
scopeHiddenThreadsCounts: {},
setScopeHiddenThreadsCount: (scopeKey, count) =>
set((state) => {
const nextCounts = { ...state.scopeHiddenThreadsCounts };
if (!scopeKey || count <= 0) {
delete nextCounts[scopeKey];
} else {
nextCounts[scopeKey] = count;
}
return { scopeHiddenThreadsCounts: nextCounts };
}),
shownScopeKey: null,
setShownScopeKey: (shownScopeKey) => set({ shownScopeKey }),
toggleShownScopeKey: (scopeKey) => set((state) => ({ shownScopeKey: state.shownScopeKey === scopeKey ? null : scopeKey })),
}));
export default useHiddenCatalogThreadsStore;
+265 -9
View File
@@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Catalog, { getCatalogRenderFeed, type CatalogProps } from '../catalog';
import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../../../lib/utils/time-filter-utils';
import useHiddenCatalogThreadsStore from '../../../stores/use-hidden-catalog-threads-store';
(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>;
@@ -16,6 +17,7 @@ type TestComment = {
pinned?: boolean;
communityAddress?: string;
deleted?: boolean;
parentCid?: string;
postCid?: string;
removed?: boolean;
state?: string;
@@ -33,17 +35,26 @@ type FilterItem = {
};
const testState = vi.hoisted(() => ({
account: { subscriptions: [] as string[] },
account: { blockedCids: {} as Record<string, boolean>, subscriptions: [] as string[] },
accountComments: [] as TestComment[],
accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>,
accountCommunityAddresses: [] as string[],
blockCidMock: vi.fn(),
commentsByCid: {} as Record<string, TestComment>,
directoryByAddress: {
'music-posting.eth': {
address: 'music-posting.eth',
features: { postsPerPage: 2 },
},
} as Record<string, { address: string; features?: Record<string, unknown> }>,
directories: [{ address: 'music-posting.eth', title: '/mu/ - Music' }] as Array<{ address: string; title?: string }>,
directories: [{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' }] as Array<{
address: string;
directoryCode?: string;
features?: Record<string, unknown>;
name?: string;
publicKey?: string;
title?: string;
}>,
feed: [] as TestComment[],
feedOptionsCalls: [] as Array<{ communitiesLength?: number; filterKey?: string; newerThan?: number; postsPerPage?: number; sortType?: string }>,
filterItems: [] as FilterItem[],
@@ -66,6 +77,7 @@ const testState = vi.hoisted(() => ({
setResetFunctionMock: vi.fn(),
showOPComment: true,
sortType: 'new' as 'active' | 'new',
unblockCidMock: vi.fn(),
virtuosoInitialScrollTops: [] as Array<number | undefined>,
windowWidth: 900,
community: {
@@ -168,6 +180,23 @@ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
};
},
useCommunity: () => testState.community,
useComments: ({ commentCids = [] }: { commentCids?: string[] } = {}) => ({
comments: commentCids.map((cid) => testState.commentsByCid[cid]),
state: 'succeeded',
}),
}));
vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts', () => ({
default: {
getState: () => ({
accounts: { account: testState.account },
accountsActions: {
blockCid: testState.blockCidMock,
unblockCid: testState.unblockCidMock,
},
activeAccountId: 'account',
}),
},
}));
vi.mock('react-virtuoso', () => ({
@@ -207,8 +236,21 @@ vi.mock('react-virtuoso', () => ({
vi.mock('../../../hooks/use-directories', () => ({
useDirectories: () => testState.directories,
useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryByAddress[address] : undefined),
findDirectoryByAddress: (directories: Array<{ address: string; title?: string; directoryCode?: string }>, address: string | undefined) =>
directories.find((entry) => entry.address === address || entry.directoryCode === address || entry.title === address),
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
findDirectoryByAddress: (
directories: Array<{ address: string; title?: string; directoryCode?: string; name?: string; publicKey?: string }>,
address: string | undefined,
) => {
if (!address) {
return undefined;
}
const normalize = (value: string) => value.replace(/\.(bso|eth)$/, '');
return directories.find((entry) =>
[entry.address, entry.directoryCode, entry.name, entry.publicKey, entry.title].some(
(identifier) => identifier === address || (!!identifier && normalize(identifier) === normalize(address)),
),
);
},
}));
vi.mock('../../../hooks/use-board-feed-page-size', () => ({
@@ -262,8 +304,20 @@ vi.mock('../../../stores/use-catalog-filters-store', () => ({
}));
vi.mock('../../../components/catalog-row', () => ({
default: ({ estimatedHeight, row }: { estimatedHeight?: number; row: TestComment[] }) =>
createElement('div', { 'data-pretext-height': estimatedHeight, 'data-testid': 'catalog-row' }, `row:${row.map((comment) => comment.cid).join(',')}`),
default: ({ estimatedHeight, row, showHiddenPosts }: { estimatedHeight?: number; row: TestComment[]; showHiddenPosts?: boolean }) =>
createElement(
'div',
{ 'data-pretext-height': estimatedHeight, 'data-show-hidden': showHiddenPosts ? 'true' : 'false', 'data-testid': 'catalog-row' },
`row:${row.map((comment) => comment.cid).join(',')}`,
...row.map((comment) =>
createElement('button', {
'aria-label': `hide-${comment.cid}`,
key: `hide-${comment.cid}`,
onClick: () => testState.blockCidMock(comment.cid),
type: 'button',
}),
),
),
}));
vi.mock('../../../components/footer', () => ({
@@ -311,6 +365,8 @@ const LocationProbe = () => {
return null;
};
const getHiddenCatalogThreadsScopeKey = (communityAddresses: string[]) => communityAddresses.filter(Boolean).slice().sort().join('\u0000');
const flushEffects = async (count = 5) => {
for (let i = 0; i < count; i += 1) {
await act(async () => {
@@ -344,11 +400,22 @@ describe('Catalog', () => {
beforeEach(() => {
vi.clearAllMocks();
latestLocation = '';
testState.account = { subscriptions: [] };
testState.account = { blockedCids: {}, subscriptions: [] };
testState.accountComments = [];
testState.accountCommentsCalls = [];
testState.accountCommunityAddresses = [];
testState.directories = [{ address: 'music-posting.eth', title: '/mu/ - Music' }];
testState.blockCidMock.mockReset();
testState.blockCidMock.mockImplementation(async (cid: string) => {
testState.account = {
...testState.account,
blockedCids: {
...testState.account.blockedCids,
[cid]: true,
},
};
});
testState.commentsByCid = {};
testState.directories = [{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' }];
testState.directoryByAddress = {
'music-posting.eth': {
address: 'music-posting.eth',
@@ -371,6 +438,8 @@ describe('Catalog', () => {
testState.searchText = '';
testState.showOPComment = true;
testState.sortType = 'new';
testState.unblockCidMock.mockReset();
testState.unblockCidMock.mockResolvedValue(undefined);
testState.virtuosoInitialScrollTops = [];
testState.windowWidth = 900;
testState.community = {
@@ -387,6 +456,7 @@ describe('Catalog', () => {
document.title = 'before';
clearStableLastVisitTimeFilterName();
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now()));
useHiddenCatalogThreadsStore.setState({ hiddenCommentsByCid: {}, scopeHiddenThreadsCounts: {}, shownScopeKey: null });
container = document.createElement('div');
document.body.appendChild(container);
@@ -396,6 +466,7 @@ describe('Catalog', () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
useHiddenCatalogThreadsStore.setState({ hiddenCommentsByCid: {}, scopeHiddenThreadsCounts: {}, shownScopeKey: null });
clearStableLastVisitTimeFilterName();
localStorage.clear();
});
@@ -438,6 +509,191 @@ describe('Catalog', () => {
expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:board-post-1,board-post-2']);
});
it('removes hidden account-backed threads from the normal board catalog feed', async () => {
testState.account = { blockedCids: { 'hidden-board-post': true }, subscriptions: [] };
testState.feed = [
{ cid: 'visible-board-post', title: 'visible', communityAddress: 'music-posting.eth' },
{ cid: 'hidden-board-post', title: 'hidden', communityAddress: 'music-posting.eth', postCid: 'hidden-board-post' },
];
testState.commentsByCid = {
'hidden-board-post': { cid: 'hidden-board-post', title: 'hidden', communityAddress: 'music-posting.eth', postCid: 'hidden-board-post' },
};
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:visible-board-post']);
expect(container.textContent).not.toContain('hidden-board-post');
});
it('shows only hidden threads for the current board when hidden catalog mode is enabled', async () => {
testState.account = { blockedCids: { 'hidden-board-post': true }, subscriptions: [] };
testState.feed = [
{ cid: 'visible-board-post', title: 'visible', communityAddress: 'music-posting.eth' },
{ cid: 'hidden-board-post', title: 'hidden', communityAddress: 'music-posting.eth', postCid: 'hidden-board-post' },
];
testState.commentsByCid = {
'hidden-board-post': { cid: 'hidden-board-post', title: 'hidden', communityAddress: 'music-posting.eth', postCid: 'hidden-board-post' },
};
useHiddenCatalogThreadsStore.getState().setShownScopeKey(getHiddenCatalogThreadsScopeKey(['music-posting.eth']));
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
const rows = Array.from(container.querySelectorAll<HTMLElement>('[data-testid="catalog-row"]'));
expect(rows.map((element) => element.textContent)).toEqual(['row:hidden-board-post']);
expect(rows[0]?.dataset.showHidden).toBe('true');
});
it('keeps a thread hidden on its board when it was hidden from a multiboard catalog', async () => {
const sportsPublicKey = 'sports-public-key';
testState.account = { blockedCids: { 'hidden-sp-post': true }, subscriptions: [] };
testState.directories = [
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
{ address: 'sports-posting.bso', directoryCode: 'sp', publicKey: sportsPublicKey, title: '/sp/ - Sports' },
];
testState.directoryByAddress = {
'sports-posting.bso': { address: 'sports-posting.bso', features: { postsPerPage: 2 } },
};
testState.resolvedCommunityAddress = 'sports-posting.bso';
testState.feed = [{ cid: 'hidden-sp-post', title: 'sports hidden', communityAddress: sportsPublicKey, postCid: 'hidden-sp-post' }];
testState.commentsByCid = {
'hidden-sp-post': { cid: 'hidden-sp-post', title: 'sports hidden', communityAddress: sportsPublicKey, postCid: 'hidden-sp-post' },
};
await renderCatalog({ initialEntry: '/sp/catalog', routePath: '/:boardIdentifier/catalog' });
expect(container.querySelector('[data-testid="catalog-row"]')).toBeNull();
useHiddenCatalogThreadsStore.getState().setShownScopeKey(getHiddenCatalogThreadsScopeKey(['sports-posting.bso']));
await renderCatalog({ initialEntry: '/sp/catalog', routePath: '/:boardIdentifier/catalog' });
const hiddenRow = container.querySelector<HTMLElement>('[data-testid="catalog-row"]');
expect(hiddenRow?.textContent).toBe('row:hidden-sp-post');
expect(hiddenRow?.dataset.showHidden).toBe('true');
});
it('hides a board thread from its own catalog after the thread is hidden in all catalog', async () => {
const musicThread = { cid: 'mu-thread-hidden-from-all', title: 'music thread', communityAddress: 'music-posting.eth', postCid: 'mu-thread-hidden-from-all' };
testState.filteredDirectoryAddresses = ['music-posting.eth', 'sports-posting.eth'];
testState.feed = [musicThread, { cid: 'sports-thread', title: 'sports thread', communityAddress: 'sports-posting.eth', postCid: 'sports-thread' }];
testState.commentsByCid = {
'mu-thread-hidden-from-all': musicThread,
};
await renderCatalog({
catalogProps: { viewType: 'all' },
initialEntry: '/all/catalog',
routePath: '/all/*',
});
expect(container.textContent).toContain('mu-thread-hidden-from-all');
await act(async () => {
container.querySelector<HTMLButtonElement>('[aria-label="hide-mu-thread-hidden-from-all"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.account.blockedCids).toEqual({ 'mu-thread-hidden-from-all': true });
testState.resolvedCommunityAddress = 'music-posting.eth';
testState.feed = [musicThread, { cid: 'mu-visible-thread', title: 'other music thread', communityAddress: 'music-posting.eth', postCid: 'mu-visible-thread' }];
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:mu-visible-thread']);
expect(container.textContent).not.toContain('mu-thread-hidden-from-all');
});
it('counts and shows hidden board threads from the raw feed when blocked cid lookup has not resolved them', async () => {
const hiddenThread = {
cid: 'raw-feed-hidden-thread',
communityAddress: 'music-posting.eth',
postCid: 'raw-feed-hidden-thread',
title: 'raw feed hidden',
};
testState.account = { blockedCids: { 'raw-feed-hidden-thread': true }, subscriptions: [] };
testState.commentsByCid = {};
testState.feed = [hiddenThread, { cid: 'raw-feed-visible-thread', communityAddress: 'music-posting.eth', postCid: 'raw-feed-visible-thread', title: 'visible' }];
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
expect(useHiddenCatalogThreadsStore.getState().scopeHiddenThreadsCounts[getHiddenCatalogThreadsScopeKey(['music-posting.eth'])]).toBe(1);
expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:raw-feed-visible-thread']);
useHiddenCatalogThreadsStore.getState().setShownScopeKey(getHiddenCatalogThreadsScopeKey(['music-posting.eth']));
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
const hiddenRow = container.querySelector<HTMLElement>('[data-testid="catalog-row"]');
expect(hiddenRow?.textContent).toBe('row:raw-feed-hidden-thread');
expect(hiddenRow?.dataset.showHidden).toBe('true');
});
it('uses the multiboard board scope for hidden catalog mode', async () => {
const sportsPublicKey = 'sports-public-key';
testState.account = {
blockedCids: {
'hidden-mu-post': true,
'hidden-sp-post': true,
'hidden-other-post': true,
},
subscriptions: [],
};
testState.directories = [
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
{ address: 'sports-posting.bso', directoryCode: 'sp', publicKey: sportsPublicKey, title: '/sp/ - Sports' },
];
testState.filteredDirectoryAddresses = ['music-posting.eth', 'sports-posting.bso'];
testState.feed = [
{ cid: 'visible-mu-post', title: 'visible', communityAddress: 'music-posting.eth' },
{ cid: 'hidden-mu-post', title: 'music hidden', communityAddress: 'music-posting.eth', postCid: 'hidden-mu-post' },
{ cid: 'hidden-sp-post', title: 'sports hidden', communityAddress: sportsPublicKey, postCid: 'hidden-sp-post' },
];
testState.commentsByCid = {
'hidden-mu-post': { cid: 'hidden-mu-post', title: 'music hidden', communityAddress: 'music-posting.eth', postCid: 'hidden-mu-post', timestamp: 100 },
'hidden-other-post': { cid: 'hidden-other-post', title: 'other hidden', communityAddress: 'other-board.eth', postCid: 'hidden-other-post', timestamp: 101 },
'hidden-sp-post': { cid: 'hidden-sp-post', title: 'sports hidden', communityAddress: sportsPublicKey, postCid: 'hidden-sp-post', timestamp: 102 },
};
useHiddenCatalogThreadsStore.getState().setShownScopeKey(getHiddenCatalogThreadsScopeKey(['music-posting.eth', 'sports-posting.bso']));
await renderCatalog({
catalogProps: { viewType: 'all' },
initialEntry: '/all/catalog',
routePath: '/all/*',
});
expect(Array.from(container.querySelectorAll('[data-testid="catalog-row"]')).map((element) => element.textContent)).toEqual(['row:hidden-sp-post,hidden-mu-post']);
});
it('leaves hidden mode automatically when the last hidden thread is unhidden', async () => {
testState.account = { blockedCids: { 'hidden-board-post': true }, subscriptions: [] };
testState.feed = [{ cid: 'hidden-board-post', title: 'hidden', communityAddress: 'music-posting.eth', postCid: 'hidden-board-post' }];
testState.commentsByCid = {
'hidden-board-post': { cid: 'hidden-board-post', title: 'hidden', communityAddress: 'music-posting.eth', postCid: 'hidden-board-post' },
};
useHiddenCatalogThreadsStore.getState().setShownScopeKey(getHiddenCatalogThreadsScopeKey(['music-posting.eth']));
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
expect(container.querySelector('[data-testid="catalog-row"]')?.textContent).toBe('row:hidden-board-post');
testState.account = { blockedCids: {}, subscriptions: [] };
testState.commentsByCid = {};
testState.feed = [];
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
expect(useHiddenCatalogThreadsStore.getState().shownScopeKey).toBeNull();
});
it('does not prune hidden board threads just because the visible board feed filtered them out', async () => {
testState.account = { blockedCids: { 'removed-hidden-post': true }, subscriptions: [] };
testState.commentsByCid = {
'removed-hidden-post': { cid: 'removed-hidden-post', title: 'removed', communityAddress: 'music-posting.eth', postCid: 'removed-hidden-post' },
};
testState.feed = [{ cid: 'visible-board-post', title: 'visible', communityAddress: 'music-posting.eth' }];
testState.hasMore = false;
await renderCatalog({ initialEntry: '/mu/catalog', routePath: '/:boardIdentifier/catalog' });
expect(testState.unblockCidMock).not.toHaveBeenCalled();
});
it('prefers the immediate feed until deferred catalog rows exist', () => {
const immediateFeed = [{ cid: 'all-post', title: 'one', communityAddress: 'music-posting.eth' }];
@@ -634,7 +890,7 @@ describe('Catalog', () => {
});
it('shows the empty subscriptions state when there are no subscribed boards to browse', async () => {
testState.account = { subscriptions: [] };
testState.account = { blockedCids: {}, subscriptions: [] };
await renderCatalog({
catalogProps: { viewType: 'subs' },
+101 -37
View File
@@ -11,12 +11,16 @@ import { useFilteredDirectoryAddresses } from '../../hooks/use-filtered-director
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
import { useFeedStateString } from '../../hooks/use-state-string';
import useExpandedTimeFilter from '../../hooks/use-expanded-time-filter';
import { filterHiddenComments, isCidHidden, useHiddenCids } from '../../hooks/use-hide';
import useHiddenCatalogThreads from '../../hooks/use-hidden-catalog-threads';
import usePruneHiddenCatalogThreads from '../../hooks/use-prune-hidden-catalog-threads';
import { useSuggestionFeedLoader } from '../../hooks/use-suggestion-feed-loader';
import useTimeFilter from '../../hooks/use-time-filter';
import useIsMobile from '../../hooks/use-is-mobile';
import useWindowWidth from '../../hooks/use-window-width';
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 useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
import { getCommunityAddress, isDirectoryBoard, normalizeMultiboardFeedPath } from '../../lib/utils/route-utils';
@@ -276,6 +280,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const searchText = useCatalogFiltersStore((state) => state.searchText);
const account = useAccount();
const hiddenCids = useHiddenCids();
const subscriptions = account?.subscriptions;
const accountCommunityAddresses = useAccountCommunityAddresses();
const filteredDirectoryAddresses = useFilteredDirectoryAddresses();
@@ -331,6 +336,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const feedSortType = sortType === 'new' ? 'new' : 'active';
const catalogVirtualizationMode = useMemo(() => resolveCatalogVirtualizationMode(location.search, 'item-size'), [location.search]);
const themeKey = typeof document !== 'undefined' ? document.body.className : '';
const hadVisibleHiddenThreadsRef = useRef(false);
// Create a stable callback for filter matching
const handleFilterMatch = useCallback((filterIndex: number, cid: string, communityAddress: string) => {
@@ -369,6 +375,45 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
]);
const { feed, hasMore, loadMore, reset, expandTimeWindow } = useFeed(feedOptions);
const {
hiddenCatalogThreads,
hiddenThreadCandidates,
isLoadingHiddenCatalogThreads,
scopeKey: hiddenCatalogThreadsScopeKey,
} = useHiddenCatalogThreads({
candidateComments: feed,
communityAddresses,
sortType: feedSortType,
});
const hiddenThreadsCount = hiddenCatalogThreads.length;
const requestedShowHiddenThreads = useHiddenCatalogThreadsStore((state) => state.shownScopeKey === hiddenCatalogThreadsScopeKey);
const setShownHiddenThreadsScopeKey = useHiddenCatalogThreadsStore((state) => state.setShownScopeKey);
const setScopeHiddenThreadsCount = useHiddenCatalogThreadsStore((state) => state.setScopeHiddenThreadsCount);
const showHiddenThreads = requestedShowHiddenThreads && (hiddenThreadsCount > 0 || isLoadingHiddenCatalogThreads);
useEffect(() => {
setScopeHiddenThreadsCount(hiddenCatalogThreadsScopeKey, hiddenThreadsCount);
return () => setScopeHiddenThreadsCount(hiddenCatalogThreadsScopeKey, 0);
}, [hiddenCatalogThreadsScopeKey, hiddenThreadsCount, setScopeHiddenThreadsCount]);
useEffect(() => {
if (!requestedShowHiddenThreads) {
hadVisibleHiddenThreadsRef.current = false;
return;
}
if (hiddenThreadsCount > 0) {
hadVisibleHiddenThreadsRef.current = true;
return;
}
if (hadVisibleHiddenThreadsRef.current || !isLoadingHiddenCatalogThreads) {
setShownHiddenThreadsScopeKey(null);
}
}, [hiddenThreadsCount, isLoadingHiddenCatalogThreads, requestedShowHiddenThreads, setShownHiddenThreadsScopeKey]);
usePruneHiddenCatalogThreads({
communityAddress,
enabled: !isMultiboard && !hasMore && !hasActiveCatalogFiltering,
hiddenThreadCandidates,
sortType: feedSortType,
});
const visibleFeed = useMemo(() => filterHiddenComments(feed, hiddenCids), [feed, hiddenCids]);
const { currentTimeFilterName, currentTimeFilterSeconds, expandSuggestionTimeWindow } = useExpandedTimeFilter({
timeFilterName,
timeFilterSeconds: multiboardTimeFilterSeconds,
@@ -396,6 +441,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
filter: suggestionFilter,
newerThan: WEEK_IN_SECONDS,
});
const visibleWeeklyFeed = useMemo(() => filterHiddenComments(weeklyFeed, hiddenCids), [hiddenCids, weeklyFeed]);
const {
feed: monthlyFeed,
hasMore: monthlyFeedHasMore,
@@ -407,6 +453,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
filter: suggestionFilter,
newerThan: MONTH_IN_SECONDS,
});
const visibleMonthlyFeed = useMemo(() => filterHiddenComments(monthlyFeed, hiddenCids), [hiddenCids, monthlyFeed]);
const {
feed: yearlyFeed,
hasMore: yearlyFeedHasMore,
@@ -418,25 +465,26 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
filter: suggestionFilter,
newerThan: YEAR_IN_SECONDS,
});
const visibleYearlyFeed = useMemo(() => filterHiddenComments(yearlyFeed, hiddenCids), [hiddenCids, yearlyFeed]);
useSuggestionFeedLoader({
currentFeedLength: feed.length,
feedLength: weeklyFeed.length,
currentFeedLength: visibleFeed.length,
feedLength: visibleWeeklyFeed.length,
hasMore: weeklyFeedHasMore,
loadMore: loadMoreWeeklyFeed,
requestKey: `${suggestionRequestKeyBase}:1w`,
shouldLoad: shouldProbeWeeklyFeed,
});
useSuggestionFeedLoader({
currentFeedLength: feed.length,
feedLength: monthlyFeed.length,
currentFeedLength: visibleFeed.length,
feedLength: visibleMonthlyFeed.length,
hasMore: monthlyFeedHasMore,
loadMore: loadMoreMonthlyFeed,
requestKey: `${suggestionRequestKeyBase}:1m`,
shouldLoad: shouldProbeMonthlyFeed,
});
useSuggestionFeedLoader({
currentFeedLength: feed.length,
feedLength: yearlyFeed.length,
currentFeedLength: visibleFeed.length,
feedLength: visibleYearlyFeed.length,
hasMore: yearlyFeedHasMore,
loadMore: loadMoreYearlyFeed,
requestKey: `${suggestionRequestKeyBase}:1y`,
@@ -474,7 +522,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
cid &&
cid === postCid &&
commentCommunityAddress === communityAddress &&
!feedCids.has(cid);
!feedCids.has(cid) &&
!isCidHidden(hiddenCids, cid);
// If search is active, also check search conditions
if (basicConditions && searchText.trim()) {
@@ -487,30 +536,34 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
return basicConditions;
}),
[recentAccountComments, communityAddress, feedCids, searchText],
[recentAccountComments, communityAddress, feedCids, hiddenCids, searchText],
);
// show newest account comment at the top of the feed but after pinned posts
const combinedFeed = useMemo(() => {
const newFeed = [...feed];
const newFeed = [...visibleFeed];
const lastPinnedIndex = newFeed.map((post) => post.pinned).lastIndexOf(true);
if (filteredComments.length > 0) {
newFeed.splice(lastPinnedIndex + 1, 0, ...filteredComments);
}
return newFeed;
}, [feed, filteredComments]);
}, [visibleFeed, filteredComments]);
const cappedFeed = useMemo(
() => (effectiveInfiniteScroll ? combinedFeed : combinedFeed.slice(0, boardPostsPerPage * maxGuiPages)),
[effectiveInfiniteScroll, combinedFeed, boardPostsPerPage, maxGuiPages],
);
const moreThreadsSuggestion = useMemo(
() => (isMultiboard ? getTimeFilterSuggestion(feed.length, weeklyFeed.length, monthlyFeed.length, yearlyFeed.length, currentTimeFilterSeconds) : null),
[currentTimeFilterSeconds, feed.length, isMultiboard, monthlyFeed.length, weeklyFeed.length, yearlyFeed.length],
() =>
isMultiboard
? getTimeFilterSuggestion(visibleFeed.length, visibleWeeklyFeed.length, visibleMonthlyFeed.length, visibleYearlyFeed.length, currentTimeFilterSeconds)
: null,
[currentTimeFilterSeconds, isMultiboard, visibleFeed.length, visibleMonthlyFeed.length, visibleWeeklyFeed.length, visibleYearlyFeed.length],
);
const moreThreadsSuggestionPathname = isInAllView ? '/all/catalog' : isInSubscriptionsView ? '/subs/catalog' : isInModView ? '/mod/catalog' : null;
const sortedFeed = useMemo(() => sortCatalogFeedForDisplay(cappedFeed, sortType), [cappedFeed, sortType]);
const catalogBaseFeed = showHiddenThreads ? hiddenCatalogThreads : cappedFeed;
const sortedFeed = useMemo(() => sortCatalogFeedForDisplay(catalogBaseFeed, sortType), [catalogBaseFeed, sortType]);
useEffect(() => {
if (filteredComments.length > 0 && !resetTriggeredRef.current) {
@@ -529,6 +582,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const community = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined);
const { error, shortAddress, state, title } = community || {};
const footerHasMore = showHiddenThreads ? isLoadingHiddenCatalogThreads : hasMore;
const footerCombinedFeedLength = catalogBaseFeed.length;
const footerMoreThreadsSuggestion = showHiddenThreads ? null : moreThreadsSuggestion;
const footerShowLoadingEllipsis = showHiddenThreads ? isLoadingHiddenCatalogThreads : effectiveInfiniteScroll;
// Memoize footer component to preserve identity across renders (Virtuoso optimization)
// Note: useFeedStateString is called inside CatalogFooter to isolate re-renders from backend state changes
@@ -538,14 +595,14 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
<>
<CatalogFooter
communityAddresses={communityAddresses}
hasMore={hasMore}
combinedFeedLength={cappedFeed.length}
hasMore={footerHasMore}
combinedFeedLength={footerCombinedFeedLength}
currentTimeFilterName={currentTimeFilterName}
moreThreadsSuggestion={moreThreadsSuggestion}
moreThreadsSuggestion={footerMoreThreadsSuggestion}
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
moreThreadsSuggestionSearch={location.search}
onExpandTimeWindow={expandSuggestionTimeWindow}
showLoadingEllipsis={effectiveInfiniteScroll}
showLoadingEllipsis={footerShowLoadingEllipsis}
/>
<PageFooterDesktop
firstRow={
@@ -570,10 +627,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
}),
[
communityAddresses,
hasMore,
cappedFeed.length,
footerHasMore,
footerCombinedFeedLength,
currentTimeFilterName,
moreThreadsSuggestion,
footerMoreThreadsSuggestion,
moreThreadsSuggestionPathname,
expandSuggestionTimeWindow,
communityAddress,
@@ -581,7 +638,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
isInSubscriptionsView,
isInModView,
location.search,
effectiveInfiniteScroll,
footerShowLoadingEllipsis,
],
);
const catalogFooter = useMemo(
@@ -589,14 +646,14 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
<>
<CatalogFooter
communityAddresses={communityAddresses}
hasMore={hasMore}
combinedFeedLength={cappedFeed.length}
hasMore={footerHasMore}
combinedFeedLength={footerCombinedFeedLength}
currentTimeFilterName={currentTimeFilterName}
moreThreadsSuggestion={moreThreadsSuggestion}
moreThreadsSuggestion={footerMoreThreadsSuggestion}
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
moreThreadsSuggestionSearch={location.search}
onExpandTimeWindow={expandSuggestionTimeWindow}
showLoadingEllipsis={effectiveInfiniteScroll}
showLoadingEllipsis={footerShowLoadingEllipsis}
/>
<PageFooterDesktop
firstRow={
@@ -620,10 +677,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
),
[
communityAddresses,
hasMore,
cappedFeed.length,
footerHasMore,
footerCombinedFeedLength,
currentTimeFilterName,
moreThreadsSuggestion,
footerMoreThreadsSuggestion,
moreThreadsSuggestionPathname,
expandSuggestionTimeWindow,
communityAddress,
@@ -631,10 +688,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
isInSubscriptionsView,
isInModView,
location.search,
effectiveInfiniteScroll,
footerShowLoadingEllipsis,
],
);
const isFeedLoaded = feed.length > 0 || state === 'failed';
const isFeedLoaded = feed.length > 0 || hiddenThreadsCount > 0 || state === 'failed';
// Process the feed to move "top" posts to the top (applied after display sort)
const processedFeed = useMemo(() => {
@@ -669,8 +726,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
return [...topPosts, ...regularPosts];
}, [sortedFeed, filterItems]);
const processedFeedMode = showHiddenThreads ? 'hidden' : 'visible';
const deferredProcessedFeed = useDeferredValue(processedFeed);
const catalogRenderFeed = getCatalogRenderFeed(processedFeed, deferredProcessedFeed);
const deferredProcessedFeedMode = useDeferredValue(processedFeedMode);
const catalogRenderFeed = getCatalogRenderFeed(processedFeed, deferredProcessedFeedMode === processedFeedMode ? deferredProcessedFeed : []);
const matchedFilterColors = useMemo(() => {
const nextMatchedFilterColors = new Map<string, string>();
@@ -739,7 +798,9 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const catalogViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 2400, top: 1200 } : { bottom: 900, top: 600 }) : { bottom: 1200, top: 1200 };
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
const virtuosoStateKey = feedCacheKey ? `${feedCacheKey}-${sortType}` : `${location.pathname}${location.search}-${sortType}-catalog`;
const virtuosoStateKey = feedCacheKey
? `${feedCacheKey}-${sortType}-${processedFeedMode}`
: `${location.pathname}${location.search}-${sortType}-${processedFeedMode}-catalog`;
const navigationType = useNavigationType();
const hasBeenVisibleRef = useRef(false);
@@ -773,8 +834,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
const lastVirtuosoState = shouldVirtualizeCatalog && navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined;
const renderCatalogRow = useCallback(
(index: number, row: Comment[]) => <CatalogRow estimatedHeight={rowHeightEstimates[index]} index={index} matchedFilterColors={matchedFilterColors} row={row} />,
[matchedFilterColors, rowHeightEstimates],
(index: number, row: Comment[]) => (
<CatalogRow estimatedHeight={rowHeightEstimates[index]} index={index} matchedFilterColors={matchedFilterColors} row={row} showHiddenPosts={showHiddenThreads} />
),
[matchedFilterColors, rowHeightEstimates, showHiddenThreads],
);
useEffect(() => {
@@ -812,7 +875,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
itemContent={renderCatalogRow}
useWindowScroll={true}
components={footerComponents}
endReached={effectiveInfiniteScroll && hasMore ? loadMore : undefined}
endReached={!showHiddenThreads && effectiveInfiniteScroll && footerHasMore ? loadMore : undefined}
ref={virtuosoRef}
restoreStateFrom={lastVirtuosoState}
initialScrollTop={lastVirtuosoState?.scrollTop}
@@ -826,6 +889,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
index={index}
matchedFilterColors={matchedFilterColors}
row={row}
showHiddenPosts={showHiddenThreads}
/>
))}
{catalogFooter}
@@ -837,8 +901,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
<div className={styles.footer}>
<CatalogLoading
communityAddresses={communityAddresses}
hasMore={hasMore}
combinedFeedLength={cappedFeed.length}
hasMore={footerHasMore}
combinedFeedLength={footerCombinedFeedLength}
state={state}
subscriptionsLength={isInSubscriptionsView ? subscriptions?.length || 0 : 1}
error={error}