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