diff --git a/src/components/board-buttons/__tests__/board-buttons.test.tsx b/src/components/board-buttons/__tests__/board-buttons.test.tsx index 6a4f0287..af052864 100644 --- a/src/components/board-buttons/__tests__/board-buttons.test.tsx +++ b/src/components/board-buttons/__tests__/board-buttons.test.tsx @@ -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, 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>, 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('[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('[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('[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('[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('[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)); diff --git a/src/components/board-buttons/board-buttons.module.css b/src/components/board-buttons/board-buttons.module.css index 1d8c84aa..06ef7b66 100644 --- a/src/components/board-buttons/board-buttons.module.css +++ b/src/components/board-buttons/board-buttons.module.css @@ -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; } diff --git a/src/components/board-buttons/board-buttons.tsx b/src/components/board-buttons/board-buttons.tsx index a107b335..11d5145a 100644 --- a/src/components/board-buttons/board-buttons.tsx +++ b/src/components/board-buttons/board-buttons.tsx @@ -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 ( + + — Hidden threads: {hiddenThreadsCount} [ + + ] + + ); +}; + export const UpdateButton = () => { const { t } = useTranslation(); const requestUpdate = useThreadLiveUpdatesStore((state) => state.requestUpdate); @@ -504,6 +565,14 @@ export const MobileBoardButtons = () => { {showBottomButton && } + {searchText ? ( {' '} @@ -705,6 +774,18 @@ export const DesktopBoardButtons = () => { )}{' '} [] + {isInCatalogView && ( + <> + {' '} + + + )} {!(isInAllView || isInSubscriptionsView) && ( <> {' '} diff --git a/src/components/catalog-row/__tests__/catalog-row.test.tsx b/src/components/catalog-row/__tests__/catalog-row.test.tsx index 9d28376e..5df18c9c 100644 --- a/src/components/catalog-row/__tests__/catalog-row.test.tsx +++ b/src/components/catalog-row/__tests__/catalog-row.test.tsx @@ -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 () => { diff --git a/src/components/catalog-row/catalog-row.tsx b/src/components/catalog-row/catalog-row.tsx index ddd218e6..507dc768 100644 --- a/src/components/catalog-row/catalog-row.tsx +++ b/src/components/catalog-row/catalog-row.tsx @@ -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 = ( -
- {hidden ? ( +
+ {shouldMaskPost ? ( ({t('hidden')}) ) : ( <> @@ -238,7 +239,7 @@ const CatalogPost = memo( <>
setHoveredCid(cid)} onMouseLeave={() => setHoveredCid(null)}> - {hidden ? ( + {shouldMaskPost ? ( @@ -247,7 +248,7 @@ const CatalogPost = memo( {shouldShowSnow() && hasThumbnail && }
(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; row: Comment[]; + showHiddenPosts?: boolean; } const CatalogRow = memo( - ({ estimatedHeight, matchedFilterColors, row }: CatalogRowProps) => { + ({ estimatedHeight, matchedFilterColors, row, showHiddenPosts = false }: CatalogRowProps) => { return (
{row.map((post, index) => ( - + ))}
); }, (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; } diff --git a/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx b/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx index 40b73927..22358759 100644 --- a/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx +++ b/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx @@ -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(); diff --git a/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx b/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx index 3d9b89b7..69b0c452 100644 --- a/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx +++ b/src/components/post-mobile/post-menu-mobile/post-menu-mobile.tsx @@ -51,6 +51,7 @@ async function copyUserIdSafe(address: string): Promise { 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) => {
- {cid && communityAddress && } + {cid && communityAddress && } {(isAccountCommentAuthor || canAttemptAuthorDelete) && cid && editMenuPost && } {cid && communityAddress && } {cid && } diff --git a/src/hooks/__tests__/use-hidden-catalog-threads.test.tsx b/src/hooks/__tests__/use-hidden-catalog-threads.test.tsx new file mode 100644 index 00000000..6bca088d --- /dev/null +++ b/src/hooks/__tests__/use-hidden-catalog-threads.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; + +const SPORTS_PUBLIC_KEY = '12D3KooWGJA6zN3Q63FtSgwNhtfA26Skdzdxz5X7A9PFfE4FBMGE'; + +const testState = vi.hoisted(() => ({ + account: { blockedCids: {} as Record }, + commentsByCid: {} as Record, + 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('../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 ( +
+ {hiddenCatalogThreads.map((thread) => thread.cid).join(',')} + {scopeKey} +
+ ); +}; + +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'); + }); +}); diff --git a/src/hooks/__tests__/use-hide.test.tsx b/src/hooks/__tests__/use-hide.test.tsx new file mode 100644 index 00000000..09e2328e --- /dev/null +++ b/src/hooks/__tests__/use-hide.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; + +const testState = vi.hoisted(() => ({ + account: { id: 'account-1', blockedCids: {} as Record }, + 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 ( +
+ + +
+ ); +}; + +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('[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('[data-testid="unhide"]')?.dataset.hidden).toBe('true'); + + await act(async () => { + container.querySelector('[data-testid="hide"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + container.querySelector('[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('[data-testid="hide"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(useHiddenCatalogThreadsStore.getState().hiddenCommentsByCid['remembered-thread']).toEqual(comment); + }); +}); diff --git a/src/hooks/__tests__/use-prune-hidden-catalog-threads.test.tsx b/src/hooks/__tests__/use-prune-hidden-catalog-threads.test.tsx new file mode 100644 index 00000000..83e36fb0 --- /dev/null +++ b/src/hooks/__tests__/use-prune-hidden-catalog-threads.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; + +const testState = vi.hoisted(() => ({ + account: { id: 'account-1', blockedCids: {} as Record }, + unblockCidMock: vi.fn(), +})); + +vi.mock('@bitsocial/bitsocial-react-hooks', async () => { + const actual = await vi.importActual('@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('../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 }); + }); +}); diff --git a/src/hooks/use-hidden-catalog-threads.ts b/src/hooks/use-hidden-catalog-threads.ts new file mode 100644 index 00000000..e26f9a1d --- /dev/null +++ b/src/hooks/use-hidden-catalog-threads.ts @@ -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, address: string | undefined) => { + if (!address) { + return; + } + + keys.add(address); + keys.add(normalizeBoardAddress(address)); +}; + +export const getBoardAddressKeys = (address: string | undefined, directories: DirectoryCommunity[]): Set => { + const keys = new Set(); + 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 => { + const keys = new Set(); + 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, 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(); + 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; diff --git a/src/hooks/use-hide.ts b/src/hooks/use-hide.ts index e24e3baf..ea64b724 100644 --- a/src/hooks/use-hide.ts +++ b/src/hooks/use-hide.ts @@ -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((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 = (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; diff --git a/src/hooks/use-prune-hidden-catalog-threads.ts b/src/hooks/use-prune-hidden-catalog-threads.ts new file mode 100644 index 00000000..845d1252 --- /dev/null +++ b/src/hooks/use-prune-hidden-catalog-threads.ts @@ -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; +}; + +const EMPTY_RAW_BOARD_CATALOG_STATE: RawBoardCatalogState = { + isFullyLoaded: false, + rootThreadCids: new Set(), +}; + +const addRootThreadCids = (cids: Set, 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(); + 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()); + const boardAddressKeys = useMemo(() => (enabled ? getBoardAddressKeys(communityAddress, directories) : new Set()), [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; diff --git a/src/lib/utils/post-menu-props.ts b/src/lib/utils/post-menu-props.ts index 11ea3dea..eb8397bd 100644 --- a/src/lib/utils/post-menu-props.ts +++ b/src/lib/utils/post-menu-props.ts @@ -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, }; }; diff --git a/src/stores/use-hidden-catalog-threads-store.ts b/src/stores/use-hidden-catalog-threads-store.ts new file mode 100644 index 00000000..d40a68eb --- /dev/null +++ b/src/stores/use-hidden-catalog-threads-store.ts @@ -0,0 +1,55 @@ +import { create } from 'zustand'; +import type { Comment } from '@bitsocial/bitsocial-react-hooks'; + +interface HiddenCatalogThreadsStore { + hiddenCommentsByCid: Record; + scopeHiddenThreadsCounts: Record; + 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((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; diff --git a/src/views/catalog/__tests__/catalog.test.tsx b/src/views/catalog/__tests__/catalog.test.tsx index b924c453..1b9ea3d5 100644 --- a/src/views/catalog/__tests__/catalog.test.tsx +++ b/src/views/catalog/__tests__/catalog.test.tsx @@ -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 | Promise }).act as (cb: () => void | Promise) => void | Promise; @@ -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, 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, directoryByAddress: { 'music-posting.eth': { address: 'music-posting.eth', features: { postsPerPage: 2 }, }, } as Record }>, - 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; + 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, 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('[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('[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('[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('[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' }, diff --git a/src/views/catalog/catalog.tsx b/src/views/catalog/catalog.tsx index 7578f75f..4f4f1afb 100644 --- a/src/views/catalog/catalog.tsx +++ b/src/views/catalog/catalog.tsx @@ -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, <> 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(); @@ -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(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[]) => , - [matchedFilterColors, rowHeightEstimates], + (index: number, row: Comment[]) => ( + + ), + [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,