mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
Merge branch 'codex/fix/hidden-catalog-redo'
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { DesktopBoardButtons, MobileBoardButtons } from '../board-buttons';
|
import { DesktopBoardButtons, MobileBoardButtons } from '../board-buttons';
|
||||||
import useThreadLiveUpdatesStore from '../../../stores/use-thread-live-updates-store';
|
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';
|
import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../../../lib/utils/time-filter-utils';
|
||||||
|
|
||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
@@ -17,6 +18,8 @@ type DirectoryEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const testState = vi.hoisted(() => ({
|
const testState = vi.hoisted(() => ({
|
||||||
|
account: { blockedCids: {} as Record<string, boolean>, subscriptions: [] as string[] },
|
||||||
|
accountCommunityAddresses: [] as string[],
|
||||||
accountComment: undefined as { communityAddress?: string } | undefined,
|
accountComment: undefined as { communityAddress?: string } | undefined,
|
||||||
alertThresholdUnit: 'minutes' as 'hours' | 'minutes',
|
alertThresholdUnit: 'minutes' as 'hours' | 'minutes',
|
||||||
alertThresholdValue: 5,
|
alertThresholdValue: 5,
|
||||||
@@ -28,6 +31,8 @@ const testState = vi.hoisted(() => ({
|
|||||||
enableInfiniteScroll: false,
|
enableInfiniteScroll: false,
|
||||||
filter: 'all' as 'all' | 'nsfw' | 'sfw',
|
filter: 'all' as 'all' | 'nsfw' | 'sfw',
|
||||||
filteredCount: 0,
|
filteredCount: 0,
|
||||||
|
filteredDirectoryAddresses: ['music-posting.eth', 'tech-posting.eth'] as string[],
|
||||||
|
hiddenThreadsByScope: {} as Record<string, Array<{ cid: string }>>,
|
||||||
imageSize: 'Small' as 'Small' | 'Large',
|
imageSize: 'Small' as 'Small' | 'Large',
|
||||||
isMobile: true,
|
isMobile: true,
|
||||||
linkCount: 3,
|
linkCount: 3,
|
||||||
@@ -73,7 +78,7 @@ vi.mock('react-router-dom', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
|
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
|
||||||
useAccount: () => undefined,
|
useAccount: () => testState.account,
|
||||||
useAccountComment: () => testState.accountComment,
|
useAccountComment: () => testState.accountComment,
|
||||||
useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.commentsByCid[commentCid] : undefined),
|
useComment: ({ commentCid }: { commentCid?: string }) => (commentCid ? testState.commentsByCid[commentCid] : undefined),
|
||||||
useSubscribe: () => ({
|
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', () => ({
|
vi.mock('../../../hooks/use-post-page-number', () => ({
|
||||||
usePostPageNumber: () => testState.pageNumber,
|
usePostPageNumber: () => testState.pageNumber,
|
||||||
}));
|
}));
|
||||||
@@ -117,10 +142,13 @@ vi.mock('../../../stores/use-feed-reset-store', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../stores/use-sorting-store', () => ({
|
vi.mock('../../../stores/use-sorting-store', () => ({
|
||||||
default: () => ({
|
default: (selector?: (state: { setSortType: typeof testState.setSortTypeMock; sortType: typeof testState.sortType }) => unknown) => {
|
||||||
setSortType: testState.setSortTypeMock,
|
const state = {
|
||||||
sortType: testState.sortType,
|
setSortType: testState.setSortTypeMock,
|
||||||
}),
|
sortType: testState.sortType,
|
||||||
|
};
|
||||||
|
return selector ? selector(state) : state;
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../stores/use-all-feed-filter-store', () => ({
|
vi.mock('../../../stores/use-all-feed-filter-store', () => ({
|
||||||
@@ -223,9 +251,13 @@ const setTrackedInputValue = (input: HTMLInputElement, value: string) => {
|
|||||||
descriptor?.set?.call(input, value);
|
descriptor?.set?.call(input, value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getScopeKey = (communityAddresses: string[]) => communityAddresses.filter(Boolean).slice().sort().join('\u0000');
|
||||||
|
|
||||||
describe('BoardButtons', () => {
|
describe('BoardButtons', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
testState.account = { blockedCids: {}, subscriptions: [] };
|
||||||
|
testState.accountCommunityAddresses = [];
|
||||||
testState.accountComment = undefined;
|
testState.accountComment = undefined;
|
||||||
testState.alertThresholdUnit = 'minutes';
|
testState.alertThresholdUnit = 'minutes';
|
||||||
testState.alertThresholdValue = 5;
|
testState.alertThresholdValue = 5;
|
||||||
@@ -237,6 +269,8 @@ describe('BoardButtons', () => {
|
|||||||
testState.enableInfiniteScroll = false;
|
testState.enableInfiniteScroll = false;
|
||||||
testState.filter = 'all';
|
testState.filter = 'all';
|
||||||
testState.filteredCount = 0;
|
testState.filteredCount = 0;
|
||||||
|
testState.filteredDirectoryAddresses = ['music-posting.eth', 'tech-posting.eth'];
|
||||||
|
testState.hiddenThreadsByScope = {};
|
||||||
testState.imageSize = 'Small';
|
testState.imageSize = 'Small';
|
||||||
testState.isMobile = true;
|
testState.isMobile = true;
|
||||||
testState.linkCount = 3;
|
testState.linkCount = 3;
|
||||||
@@ -248,6 +282,7 @@ describe('BoardButtons', () => {
|
|||||||
testState.subscribed = false;
|
testState.subscribed = false;
|
||||||
testState.viewMode = 'compact';
|
testState.viewMode = 'compact';
|
||||||
useThreadLiveUpdatesStore.getState().resetState();
|
useThreadLiveUpdatesStore.getState().resetState();
|
||||||
|
useHiddenCatalogThreadsStore.setState({ hiddenCommentsByCid: {}, scopeHiddenThreadsCounts: {}, shownScopeKey: null });
|
||||||
clearStableLastVisitTimeFilterName();
|
clearStableLastVisitTimeFilterName();
|
||||||
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now()));
|
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now()));
|
||||||
Object.defineProperty(globalThis, 'alert', {
|
Object.defineProperty(globalThis, 'alert', {
|
||||||
@@ -274,6 +309,7 @@ describe('BoardButtons', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
container.remove();
|
container.remove();
|
||||||
|
useHiddenCatalogThreadsStore.setState({ hiddenCommentsByCid: {}, scopeHiddenThreadsCounts: {}, shownScopeKey: null });
|
||||||
clearStableLastVisitTimeFilterName();
|
clearStableLastVisitTimeFilterName();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
@@ -340,6 +376,51 @@ describe('BoardButtons', () => {
|
|||||||
expect(testState.resetMock).toHaveBeenCalledTimes(1);
|
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 () => {
|
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));
|
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now() - 3 * 24 * 60 * 60 * 1000));
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
cursor: not-allowed !important;
|
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;
|
all: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.desktopBoardButtons::after {
|
.desktopBoardButtons::after {
|
||||||
content: "";
|
content: '';
|
||||||
display: table;
|
display: table;
|
||||||
clear: both;
|
clear: both;
|
||||||
}
|
}
|
||||||
@@ -107,6 +108,30 @@
|
|||||||
text-transform: none !important;
|
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 {
|
.mobileBoardButtons {
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
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 { isAllView, isCatalogView, isModView, isModQueueView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||||
import { usePostPageNumber } from '../../hooks/use-post-page-number';
|
import { usePostPageNumber } from '../../hooks/use-post-page-number';
|
||||||
import { useDirectories, useDirectoryByAddress } from '../../hooks/use-directories';
|
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 { getBoardPath, isDirectoryBoard } from '../../lib/utils/route-utils';
|
||||||
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
import { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
||||||
import useSafeAccountComment from '../../hooks/use-safe-account-comment';
|
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 useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
||||||
import useCatalogStyleStore from '../../stores/use-catalog-style-store';
|
import useCatalogStyleStore from '../../stores/use-catalog-style-store';
|
||||||
import useFeedResetStore from '../../stores/use-feed-reset-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 useSortingStore from '../../stores/use-sorting-store';
|
||||||
import useAllFeedFilterStore from '../../stores/use-all-feed-filter-store';
|
import useAllFeedFilterStore from '../../stores/use-all-feed-filter-store';
|
||||||
import useModQueueStore from '../../stores/use-mod-queue-store';
|
import useModQueueStore from '../../stores/use-mod-queue-store';
|
||||||
@@ -37,6 +42,8 @@ interface BoardButtonsProps {
|
|||||||
isTopbar?: boolean;
|
isTopbar?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMPTY_COMMUNITY_ADDRESSES: string[] = [];
|
||||||
|
|
||||||
const getMultiboardPath = ({
|
const getMultiboardPath = ({
|
||||||
isInAllView,
|
isInAllView,
|
||||||
isInCatalogView,
|
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 = () => {
|
export const UpdateButton = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const requestUpdate = useThreadLiveUpdatesStore((state) => state.requestUpdate);
|
const requestUpdate = useThreadLiveUpdatesStore((state) => state.requestUpdate);
|
||||||
@@ -504,6 +565,14 @@ export const MobileBoardButtons = () => {
|
|||||||
<ArchiveButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
|
<ArchiveButton address={communityAddress} isInAllView={isInAllView} isInSubscriptionsView={isInSubscriptionsView} isInModView={isInModView} />
|
||||||
{showBottomButton && <BottomButton />}
|
{showBottomButton && <BottomButton />}
|
||||||
<RefreshButton />
|
<RefreshButton />
|
||||||
|
<HiddenCatalogThreadsToggle
|
||||||
|
address={communityAddress}
|
||||||
|
isInAllView={isInAllView}
|
||||||
|
isInCatalogView={isInCatalogView}
|
||||||
|
isInSubscriptionsView={isInSubscriptionsView}
|
||||||
|
isInModView={isInModView}
|
||||||
|
isMobilePlacement={true}
|
||||||
|
/>
|
||||||
{searchText ? (
|
{searchText ? (
|
||||||
<span className={styles.filteredThreadsCount}>
|
<span className={styles.filteredThreadsCount}>
|
||||||
{' '}
|
{' '}
|
||||||
@@ -705,6 +774,18 @@ export const DesktopBoardButtons = () => {
|
|||||||
</>
|
</>
|
||||||
)}{' '}
|
)}{' '}
|
||||||
[<RefreshButton />]
|
[<RefreshButton />]
|
||||||
|
{isInCatalogView && (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<HiddenCatalogThreadsToggle
|
||||||
|
address={communityAddress}
|
||||||
|
isInAllView={isInAllView}
|
||||||
|
isInCatalogView={isInCatalogView}
|
||||||
|
isInSubscriptionsView={isInSubscriptionsView}
|
||||||
|
isInModView={isInModView}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{!(isInAllView || isInSubscriptionsView) && (
|
{!(isInAllView || isInSubscriptionsView) && (
|
||||||
<>
|
<>
|
||||||
{' '}
|
{' '}
|
||||||
|
|||||||
@@ -492,14 +492,15 @@ describe('CatalogRow', () => {
|
|||||||
testState.hiddenCids = new Set(['hidden-1']);
|
testState.hiddenCids = new Set(['hidden-1']);
|
||||||
testState.showOPComment = false;
|
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[] = [
|
const posts: TestComment[] = [
|
||||||
{
|
hiddenPost,
|
||||||
author: { address: 'hidden-author', displayName: 'Ghost' },
|
|
||||||
cid: 'hidden-1',
|
|
||||||
content: 'hidden text',
|
|
||||||
link: 'https://example.com/hidden.png',
|
|
||||||
communityAddress: 'music-posting.eth',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
author: { address: 'text-author', displayName: 'Anon' },
|
author: { address: 'text-author', displayName: 'Anon' },
|
||||||
cid: 'text-1',
|
cid: 'text-1',
|
||||||
@@ -516,7 +517,13 @@ describe('CatalogRow', () => {
|
|||||||
expect(links).toContain('/mu/thread/hidden-1');
|
expect(links).toContain('/mu/thread/hidden-1');
|
||||||
expect(links).toContain('/mu/thread/text-1');
|
expect(links).toContain('/mu/thread/text-1');
|
||||||
expect(container.textContent).toContain('(hidden)');
|
expect(container.textContent).toContain('(hidden)');
|
||||||
|
expect(container.textContent).not.toContain('hidden text');
|
||||||
expect(container.textContent).toContain('Text title: Plain thread body');
|
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 () => {
|
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
|
// Memoize CatalogPost to prevent rerenders when parent rerenders due to updatingState
|
||||||
const CatalogPost = memo(
|
const CatalogPost = memo(
|
||||||
({ matchedFilterColor, post }: { matchedFilterColor?: string; post: Comment }) => {
|
({ matchedFilterColor, post, showHiddenPost = false }: { matchedFilterColor?: string; post: Comment; showHiddenPost?: boolean }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const resolvedPost = useMemo(() => withResolvedCommentCommunityAddress(post), [post]);
|
const resolvedPost = useMemo(() => withResolvedCommentCommunityAddress(post), [post]);
|
||||||
const { author, cid, content, link, linkHeight, linkWidth, locked, pinned, replyCount, spoiler, communityAddress, timestamp, title, thumbnailUrl } =
|
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 commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
||||||
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
||||||
|
|
||||||
const { hidden } = useHide({ cid });
|
const { hidden } = useHide({ cid, comment: resolvedPost });
|
||||||
|
const shouldMaskPost = hidden && !showHiddenPost;
|
||||||
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -207,8 +208,8 @@ const CatalogPost = memo(
|
|||||||
const lastReplyAuthorBadge = getAuthorBadge({ address: lastReply?.author?.address, role: lastReplyAuthorRole });
|
const lastReplyAuthorBadge = getAuthorBadge({ address: lastReply?.author?.address, role: lastReplyAuthorRole });
|
||||||
|
|
||||||
const postContent = (
|
const postContent = (
|
||||||
<div className={`${styles.teaser} ${hidden && styles.hidden}`}>
|
<div className={`${styles.teaser} ${shouldMaskPost && styles.hidden}`}>
|
||||||
{hidden ? (
|
{shouldMaskPost ? (
|
||||||
<b>({t('hidden')})</b>
|
<b>({t('hidden')})</b>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -238,7 +239,7 @@ const CatalogPost = memo(
|
|||||||
<>
|
<>
|
||||||
<div className={`${styles.post} ${imageSize === 'Large' ? styles.large : ''}`} style={CSSProperties}>
|
<div className={`${styles.post} ${imageSize === 'Large' ? styles.large : ''}`} style={CSSProperties}>
|
||||||
<div onMouseOver={() => setHoveredCid(cid)} onMouseLeave={() => setHoveredCid(null)}>
|
<div onMouseOver={() => setHoveredCid(cid)} onMouseLeave={() => setHoveredCid(null)}>
|
||||||
{hidden ? (
|
{shouldMaskPost ? (
|
||||||
<Link to={postLink}>
|
<Link to={postLink}>
|
||||||
<span className={styles.hiddenThumbnail} />
|
<span className={styles.hiddenThumbnail} />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -247,7 +248,7 @@ const CatalogPost = memo(
|
|||||||
{shouldShowSnow() && hasThumbnail && <img src='assets/xmashat.gif' className={styles.xmasHat} alt='' />}
|
{shouldShowSnow() && hasThumbnail && <img src='assets/xmashat.gif' className={styles.xmasHat} alt='' />}
|
||||||
<Link to={postLink}>
|
<Link to={postLink}>
|
||||||
<div
|
<div
|
||||||
className={`${styles.mediaPaddingWrapper} ${hidden && styles.hidden}`}
|
className={`${styles.mediaPaddingWrapper} ${shouldMaskPost && styles.hidden}`}
|
||||||
ref={refs.setReference}
|
ref={refs.setReference}
|
||||||
onMouseOver={() => (timeoutRef.current = setTimeout(() => setShowPortal(true), 250))}
|
onMouseOver={() => (timeoutRef.current = setTimeout(() => setShowPortal(true), 250))}
|
||||||
onMouseLeave={() => {
|
onMouseLeave={() => {
|
||||||
@@ -352,7 +353,8 @@ const CatalogPost = memo(
|
|||||||
prev?.linkWidth === next?.linkWidth &&
|
prev?.linkWidth === next?.linkWidth &&
|
||||||
prev?.linkHeight === next?.linkHeight &&
|
prev?.linkHeight === next?.linkHeight &&
|
||||||
prevCommunityAddress === nextCommunityAddress &&
|
prevCommunityAddress === nextCommunityAddress &&
|
||||||
prevProps.matchedFilterColor === nextProps.matchedFilterColor
|
prevProps.matchedFilterColor === nextProps.matchedFilterColor &&
|
||||||
|
prevProps.showHiddenPost === nextProps.showHiddenPost
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -362,20 +364,25 @@ interface CatalogRowProps {
|
|||||||
index?: number;
|
index?: number;
|
||||||
matchedFilterColors?: Map<string, string>;
|
matchedFilterColors?: Map<string, string>;
|
||||||
row: Comment[];
|
row: Comment[];
|
||||||
|
showHiddenPosts?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CatalogRow = memo(
|
const CatalogRow = memo(
|
||||||
({ estimatedHeight, matchedFilterColors, row }: CatalogRowProps) => {
|
({ estimatedHeight, matchedFilterColors, row, showHiddenPosts = false }: CatalogRowProps) => {
|
||||||
return (
|
return (
|
||||||
<div className={styles.row} data-pretext-height={estimatedHeight}>
|
<div className={styles.row} data-pretext-height={estimatedHeight}>
|
||||||
{row.map((post, index) => (
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
(prevProps, nextProps) => {
|
(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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ const PostMenuDesktop = ({ postMenu }: PostMenuDesktopProps) => {
|
|||||||
const { thumbnail, type, url } = commentMediaInfo || {};
|
const { thumbnail, type, url } = commentMediaInfo || {};
|
||||||
const [menuBtnRotated, setMenuBtnRotated] = useState(false);
|
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 location = useLocation();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ async function copyUserIdSafe(address: string): Promise<void> {
|
|||||||
|
|
||||||
type HideButtonProps = {
|
type HideButtonProps = {
|
||||||
cid?: string;
|
cid?: string;
|
||||||
|
comment?: Comment;
|
||||||
isReply?: boolean;
|
isReply?: boolean;
|
||||||
postCid?: string;
|
postCid?: string;
|
||||||
onClose?: () => void;
|
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 { t } = useTranslation();
|
||||||
const { hide, hidden, unhide } = useHide({ cid: cid || '' });
|
const { hide, hidden, unhide } = useHide({ cid: cid || '', comment });
|
||||||
const isInPostView = isPostPageView(useLocation().pathname, useParams());
|
const isInPostView = isPostPageView(useLocation().pathname, useParams());
|
||||||
|
|
||||||
const togglePostHidden = () => {
|
const togglePostHidden = () => {
|
||||||
@@ -365,7 +366,7 @@ const PostMenuMobile = ({ postMenu, editMenuPost }: PostMenuMobileProps) => {
|
|||||||
<FloatingFocusManager context={context} modal={false}>
|
<FloatingFocusManager context={context} modal={false}>
|
||||||
<div className={styles.postMenu} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}>
|
<div className={styles.postMenu} ref={refs.setFloating} style={floatingStyles} aria-labelledby={headingId} {...getFloatingProps()}>
|
||||||
<ReportPostButton onClose={handleClose} />
|
<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} />}
|
{(isAccountCommentAuthor || canAttemptAuthorDelete) && cid && editMenuPost && <DeletePostButton post={editMenuPost} onClose={handleClose} />}
|
||||||
{cid && communityAddress && <CopyLinkButton cid={cid} communityAddress={communityAddress} linkType='thread' onClose={handleClose} />}
|
{cid && communityAddress && <CopyLinkButton cid={cid} communityAddress={communityAddress} linkType='thread' onClose={handleClose} />}
|
||||||
{cid && <CopyContentIdButton cid={cid} 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 { useCallback, useMemo } from 'react';
|
||||||
import { create } from 'zustand';
|
import { useAccount, useBlock } from '@bitsocial/bitsocial-react-hooks';
|
||||||
import localForageLru from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru/index.js';
|
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 {
|
export type HiddenCidLookup = { [cid: string]: boolean | undefined };
|
||||||
hiddenCids: { [key: string]: boolean };
|
|
||||||
hide: (cid: string) => void;
|
|
||||||
unhide: (cid: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hideStore = localForageLru.createInstance({
|
type CommentWithCid = {
|
||||||
name: 'hideStore',
|
cid?: string;
|
||||||
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 },
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
initializeHideStore();
|
export const isCidHidden = (hiddenCids: HiddenCidLookup | undefined, cid?: string): boolean => Boolean(cid && hiddenCids?.[cid]);
|
||||||
|
|
||||||
const useHide = ({ cid }: { cid: string }) => {
|
export const filterHiddenComments = <T extends CommentWithCid>(comments: readonly T[], hiddenCids: HiddenCidLookup | undefined): T[] =>
|
||||||
const hidden = useHideStore((state) => !!state.hiddenCids[cid]);
|
comments.filter((comment) => !isCidHidden(hiddenCids, comment?.cid));
|
||||||
const hide = useHideStore((state) => state.hide);
|
|
||||||
const unhide = useHideStore((state) => state.unhide);
|
|
||||||
|
|
||||||
const hideCallback = useCallback(() => hide(cid), [hide, cid]);
|
export const useHiddenCids = (): HiddenCidLookup => {
|
||||||
const unhideCallback = useCallback(() => unhide(cid), [unhide, cid]);
|
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;
|
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;
|
thumbnailUrl?: string;
|
||||||
deleted?: boolean;
|
deleted?: boolean;
|
||||||
removed?: boolean;
|
removed?: boolean;
|
||||||
|
comment?: Comment;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const selectPostMenuProps = (post?: Comment): PostMenuProps => {
|
export const selectPostMenuProps = (post?: Comment): PostMenuProps => {
|
||||||
@@ -30,5 +31,6 @@ export const selectPostMenuProps = (post?: Comment): PostMenuProps => {
|
|||||||
thumbnailUrl: post?.thumbnailUrl,
|
thumbnailUrl: post?.thumbnailUrl,
|
||||||
deleted: post?.deleted,
|
deleted: post?.deleted,
|
||||||
removed: post?.removed,
|
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import Catalog, { getCatalogRenderFeed, type CatalogProps } from '../catalog';
|
import Catalog, { getCatalogRenderFeed, type CatalogProps } from '../catalog';
|
||||||
import { clearStableLastVisitTimeFilterName, LAST_VISIT_STORAGE_KEY } from '../../../lib/utils/time-filter-utils';
|
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;
|
(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 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;
|
pinned?: boolean;
|
||||||
communityAddress?: string;
|
communityAddress?: string;
|
||||||
deleted?: boolean;
|
deleted?: boolean;
|
||||||
|
parentCid?: string;
|
||||||
postCid?: string;
|
postCid?: string;
|
||||||
removed?: boolean;
|
removed?: boolean;
|
||||||
state?: string;
|
state?: string;
|
||||||
@@ -33,17 +35,26 @@ type FilterItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const testState = vi.hoisted(() => ({
|
const testState = vi.hoisted(() => ({
|
||||||
account: { subscriptions: [] as string[] },
|
account: { blockedCids: {} as Record<string, boolean>, subscriptions: [] as string[] },
|
||||||
accountComments: [] as TestComment[],
|
accountComments: [] as TestComment[],
|
||||||
accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>,
|
accountCommentsCalls: [] as Array<{ commentIndices?: number[]; communityAddress?: string; newerThan?: number; sortType?: 'new' | 'old' } | undefined>,
|
||||||
accountCommunityAddresses: [] as string[],
|
accountCommunityAddresses: [] as string[],
|
||||||
|
blockCidMock: vi.fn(),
|
||||||
|
commentsByCid: {} as Record<string, TestComment>,
|
||||||
directoryByAddress: {
|
directoryByAddress: {
|
||||||
'music-posting.eth': {
|
'music-posting.eth': {
|
||||||
address: 'music-posting.eth',
|
address: 'music-posting.eth',
|
||||||
features: { postsPerPage: 2 },
|
features: { postsPerPage: 2 },
|
||||||
},
|
},
|
||||||
} as Record<string, { address: string; features?: Record<string, unknown> }>,
|
} 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[],
|
feed: [] as TestComment[],
|
||||||
feedOptionsCalls: [] as Array<{ communitiesLength?: number; filterKey?: string; newerThan?: number; postsPerPage?: number; sortType?: string }>,
|
feedOptionsCalls: [] as Array<{ communitiesLength?: number; filterKey?: string; newerThan?: number; postsPerPage?: number; sortType?: string }>,
|
||||||
filterItems: [] as FilterItem[],
|
filterItems: [] as FilterItem[],
|
||||||
@@ -66,6 +77,7 @@ const testState = vi.hoisted(() => ({
|
|||||||
setResetFunctionMock: vi.fn(),
|
setResetFunctionMock: vi.fn(),
|
||||||
showOPComment: true,
|
showOPComment: true,
|
||||||
sortType: 'new' as 'active' | 'new',
|
sortType: 'new' as 'active' | 'new',
|
||||||
|
unblockCidMock: vi.fn(),
|
||||||
virtuosoInitialScrollTops: [] as Array<number | undefined>,
|
virtuosoInitialScrollTops: [] as Array<number | undefined>,
|
||||||
windowWidth: 900,
|
windowWidth: 900,
|
||||||
community: {
|
community: {
|
||||||
@@ -168,6 +180,23 @@ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
useCommunity: () => testState.community,
|
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', () => ({
|
vi.mock('react-virtuoso', () => ({
|
||||||
@@ -207,8 +236,21 @@ vi.mock('react-virtuoso', () => ({
|
|||||||
vi.mock('../../../hooks/use-directories', () => ({
|
vi.mock('../../../hooks/use-directories', () => ({
|
||||||
useDirectories: () => testState.directories,
|
useDirectories: () => testState.directories,
|
||||||
useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryByAddress[address] : undefined),
|
useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryByAddress[address] : undefined),
|
||||||
findDirectoryByAddress: (directories: Array<{ address: string; title?: string; directoryCode?: string }>, address: string | undefined) =>
|
normalizeBoardAddress: (address: string) => address.replace(/\.(bso|eth)$/, ''),
|
||||||
directories.find((entry) => entry.address === address || entry.directoryCode === address || entry.title === address),
|
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', () => ({
|
vi.mock('../../../hooks/use-board-feed-page-size', () => ({
|
||||||
@@ -262,8 +304,20 @@ vi.mock('../../../stores/use-catalog-filters-store', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../components/catalog-row', () => ({
|
vi.mock('../../../components/catalog-row', () => ({
|
||||||
default: ({ estimatedHeight, row }: { estimatedHeight?: number; row: TestComment[] }) =>
|
default: ({ estimatedHeight, row, showHiddenPosts }: { estimatedHeight?: number; row: TestComment[]; showHiddenPosts?: boolean }) =>
|
||||||
createElement('div', { 'data-pretext-height': estimatedHeight, 'data-testid': 'catalog-row' }, `row:${row.map((comment) => comment.cid).join(',')}`),
|
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', () => ({
|
vi.mock('../../../components/footer', () => ({
|
||||||
@@ -311,6 +365,8 @@ const LocationProbe = () => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getHiddenCatalogThreadsScopeKey = (communityAddresses: string[]) => communityAddresses.filter(Boolean).slice().sort().join('\u0000');
|
||||||
|
|
||||||
const flushEffects = async (count = 5) => {
|
const flushEffects = async (count = 5) => {
|
||||||
for (let i = 0; i < count; i += 1) {
|
for (let i = 0; i < count; i += 1) {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -344,11 +400,22 @@ describe('Catalog', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
latestLocation = '';
|
latestLocation = '';
|
||||||
testState.account = { subscriptions: [] };
|
testState.account = { blockedCids: {}, subscriptions: [] };
|
||||||
testState.accountComments = [];
|
testState.accountComments = [];
|
||||||
testState.accountCommentsCalls = [];
|
testState.accountCommentsCalls = [];
|
||||||
testState.accountCommunityAddresses = [];
|
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 = {
|
testState.directoryByAddress = {
|
||||||
'music-posting.eth': {
|
'music-posting.eth': {
|
||||||
address: 'music-posting.eth',
|
address: 'music-posting.eth',
|
||||||
@@ -371,6 +438,8 @@ describe('Catalog', () => {
|
|||||||
testState.searchText = '';
|
testState.searchText = '';
|
||||||
testState.showOPComment = true;
|
testState.showOPComment = true;
|
||||||
testState.sortType = 'new';
|
testState.sortType = 'new';
|
||||||
|
testState.unblockCidMock.mockReset();
|
||||||
|
testState.unblockCidMock.mockResolvedValue(undefined);
|
||||||
testState.virtuosoInitialScrollTops = [];
|
testState.virtuosoInitialScrollTops = [];
|
||||||
testState.windowWidth = 900;
|
testState.windowWidth = 900;
|
||||||
testState.community = {
|
testState.community = {
|
||||||
@@ -387,6 +456,7 @@ describe('Catalog', () => {
|
|||||||
document.title = 'before';
|
document.title = 'before';
|
||||||
clearStableLastVisitTimeFilterName();
|
clearStableLastVisitTimeFilterName();
|
||||||
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now()));
|
localStorage.setItem(LAST_VISIT_STORAGE_KEY, String(Date.now()));
|
||||||
|
useHiddenCatalogThreadsStore.setState({ hiddenCommentsByCid: {}, scopeHiddenThreadsCounts: {}, shownScopeKey: null });
|
||||||
|
|
||||||
container = document.createElement('div');
|
container = document.createElement('div');
|
||||||
document.body.appendChild(container);
|
document.body.appendChild(container);
|
||||||
@@ -396,6 +466,7 @@ describe('Catalog', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
act(() => root.unmount());
|
act(() => root.unmount());
|
||||||
container.remove();
|
container.remove();
|
||||||
|
useHiddenCatalogThreadsStore.setState({ hiddenCommentsByCid: {}, scopeHiddenThreadsCounts: {}, shownScopeKey: null });
|
||||||
clearStableLastVisitTimeFilterName();
|
clearStableLastVisitTimeFilterName();
|
||||||
localStorage.clear();
|
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']);
|
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', () => {
|
it('prefers the immediate feed until deferred catalog rows exist', () => {
|
||||||
const immediateFeed = [{ cid: 'all-post', title: 'one', communityAddress: 'music-posting.eth' }];
|
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 () => {
|
it('shows the empty subscriptions state when there are no subscribed boards to browse', async () => {
|
||||||
testState.account = { subscriptions: [] };
|
testState.account = { blockedCids: {}, subscriptions: [] };
|
||||||
|
|
||||||
await renderCatalog({
|
await renderCatalog({
|
||||||
catalogProps: { viewType: 'subs' },
|
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 { useResolvedCommunityAddress } from '../../hooks/use-resolved-community-address';
|
||||||
import { useFeedStateString } from '../../hooks/use-state-string';
|
import { useFeedStateString } from '../../hooks/use-state-string';
|
||||||
import useExpandedTimeFilter from '../../hooks/use-expanded-time-filter';
|
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 { useSuggestionFeedLoader } from '../../hooks/use-suggestion-feed-loader';
|
||||||
import useTimeFilter from '../../hooks/use-time-filter';
|
import useTimeFilter from '../../hooks/use-time-filter';
|
||||||
import useIsMobile from '../../hooks/use-is-mobile';
|
import useIsMobile from '../../hooks/use-is-mobile';
|
||||||
import useWindowWidth from '../../hooks/use-window-width';
|
import useWindowWidth from '../../hooks/use-window-width';
|
||||||
import useCatalogStyleStore from '../../stores/use-catalog-style-store';
|
import useCatalogStyleStore from '../../stores/use-catalog-style-store';
|
||||||
import useFeedResetStore from '../../stores/use-feed-reset-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 useSortingStore from '../../stores/use-sorting-store';
|
||||||
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
import useCatalogFiltersStore from '../../stores/use-catalog-filters-store';
|
||||||
import { getCommunityAddress, isDirectoryBoard, normalizeMultiboardFeedPath } from '../../lib/utils/route-utils';
|
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 searchText = useCatalogFiltersStore((state) => state.searchText);
|
||||||
|
|
||||||
const account = useAccount();
|
const account = useAccount();
|
||||||
|
const hiddenCids = useHiddenCids();
|
||||||
const subscriptions = account?.subscriptions;
|
const subscriptions = account?.subscriptions;
|
||||||
const accountCommunityAddresses = useAccountCommunityAddresses();
|
const accountCommunityAddresses = useAccountCommunityAddresses();
|
||||||
const filteredDirectoryAddresses = useFilteredDirectoryAddresses();
|
const filteredDirectoryAddresses = useFilteredDirectoryAddresses();
|
||||||
@@ -331,6 +336,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
const feedSortType = sortType === 'new' ? 'new' : 'active';
|
const feedSortType = sortType === 'new' ? 'new' : 'active';
|
||||||
const catalogVirtualizationMode = useMemo(() => resolveCatalogVirtualizationMode(location.search, 'item-size'), [location.search]);
|
const catalogVirtualizationMode = useMemo(() => resolveCatalogVirtualizationMode(location.search, 'item-size'), [location.search]);
|
||||||
const themeKey = typeof document !== 'undefined' ? document.body.className : '';
|
const themeKey = typeof document !== 'undefined' ? document.body.className : '';
|
||||||
|
const hadVisibleHiddenThreadsRef = useRef(false);
|
||||||
|
|
||||||
// Create a stable callback for filter matching
|
// Create a stable callback for filter matching
|
||||||
const handleFilterMatch = useCallback((filterIndex: number, cid: string, communityAddress: string) => {
|
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 { 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({
|
const { currentTimeFilterName, currentTimeFilterSeconds, expandSuggestionTimeWindow } = useExpandedTimeFilter({
|
||||||
timeFilterName,
|
timeFilterName,
|
||||||
timeFilterSeconds: multiboardTimeFilterSeconds,
|
timeFilterSeconds: multiboardTimeFilterSeconds,
|
||||||
@@ -396,6 +441,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
filter: suggestionFilter,
|
filter: suggestionFilter,
|
||||||
newerThan: WEEK_IN_SECONDS,
|
newerThan: WEEK_IN_SECONDS,
|
||||||
});
|
});
|
||||||
|
const visibleWeeklyFeed = useMemo(() => filterHiddenComments(weeklyFeed, hiddenCids), [hiddenCids, weeklyFeed]);
|
||||||
const {
|
const {
|
||||||
feed: monthlyFeed,
|
feed: monthlyFeed,
|
||||||
hasMore: monthlyFeedHasMore,
|
hasMore: monthlyFeedHasMore,
|
||||||
@@ -407,6 +453,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
filter: suggestionFilter,
|
filter: suggestionFilter,
|
||||||
newerThan: MONTH_IN_SECONDS,
|
newerThan: MONTH_IN_SECONDS,
|
||||||
});
|
});
|
||||||
|
const visibleMonthlyFeed = useMemo(() => filterHiddenComments(monthlyFeed, hiddenCids), [hiddenCids, monthlyFeed]);
|
||||||
const {
|
const {
|
||||||
feed: yearlyFeed,
|
feed: yearlyFeed,
|
||||||
hasMore: yearlyFeedHasMore,
|
hasMore: yearlyFeedHasMore,
|
||||||
@@ -418,25 +465,26 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
filter: suggestionFilter,
|
filter: suggestionFilter,
|
||||||
newerThan: YEAR_IN_SECONDS,
|
newerThan: YEAR_IN_SECONDS,
|
||||||
});
|
});
|
||||||
|
const visibleYearlyFeed = useMemo(() => filterHiddenComments(yearlyFeed, hiddenCids), [hiddenCids, yearlyFeed]);
|
||||||
useSuggestionFeedLoader({
|
useSuggestionFeedLoader({
|
||||||
currentFeedLength: feed.length,
|
currentFeedLength: visibleFeed.length,
|
||||||
feedLength: weeklyFeed.length,
|
feedLength: visibleWeeklyFeed.length,
|
||||||
hasMore: weeklyFeedHasMore,
|
hasMore: weeklyFeedHasMore,
|
||||||
loadMore: loadMoreWeeklyFeed,
|
loadMore: loadMoreWeeklyFeed,
|
||||||
requestKey: `${suggestionRequestKeyBase}:1w`,
|
requestKey: `${suggestionRequestKeyBase}:1w`,
|
||||||
shouldLoad: shouldProbeWeeklyFeed,
|
shouldLoad: shouldProbeWeeklyFeed,
|
||||||
});
|
});
|
||||||
useSuggestionFeedLoader({
|
useSuggestionFeedLoader({
|
||||||
currentFeedLength: feed.length,
|
currentFeedLength: visibleFeed.length,
|
||||||
feedLength: monthlyFeed.length,
|
feedLength: visibleMonthlyFeed.length,
|
||||||
hasMore: monthlyFeedHasMore,
|
hasMore: monthlyFeedHasMore,
|
||||||
loadMore: loadMoreMonthlyFeed,
|
loadMore: loadMoreMonthlyFeed,
|
||||||
requestKey: `${suggestionRequestKeyBase}:1m`,
|
requestKey: `${suggestionRequestKeyBase}:1m`,
|
||||||
shouldLoad: shouldProbeMonthlyFeed,
|
shouldLoad: shouldProbeMonthlyFeed,
|
||||||
});
|
});
|
||||||
useSuggestionFeedLoader({
|
useSuggestionFeedLoader({
|
||||||
currentFeedLength: feed.length,
|
currentFeedLength: visibleFeed.length,
|
||||||
feedLength: yearlyFeed.length,
|
feedLength: visibleYearlyFeed.length,
|
||||||
hasMore: yearlyFeedHasMore,
|
hasMore: yearlyFeedHasMore,
|
||||||
loadMore: loadMoreYearlyFeed,
|
loadMore: loadMoreYearlyFeed,
|
||||||
requestKey: `${suggestionRequestKeyBase}:1y`,
|
requestKey: `${suggestionRequestKeyBase}:1y`,
|
||||||
@@ -474,7 +522,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
cid &&
|
cid &&
|
||||||
cid === postCid &&
|
cid === postCid &&
|
||||||
commentCommunityAddress === communityAddress &&
|
commentCommunityAddress === communityAddress &&
|
||||||
!feedCids.has(cid);
|
!feedCids.has(cid) &&
|
||||||
|
!isCidHidden(hiddenCids, cid);
|
||||||
|
|
||||||
// If search is active, also check search conditions
|
// If search is active, also check search conditions
|
||||||
if (basicConditions && searchText.trim()) {
|
if (basicConditions && searchText.trim()) {
|
||||||
@@ -487,30 +536,34 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
|
|
||||||
return basicConditions;
|
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
|
// show newest account comment at the top of the feed but after pinned posts
|
||||||
const combinedFeed = useMemo(() => {
|
const combinedFeed = useMemo(() => {
|
||||||
const newFeed = [...feed];
|
const newFeed = [...visibleFeed];
|
||||||
const lastPinnedIndex = newFeed.map((post) => post.pinned).lastIndexOf(true);
|
const lastPinnedIndex = newFeed.map((post) => post.pinned).lastIndexOf(true);
|
||||||
if (filteredComments.length > 0) {
|
if (filteredComments.length > 0) {
|
||||||
newFeed.splice(lastPinnedIndex + 1, 0, ...filteredComments);
|
newFeed.splice(lastPinnedIndex + 1, 0, ...filteredComments);
|
||||||
}
|
}
|
||||||
return newFeed;
|
return newFeed;
|
||||||
}, [feed, filteredComments]);
|
}, [visibleFeed, filteredComments]);
|
||||||
|
|
||||||
const cappedFeed = useMemo(
|
const cappedFeed = useMemo(
|
||||||
() => (effectiveInfiniteScroll ? combinedFeed : combinedFeed.slice(0, boardPostsPerPage * maxGuiPages)),
|
() => (effectiveInfiniteScroll ? combinedFeed : combinedFeed.slice(0, boardPostsPerPage * maxGuiPages)),
|
||||||
[effectiveInfiniteScroll, combinedFeed, boardPostsPerPage, maxGuiPages],
|
[effectiveInfiniteScroll, combinedFeed, boardPostsPerPage, maxGuiPages],
|
||||||
);
|
);
|
||||||
const moreThreadsSuggestion = useMemo(
|
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 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(() => {
|
useEffect(() => {
|
||||||
if (filteredComments.length > 0 && !resetTriggeredRef.current) {
|
if (filteredComments.length > 0 && !resetTriggeredRef.current) {
|
||||||
@@ -529,6 +582,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
|
|
||||||
const community = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined);
|
const community = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined);
|
||||||
const { error, shortAddress, state, title } = community || {};
|
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)
|
// Memoize footer component to preserve identity across renders (Virtuoso optimization)
|
||||||
// Note: useFeedStateString is called inside CatalogFooter to isolate re-renders from backend state changes
|
// 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
|
<CatalogFooter
|
||||||
communityAddresses={communityAddresses}
|
communityAddresses={communityAddresses}
|
||||||
hasMore={hasMore}
|
hasMore={footerHasMore}
|
||||||
combinedFeedLength={cappedFeed.length}
|
combinedFeedLength={footerCombinedFeedLength}
|
||||||
currentTimeFilterName={currentTimeFilterName}
|
currentTimeFilterName={currentTimeFilterName}
|
||||||
moreThreadsSuggestion={moreThreadsSuggestion}
|
moreThreadsSuggestion={footerMoreThreadsSuggestion}
|
||||||
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
|
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
|
||||||
moreThreadsSuggestionSearch={location.search}
|
moreThreadsSuggestionSearch={location.search}
|
||||||
onExpandTimeWindow={expandSuggestionTimeWindow}
|
onExpandTimeWindow={expandSuggestionTimeWindow}
|
||||||
showLoadingEllipsis={effectiveInfiniteScroll}
|
showLoadingEllipsis={footerShowLoadingEllipsis}
|
||||||
/>
|
/>
|
||||||
<PageFooterDesktop
|
<PageFooterDesktop
|
||||||
firstRow={
|
firstRow={
|
||||||
@@ -570,10 +627,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
communityAddresses,
|
communityAddresses,
|
||||||
hasMore,
|
footerHasMore,
|
||||||
cappedFeed.length,
|
footerCombinedFeedLength,
|
||||||
currentTimeFilterName,
|
currentTimeFilterName,
|
||||||
moreThreadsSuggestion,
|
footerMoreThreadsSuggestion,
|
||||||
moreThreadsSuggestionPathname,
|
moreThreadsSuggestionPathname,
|
||||||
expandSuggestionTimeWindow,
|
expandSuggestionTimeWindow,
|
||||||
communityAddress,
|
communityAddress,
|
||||||
@@ -581,7 +638,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
isInSubscriptionsView,
|
isInSubscriptionsView,
|
||||||
isInModView,
|
isInModView,
|
||||||
location.search,
|
location.search,
|
||||||
effectiveInfiniteScroll,
|
footerShowLoadingEllipsis,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
const catalogFooter = useMemo(
|
const catalogFooter = useMemo(
|
||||||
@@ -589,14 +646,14 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
<>
|
<>
|
||||||
<CatalogFooter
|
<CatalogFooter
|
||||||
communityAddresses={communityAddresses}
|
communityAddresses={communityAddresses}
|
||||||
hasMore={hasMore}
|
hasMore={footerHasMore}
|
||||||
combinedFeedLength={cappedFeed.length}
|
combinedFeedLength={footerCombinedFeedLength}
|
||||||
currentTimeFilterName={currentTimeFilterName}
|
currentTimeFilterName={currentTimeFilterName}
|
||||||
moreThreadsSuggestion={moreThreadsSuggestion}
|
moreThreadsSuggestion={footerMoreThreadsSuggestion}
|
||||||
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
|
moreThreadsSuggestionPathname={moreThreadsSuggestionPathname}
|
||||||
moreThreadsSuggestionSearch={location.search}
|
moreThreadsSuggestionSearch={location.search}
|
||||||
onExpandTimeWindow={expandSuggestionTimeWindow}
|
onExpandTimeWindow={expandSuggestionTimeWindow}
|
||||||
showLoadingEllipsis={effectiveInfiniteScroll}
|
showLoadingEllipsis={footerShowLoadingEllipsis}
|
||||||
/>
|
/>
|
||||||
<PageFooterDesktop
|
<PageFooterDesktop
|
||||||
firstRow={
|
firstRow={
|
||||||
@@ -620,10 +677,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
),
|
),
|
||||||
[
|
[
|
||||||
communityAddresses,
|
communityAddresses,
|
||||||
hasMore,
|
footerHasMore,
|
||||||
cappedFeed.length,
|
footerCombinedFeedLength,
|
||||||
currentTimeFilterName,
|
currentTimeFilterName,
|
||||||
moreThreadsSuggestion,
|
footerMoreThreadsSuggestion,
|
||||||
moreThreadsSuggestionPathname,
|
moreThreadsSuggestionPathname,
|
||||||
expandSuggestionTimeWindow,
|
expandSuggestionTimeWindow,
|
||||||
communityAddress,
|
communityAddress,
|
||||||
@@ -631,10 +688,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
isInSubscriptionsView,
|
isInSubscriptionsView,
|
||||||
isInModView,
|
isInModView,
|
||||||
location.search,
|
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)
|
// Process the feed to move "top" posts to the top (applied after display sort)
|
||||||
const processedFeed = useMemo(() => {
|
const processedFeed = useMemo(() => {
|
||||||
@@ -669,8 +726,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
return [...topPosts, ...regularPosts];
|
return [...topPosts, ...regularPosts];
|
||||||
}, [sortedFeed, filterItems]);
|
}, [sortedFeed, filterItems]);
|
||||||
|
|
||||||
|
const processedFeedMode = showHiddenThreads ? 'hidden' : 'visible';
|
||||||
const deferredProcessedFeed = useDeferredValue(processedFeed);
|
const deferredProcessedFeed = useDeferredValue(processedFeed);
|
||||||
const catalogRenderFeed = getCatalogRenderFeed(processedFeed, deferredProcessedFeed);
|
const deferredProcessedFeedMode = useDeferredValue(processedFeedMode);
|
||||||
|
const catalogRenderFeed = getCatalogRenderFeed(processedFeed, deferredProcessedFeedMode === processedFeedMode ? deferredProcessedFeed : []);
|
||||||
|
|
||||||
const matchedFilterColors = useMemo(() => {
|
const matchedFilterColors = useMemo(() => {
|
||||||
const nextMatchedFilterColors = new Map<string, string>();
|
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 catalogViewportBuffer = isMultiboardView ? (isMobile ? { bottom: 2400, top: 1200 } : { bottom: 900, top: 600 }) : { bottom: 1200, top: 1200 };
|
||||||
|
|
||||||
const virtuosoRef = useRef<VirtuosoHandle | null>(null);
|
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 navigationType = useNavigationType();
|
||||||
|
|
||||||
const hasBeenVisibleRef = useRef(false);
|
const hasBeenVisibleRef = useRef(false);
|
||||||
@@ -773,8 +834,10 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
const lastVirtuosoState = shouldVirtualizeCatalog && navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined;
|
const lastVirtuosoState = shouldVirtualizeCatalog && navigationType === 'POP' ? lastVirtuosoStates?.[virtuosoStateKey] : undefined;
|
||||||
|
|
||||||
const renderCatalogRow = useCallback(
|
const renderCatalogRow = useCallback(
|
||||||
(index: number, row: Comment[]) => <CatalogRow estimatedHeight={rowHeightEstimates[index]} index={index} matchedFilterColors={matchedFilterColors} row={row} />,
|
(index: number, row: Comment[]) => (
|
||||||
[matchedFilterColors, rowHeightEstimates],
|
<CatalogRow estimatedHeight={rowHeightEstimates[index]} index={index} matchedFilterColors={matchedFilterColors} row={row} showHiddenPosts={showHiddenThreads} />
|
||||||
|
),
|
||||||
|
[matchedFilterColors, rowHeightEstimates, showHiddenThreads],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -812,7 +875,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
itemContent={renderCatalogRow}
|
itemContent={renderCatalogRow}
|
||||||
useWindowScroll={true}
|
useWindowScroll={true}
|
||||||
components={footerComponents}
|
components={footerComponents}
|
||||||
endReached={effectiveInfiniteScroll && hasMore ? loadMore : undefined}
|
endReached={!showHiddenThreads && effectiveInfiniteScroll && footerHasMore ? loadMore : undefined}
|
||||||
ref={virtuosoRef}
|
ref={virtuosoRef}
|
||||||
restoreStateFrom={lastVirtuosoState}
|
restoreStateFrom={lastVirtuosoState}
|
||||||
initialScrollTop={lastVirtuosoState?.scrollTop}
|
initialScrollTop={lastVirtuosoState?.scrollTop}
|
||||||
@@ -826,6 +889,7 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
index={index}
|
index={index}
|
||||||
matchedFilterColors={matchedFilterColors}
|
matchedFilterColors={matchedFilterColors}
|
||||||
row={row}
|
row={row}
|
||||||
|
showHiddenPosts={showHiddenThreads}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{catalogFooter}
|
{catalogFooter}
|
||||||
@@ -837,8 +901,8 @@ const Catalog = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp,
|
|||||||
<div className={styles.footer}>
|
<div className={styles.footer}>
|
||||||
<CatalogLoading
|
<CatalogLoading
|
||||||
communityAddresses={communityAddresses}
|
communityAddresses={communityAddresses}
|
||||||
hasMore={hasMore}
|
hasMore={footerHasMore}
|
||||||
combinedFeedLength={cappedFeed.length}
|
combinedFeedLength={footerCombinedFeedLength}
|
||||||
state={state}
|
state={state}
|
||||||
subscriptionsLength={isInSubscriptionsView ? subscriptions?.length || 0 : 1}
|
subscriptionsLength={isInSubscriptionsView ? subscriptions?.length || 0 : 1}
|
||||||
error={error}
|
error={error}
|
||||||
|
|||||||
Reference in New Issue
Block a user