fix(catalog): hide threads across board catalogs

This commit is contained in:
Tommaso Casaburi
2026-05-14 16:59:55 +07:00
parent d1fbc7f46a
commit 8217bad735
17 changed files with 1544 additions and 123 deletions
@@ -0,0 +1,156 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Comment } from '@bitsocial/bitsocial-react-hooks';
import useHiddenCatalogThreads from '../use-hidden-catalog-threads';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const SPORTS_PUBLIC_KEY = '12D3KooWGJA6zN3Q63FtSgwNhtfA26Skdzdxz5X7A9PFfE4FBMGE';
const testState = vi.hoisted(() => ({
account: { blockedCids: {} as Record<string, boolean> },
commentsByCid: {} as Record<string, Comment>,
directories: [
{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' },
{ address: 'sports-posting.bso', directoryCode: 'sp', publicKey: '12D3KooWGJA6zN3Q63FtSgwNhtfA26Skdzdxz5X7A9PFfE4FBMGE', title: '/sp/ - Sports' },
],
}));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useAccount: () => testState.account,
useComments: ({ commentCids = [] }: { commentCids?: string[] } = {}) => ({
comments: commentCids.map((cid) => testState.commentsByCid[cid]),
state: 'succeeded',
}),
}));
vi.mock('../use-directories', async () => {
const actual = await vi.importActual<typeof import('../use-directories')>('../use-directories');
return {
...actual,
useDirectories: () => testState.directories,
};
});
const Result = ({
candidateComments = [],
communityAddresses,
sortType = 'new',
}: {
candidateComments?: Comment[];
communityAddresses: string[];
sortType?: 'active' | 'new';
}) => {
const { hiddenCatalogThreads, scopeKey } = useHiddenCatalogThreads({ candidateComments, communityAddresses, sortType });
return (
<div>
<span data-testid='hidden-cids'>{hiddenCatalogThreads.map((thread) => thread.cid).join(',')}</span>
<span data-testid='scope-key'>{scopeKey}</span>
</div>
);
};
let container: HTMLDivElement;
let root: Root;
describe('useHiddenCatalogThreads', () => {
beforeEach(() => {
testState.account = { blockedCids: {} };
testState.commentsByCid = {};
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('counts hidden threads for a board reached through a directory alias and public key', async () => {
testState.account = { blockedCids: { 'hidden-sp-thread': true, 'hidden-sp-reply': true, 'hidden-mu-thread': true } };
testState.commentsByCid = {
'hidden-mu-thread': {
cid: 'hidden-mu-thread',
communityAddress: 'music-posting.eth',
postCid: 'hidden-mu-thread',
timestamp: 100,
} as Comment,
'hidden-sp-reply': {
cid: 'hidden-sp-reply',
communityAddress: SPORTS_PUBLIC_KEY,
parentCid: 'hidden-sp-thread',
postCid: 'hidden-sp-thread',
timestamp: 101,
} as Comment,
'hidden-sp-thread': {
cid: 'hidden-sp-thread',
communityAddress: SPORTS_PUBLIC_KEY,
postCid: 'hidden-sp-thread',
timestamp: 102,
} as Comment,
};
await act(async () => {
root.render(createElement(Result, { communityAddresses: ['sports-posting.bso'] }));
});
expect(container.querySelector('[data-testid="hidden-cids"]')?.textContent).toBe('hidden-sp-thread');
});
it('includes hidden threads from every board in a multiboard scope', async () => {
testState.account = { blockedCids: { 'hidden-mu-thread': true, 'hidden-other-thread': true, 'hidden-sp-thread': true } };
testState.commentsByCid = {
'hidden-mu-thread': {
cid: 'hidden-mu-thread',
communityAddress: 'music-posting.eth',
postCid: 'hidden-mu-thread',
timestamp: 100,
} as Comment,
'hidden-other-thread': {
cid: 'hidden-other-thread',
communityAddress: 'other-board.eth',
postCid: 'hidden-other-thread',
timestamp: 101,
} as Comment,
'hidden-sp-thread': {
cid: 'hidden-sp-thread',
communityAddress: SPORTS_PUBLIC_KEY,
postCid: 'hidden-sp-thread',
timestamp: 102,
} as Comment,
};
await act(async () => {
root.render(createElement(Result, { communityAddresses: ['music-posting.eth', 'sports-posting.bso'] }));
});
expect(container.querySelector('[data-testid="hidden-cids"]')?.textContent).toBe('hidden-sp-thread,hidden-mu-thread');
});
it('uses candidate comments from the current feed when the blocked cid lookup has not loaded the comment', async () => {
testState.account = { blockedCids: { 'hidden-mu-thread': true } };
testState.commentsByCid = {};
await act(async () => {
root.render(
createElement(Result, {
candidateComments: [
{
cid: 'hidden-mu-thread',
communityAddress: 'music-posting.eth',
postCid: 'hidden-mu-thread',
timestamp: 100,
} as Comment,
],
communityAddresses: ['music-posting.eth'],
}),
);
});
expect(container.querySelector('[data-testid="hidden-cids"]')?.textContent).toBe('hidden-mu-thread');
});
});
+132
View File
@@ -0,0 +1,132 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import useHide from '../use-hide';
import useHiddenCatalogThreadsStore from '../../stores/use-hidden-catalog-threads-store';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
account: { id: 'account-1', blockedCids: {} as Record<string, boolean> },
blockCidMock: vi.fn(),
unblockCidMock: vi.fn(),
}));
vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
useAccount: () => testState.account,
useBlock: ({ cid }: { cid?: string }) => ({
blocked: Boolean(cid && testState.account.blockedCids[cid]),
error: undefined,
errors: [],
state: 'ready',
}),
}));
vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts', () => ({
default: {
getState: () => ({
accounts: { [testState.account.id]: testState.account },
accountsActions: {
blockCid: testState.blockCidMock,
unblockCid: testState.unblockCidMock,
},
activeAccountId: testState.account.id,
}),
},
}));
const HideButton = ({ cid, comment }: { cid: string; comment?: { cid: string; communityAddress?: string; postCid?: string } }) => {
const { hide, hidden, unhide } = useHide({ cid, comment });
return (
<div>
<button data-hidden={hidden ? 'true' : 'false'} data-testid='hide' type='button' onClick={hide}>
hide {cid}
</button>
<button data-hidden={hidden ? 'true' : 'false'} data-testid='unhide' type='button' onClick={unhide}>
unhide {cid}
</button>
</div>
);
};
let container: HTMLDivElement;
let root: Root;
describe('useHide', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.account = { id: 'account-1', blockedCids: {} };
testState.blockCidMock.mockImplementation(async (cid: string) => {
testState.account = {
...testState.account,
blockedCids: { ...testState.account.blockedCids, [cid]: true },
};
});
testState.unblockCidMock.mockImplementation(async (cid: string) => {
const blockedCids = { ...testState.account.blockedCids };
delete blockedCids[cid];
testState.account = { ...testState.account, blockedCids };
});
useHiddenCatalogThreadsStore.setState({ hiddenCommentsByCid: {}, scopeHiddenThreadsCounts: {}, shownScopeKey: null });
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
useHiddenCatalogThreadsStore.setState({ hiddenCommentsByCid: {}, scopeHiddenThreadsCounts: {}, shownScopeKey: null });
});
it('hides the current cid after a reused component rerenders for another post', async () => {
await act(async () => {
root.render(createElement(HideButton, { cid: 'first-thread' }));
});
await act(async () => {
root.render(createElement(HideButton, { cid: 'second-thread' }));
});
await act(async () => {
container.querySelector<HTMLButtonElement>('[data-testid="hide"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.blockCidMock).toHaveBeenCalledTimes(1);
expect(testState.blockCidMock).toHaveBeenCalledWith('second-thread');
expect(testState.account.blockedCids).toEqual({ 'second-thread': true });
});
it('unhides the current cid and skips duplicate account writes', async () => {
testState.account = { id: 'account-1', blockedCids: { 'hidden-thread': true } };
await act(async () => {
root.render(createElement(HideButton, { cid: 'hidden-thread' }));
});
expect(container.querySelector<HTMLButtonElement>('[data-testid="unhide"]')?.dataset.hidden).toBe('true');
await act(async () => {
container.querySelector<HTMLButtonElement>('[data-testid="hide"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
container.querySelector<HTMLButtonElement>('[data-testid="unhide"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(testState.blockCidMock).not.toHaveBeenCalled();
expect(testState.unblockCidMock).toHaveBeenCalledWith('hidden-thread');
expect(testState.account.blockedCids).toEqual({});
expect(useHiddenCatalogThreadsStore.getState().hiddenCommentsByCid['hidden-thread']).toBeUndefined();
});
it('remembers the hidden comment so catalog counters can resolve it immediately', async () => {
const comment = { cid: 'remembered-thread', communityAddress: 'music-posting.eth', postCid: 'remembered-thread' };
await act(async () => {
root.render(createElement(HideButton, { cid: 'remembered-thread', comment }));
});
await act(async () => {
container.querySelector<HTMLButtonElement>('[data-testid="hide"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
expect(useHiddenCatalogThreadsStore.getState().hiddenCommentsByCid['remembered-thread']).toEqual(comment);
});
});
@@ -0,0 +1,162 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Comment } from '@bitsocial/bitsocial-react-hooks';
import communitiesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities';
import communitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import usePruneHiddenCatalogThreads from '../use-prune-hidden-catalog-threads';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
const testState = vi.hoisted(() => ({
account: { id: 'account-1', blockedCids: {} as Record<string, boolean> },
unblockCidMock: vi.fn(),
}));
vi.mock('@bitsocial/bitsocial-react-hooks', async () => {
const actual = await vi.importActual<typeof import('@bitsocial/bitsocial-react-hooks')>('@bitsocial/bitsocial-react-hooks');
return {
...actual,
useAccount: () => testState.account,
};
});
vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts', () => ({
default: {
getState: () => ({
accounts: { [testState.account.id]: testState.account },
accountsActions: {
unblockCid: testState.unblockCidMock,
},
activeAccountId: testState.account.id,
}),
},
}));
vi.mock('../use-directories', async () => {
const actual = await vi.importActual<typeof import('../use-directories')>('../use-directories');
return {
...actual,
useDirectories: () => [{ address: 'music-posting.eth', directoryCode: 'mu', title: '/mu/ - Music' }],
};
});
const hiddenThread = {
cid: 'hidden-mu-thread',
communityAddress: 'music-posting.eth',
postCid: 'hidden-mu-thread',
title: 'hidden',
} as Comment;
const visibleThread = {
cid: 'visible-mu-thread',
communityAddress: 'music-posting.eth',
postCid: 'visible-mu-thread',
title: 'visible',
} as Comment;
const PruneHarness = ({ enabled = true, hiddenThreadCandidates = [hiddenThread] }: { enabled?: boolean; hiddenThreadCandidates?: Comment[] }) => {
usePruneHiddenCatalogThreads({
communityAddress: 'music-posting.eth',
enabled,
hiddenThreadCandidates,
sortType: 'new',
});
return null;
};
const setRawBoardPage = (comments: Comment[], nextCid?: string) => {
communitiesStore.setState({
communities: {
'music-posting.eth': {
address: 'music-posting.eth',
posts: {
pageCids: {
new: 'raw-page-1',
},
},
updatedAt: 1,
},
},
});
communitiesPagesStore.setState({
communitiesPages: {
'raw-page-1': {
comments,
nextCid,
},
},
});
};
const flushEffects = async () => {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
};
let container: HTMLDivElement;
let root: Root;
describe('usePruneHiddenCatalogThreads', () => {
beforeEach(() => {
vi.clearAllMocks();
testState.account = { id: 'account-1', blockedCids: { [hiddenThread.cid]: true } };
testState.unblockCidMock.mockImplementation(async (cid: string) => {
const blockedCids = { ...testState.account.blockedCids };
delete blockedCids[cid];
testState.account = { ...testState.account, blockedCids };
});
communitiesStore.setState({ communities: {} });
communitiesPagesStore.setState({ communitiesPages: {}, comments: {} });
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
communitiesStore.setState({ communities: {} });
communitiesPagesStore.setState({ communitiesPages: {}, comments: {} });
});
it('does not unhide a thread just because the visible feed filtered it out', async () => {
setRawBoardPage([hiddenThread, visibleThread]);
await act(async () => {
root.render(createElement(PruneHarness));
});
await flushEffects();
expect(testState.unblockCidMock).not.toHaveBeenCalled();
expect(testState.account.blockedCids).toEqual({ [hiddenThread.cid]: true });
});
it('unhides a stale hidden thread when a fully loaded raw board page proves it is gone', async () => {
setRawBoardPage([visibleThread]);
await act(async () => {
root.render(createElement(PruneHarness));
});
await flushEffects();
expect(testState.unblockCidMock).toHaveBeenCalledWith(hiddenThread.cid);
expect(testState.account.blockedCids).toEqual({});
});
it('waits for the entire raw board page chain before pruning', async () => {
setRawBoardPage([visibleThread], 'raw-page-2');
await act(async () => {
root.render(createElement(PruneHarness));
});
await flushEffects();
expect(testState.unblockCidMock).not.toHaveBeenCalled();
expect(testState.account.blockedCids).toEqual({ [hiddenThread.cid]: true });
});
});
+208
View File
@@ -0,0 +1,208 @@
import { useMemo } from 'react';
import { type Comment, useAccount, useComments } from '@bitsocial/bitsocial-react-hooks';
import { isCommentArchived } from '../lib/utils/comment-moderation-utils';
import { getCommentCommunityAddress } from '../lib/utils/comment-utils';
import useHiddenCatalogThreadsStore from '../stores/use-hidden-catalog-threads-store';
import { findDirectoryByAddress, normalizeBoardAddress, useDirectories, type DirectoryCommunity } from './use-directories';
type HiddenCatalogThreadsOptions = {
candidateComments?: readonly Comment[];
communityAddresses: readonly string[];
sortType: 'active' | 'new';
};
type HiddenCatalogThreadsResult = {
hiddenCatalogThreads: Comment[];
hiddenThreadCandidates: Comment[];
isLoadingHiddenCatalogThreads: boolean;
scopeKey: string;
};
const getHiddenCatalogThreadsScopeKey = (communityAddresses: readonly string[]): string => communityAddresses.filter(Boolean).slice().sort().join('\u0000');
const addAddressKeys = (keys: Set<string>, address: string | undefined) => {
if (!address) {
return;
}
keys.add(address);
keys.add(normalizeBoardAddress(address));
};
export const getBoardAddressKeys = (address: string | undefined, directories: DirectoryCommunity[]): Set<string> => {
const keys = new Set<string>();
addAddressKeys(keys, address);
const directory = findDirectoryByAddress(directories, address);
if (directory) {
addAddressKeys(keys, directory.address);
addAddressKeys(keys, directory.name);
addAddressKeys(keys, directory.publicKey);
addAddressKeys(keys, directory.directoryCode);
}
keys.delete('');
return keys;
};
const getScopeAddressKeys = (communityAddresses: readonly string[], directories: DirectoryCommunity[]): Set<string> => {
const keys = new Set<string>();
for (const communityAddress of communityAddresses) {
for (const key of getBoardAddressKeys(communityAddress, directories)) {
keys.add(key);
}
}
return keys;
};
export const isBoardAddressInScope = (address: string | undefined, scopeAddressKeys: Set<string>, directories: DirectoryCommunity[]): boolean => {
if (!address || scopeAddressKeys.size === 0) {
return false;
}
for (const key of getBoardAddressKeys(address, directories)) {
if (scopeAddressKeys.has(key)) {
return true;
}
}
return false;
};
const getBlockedCidList = (blockedCids: { [cid: string]: boolean | undefined } | undefined): string[] =>
Object.entries(blockedCids || {})
.filter(([, blocked]) => blocked)
.map(([cid]) => cid)
.sort();
const isThreadPost = (comment: Comment): boolean => {
const { cid, parentCid, postCid } = comment || {};
return Boolean(cid && !parentCid && (!postCid || postCid === cid));
};
const getTimestamp = (value: unknown): number => (typeof value === 'number' && Number.isFinite(value) ? value : 0);
const sortHiddenThreads = (threads: Comment[], sortType: 'active' | 'new'): Comment[] =>
[...threads].sort((a, b) => {
if (a.pinned !== b.pinned) {
return a.pinned ? -1 : 1;
}
const activeTimestampDifference = sortType === 'active' ? getTimestamp(b.lastReplyTimestamp || b.timestamp) - getTimestamp(a.lastReplyTimestamp || a.timestamp) : 0;
if (activeTimestampDifference !== 0) {
return activeTimestampDifference;
}
const timestampDifference = getTimestamp(b.timestamp) - getTimestamp(a.timestamp);
if (timestampDifference !== 0) {
return timestampDifference;
}
return String(a.cid || '').localeCompare(String(b.cid || ''));
});
const getHiddenThreadCandidates = ({
blockedCidList,
comments,
communityAddresses,
directories,
}: {
blockedCidList: readonly string[];
comments: readonly (Comment | undefined)[];
communityAddresses: readonly string[];
directories: DirectoryCommunity[];
}): Comment[] => {
if (blockedCidList.length === 0 || communityAddresses.length === 0) {
return [];
}
const blockedCidSet = new Set(blockedCidList);
const scopeAddressKeys = getScopeAddressKeys(communityAddresses, directories);
const seenCids = new Set<string>();
const candidates: Comment[] = [];
for (const comment of comments) {
const cid = comment?.cid;
if (!comment || !cid || seenCids.has(cid) || !blockedCidSet.has(cid)) {
continue;
}
if (!isThreadPost(comment)) {
continue;
}
if (!isBoardAddressInScope(getCommentCommunityAddress(comment), scopeAddressKeys, directories)) {
continue;
}
seenCids.add(cid);
candidates.push(comment);
}
return candidates;
};
const mergeCommentsByCid = (comments: readonly (Comment | undefined)[], candidateComments: readonly Comment[]): (Comment | undefined)[] => {
if (candidateComments.length === 0) {
return [...comments];
}
const mergedComments = [...comments];
const seenCids = new Set(comments.map((comment) => comment?.cid).filter((cid): cid is string => typeof cid === 'string'));
for (const comment of candidateComments) {
const cid = comment?.cid;
if (!cid || seenCids.has(cid)) {
continue;
}
seenCids.add(cid);
mergedComments.push(comment);
}
return mergedComments;
};
const getHiddenCatalogThreads = (hiddenThreadCandidates: readonly Comment[], sortType: 'active' | 'new'): Comment[] =>
sortHiddenThreads(
hiddenThreadCandidates.filter((comment) => !isCommentArchived(comment)),
sortType,
);
const useHiddenCatalogThreads = ({ candidateComments = [], communityAddresses, sortType }: HiddenCatalogThreadsOptions): HiddenCatalogThreadsResult => {
const account = useAccount();
const directories = useDirectories();
const hiddenCommentsByCid = useHiddenCatalogThreadsStore((state) => state.hiddenCommentsByCid);
const blockedCidList = useMemo(() => getBlockedCidList(account?.blockedCids), [account?.blockedCids]);
const { comments, state } = useComments({
autoUpdate: false,
commentCids: blockedCidList,
});
const rememberedHiddenComments = useMemo(
() => blockedCidList.map((cid) => hiddenCommentsByCid[cid]).filter((comment): comment is Comment => Boolean(comment)),
[blockedCidList, hiddenCommentsByCid],
);
const candidateCommentList = useMemo(
() => mergeCommentsByCid(mergeCommentsByCid(comments, rememberedHiddenComments), candidateComments),
[candidateComments, comments, rememberedHiddenComments],
);
const hiddenThreadCandidates = useMemo(
() =>
getHiddenThreadCandidates({
blockedCidList,
comments: candidateCommentList,
communityAddresses,
directories,
}),
[blockedCidList, candidateCommentList, communityAddresses, directories],
);
const hiddenCatalogThreads = useMemo(() => getHiddenCatalogThreads(hiddenThreadCandidates, sortType), [hiddenThreadCandidates, sortType]);
const scopeKey = useMemo(() => getHiddenCatalogThreadsScopeKey(communityAddresses), [communityAddresses]);
return useMemo(
() => ({
hiddenCatalogThreads,
hiddenThreadCandidates,
isLoadingHiddenCatalogThreads: blockedCidList.length > 0 && state !== 'succeeded',
scopeKey,
}),
[blockedCidList.length, hiddenCatalogThreads, hiddenThreadCandidates, scopeKey, state],
);
};
export default useHiddenCatalogThreads;
+80 -48
View File
@@ -1,59 +1,91 @@
import { useCallback, useMemo } from 'react';
import { create } from 'zustand';
import localForageLru from '@bitsocial/bitsocial-react-hooks/dist/lib/localforage-lru/index.js';
import { useAccount, useBlock } from '@bitsocial/bitsocial-react-hooks';
import type { Comment } from '@bitsocial/bitsocial-react-hooks';
import accountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts';
import useHiddenCatalogThreadsStore from '../stores/use-hidden-catalog-threads-store';
interface HideStoreState {
hiddenCids: { [key: string]: boolean };
hide: (cid: string) => void;
unhide: (cid: string) => void;
}
export type HiddenCidLookup = { [cid: string]: boolean | undefined };
const hideStore = localForageLru.createInstance({
name: 'hideStore',
size: 1000,
});
const useHideStore = create<HideStoreState>((set) => ({
hiddenCids: {},
hide: (cid: string) => {
set((state) => ({
hiddenCids: { ...state.hiddenCids, [cid]: true },
}));
hideStore.setItem(cid, true);
},
unhide: (cid: string) => {
set((state) => {
const newHiddenCids = { ...state.hiddenCids };
delete newHiddenCids[cid];
return { hiddenCids: newHiddenCids };
});
hideStore.removeItem(cid);
},
}));
const initializeHideStore = async () => {
const entries: [string, boolean][] = await hideStore.entries();
const hiddenCids: { [key: string]: boolean } = {};
entries.forEach(([key, value]) => {
hiddenCids[key] = value;
});
useHideStore.setState((state) => ({
hiddenCids: { ...hiddenCids, ...state.hiddenCids },
}));
type CommentWithCid = {
cid?: string;
};
initializeHideStore();
export const isCidHidden = (hiddenCids: HiddenCidLookup | undefined, cid?: string): boolean => Boolean(cid && hiddenCids?.[cid]);
const useHide = ({ cid }: { cid: string }) => {
const hidden = useHideStore((state) => !!state.hiddenCids[cid]);
const hide = useHideStore((state) => state.hide);
const unhide = useHideStore((state) => state.unhide);
export const filterHiddenComments = <T extends CommentWithCid>(comments: readonly T[], hiddenCids: HiddenCidLookup | undefined): T[] =>
comments.filter((comment) => !isCidHidden(hiddenCids, comment?.cid));
const hideCallback = useCallback(() => hide(cid), [hide, cid]);
const unhideCallback = useCallback(() => unhide(cid), [unhide, cid]);
export const useHiddenCids = (): HiddenCidLookup => {
const account = useAccount();
return useMemo(() => account?.blockedCids || {}, [account?.blockedCids]);
};
return useMemo(() => ({ hidden, hide: hideCallback, unhide: unhideCallback }), [hidden, hideCallback, unhideCallback]);
const shouldLogHideActionError = (cid: string, expectedHidden: boolean): boolean => {
const { accounts, activeAccountId } = accountsStore.getState();
if (!activeAccountId) {
return true;
}
return Boolean(accounts?.[activeAccountId]?.blockedCids?.[cid]) !== expectedHidden;
};
const getCurrentAccountHiddenState = (cid: string): boolean => {
const { accounts, activeAccountId } = accountsStore.getState();
return Boolean(activeAccountId && accounts?.[activeAccountId]?.blockedCids?.[cid]);
};
const useHide = ({ cid, comment }: { cid: string; comment?: Comment }) => {
const account = useAccount();
const { error, errors, state } = useBlock({ cid: cid || undefined });
const hidden = isCidHidden(account?.blockedCids, cid);
const rememberHiddenComment = useHiddenCatalogThreadsStore((state) => state.rememberHiddenComment);
const forgetHiddenComment = useHiddenCatalogThreadsStore((state) => state.forgetHiddenComment);
const hide = useCallback(() => {
if (!cid) {
return;
}
if (getCurrentAccountHiddenState(cid)) {
rememberHiddenComment(comment);
return;
}
rememberHiddenComment(comment);
void accountsStore
.getState()
.accountsActions.blockCid(cid)
.catch((error: unknown) => {
if (!getCurrentAccountHiddenState(cid)) {
forgetHiddenComment(cid);
}
if (shouldLogHideActionError(cid, true)) {
console.error('Failed to hide post', error);
}
});
}, [cid, comment, forgetHiddenComment, rememberHiddenComment]);
const unhide = useCallback(() => {
if (!cid) {
return;
}
forgetHiddenComment(cid);
if (!getCurrentAccountHiddenState(cid)) {
return;
}
void accountsStore
.getState()
.accountsActions.unblockCid(cid)
.catch((error: unknown) => {
if (shouldLogHideActionError(cid, false)) {
console.error('Failed to unhide post', error);
}
});
}, [cid, forgetHiddenComment]);
return useMemo(() => ({ error, errors, hidden, hide, state, unhide }), [error, errors, hidden, hide, state, unhide]);
};
export default useHide;
@@ -0,0 +1,152 @@
import { useEffect, useMemo, useRef } from 'react';
import { useAccount, type Comment, type CommunitiesPages, type Community } from '@bitsocial/bitsocial-react-hooks';
import accountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts';
import communitiesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities';
import communitiesPagesStore, { getCommunityFirstPageCid, getCommunityPages } from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
import { getCommentCommunityAddress } from '../lib/utils/comment-utils';
import { isCommentArchived } from '../lib/utils/comment-moderation-utils';
import { useDirectories } from './use-directories';
import { getBoardAddressKeys, isBoardAddressInScope } from './use-hidden-catalog-threads';
type UsePruneHiddenCatalogThreadsOptions = {
enabled: boolean;
hiddenThreadCandidates: readonly Comment[];
communityAddress: string | undefined;
sortType: 'active' | 'new';
};
type RawBoardCatalogState = {
isFullyLoaded: boolean;
rootThreadCids: Set<string>;
};
const EMPTY_RAW_BOARD_CATALOG_STATE: RawBoardCatalogState = {
isFullyLoaded: false,
rootThreadCids: new Set<string>(),
};
const addRootThreadCids = (cids: Set<string>, comments: readonly Comment[] | undefined) => {
for (const comment of comments || []) {
if (comment?.cid && !comment.parentCid) {
cids.add(comment.cid);
}
}
};
const getRawBoardCatalogState = ({
accountId,
communitiesPages,
community,
sortType,
}: {
accountId: string | undefined;
communitiesPages: CommunitiesPages;
community: Community | undefined;
sortType: 'active' | 'new';
}): RawBoardCatalogState => {
if (!community) {
return EMPTY_RAW_BOARD_CATALOG_STATE;
}
const rootThreadCids = new Set<string>();
const preloadedSortPage = community.posts?.pages?.[sortType];
addRootThreadCids(rootThreadCids, preloadedSortPage?.comments);
const firstPageCid = getCommunityFirstPageCid(community, sortType, 'posts');
const pages = firstPageCid ? getCommunityPages(community, sortType, communitiesPages, 'posts', accountId) : [];
for (const page of pages) {
addRootThreadCids(rootThreadCids, page?.comments);
}
if (pages.length > 0) {
return {
isFullyLoaded: !pages[pages.length - 1]?.nextCid,
rootThreadCids,
};
}
const pageCids = community.posts?.pageCids || {};
const hasPageCids = Object.keys(pageCids).length > 0;
const preloadedPages = Object.values(community.posts?.pages || {}) as Array<{ comments?: Comment[]; nextCid?: string }>;
const hasCompletePreloadedPage = !hasPageCids && preloadedPages.some((page) => Array.isArray(page?.comments)) && preloadedPages.every((page) => !page?.nextCid);
if (hasCompletePreloadedPage) {
for (const page of preloadedPages) {
addRootThreadCids(rootThreadCids, page?.comments);
}
}
return {
isFullyLoaded: hasCompletePreloadedPage,
rootThreadCids,
};
};
const usePruneHiddenCatalogThreads = ({ enabled, hiddenThreadCandidates, communityAddress, sortType }: UsePruneHiddenCatalogThreadsOptions) => {
const account = useAccount();
const directories = useDirectories();
const community = communitiesStore((state) => (communityAddress ? state.communities[communityAddress] : undefined));
const communitiesPages = communitiesPagesStore((state) => state.communitiesPages);
const pendingPruneCidsRef = useRef(new Set<string>());
const boardAddressKeys = useMemo(() => (enabled ? getBoardAddressKeys(communityAddress, directories) : new Set<string>()), [communityAddress, directories, enabled]);
const rawBoardCatalogState = useMemo(
() =>
enabled
? getRawBoardCatalogState({
accountId: account?.id,
communitiesPages,
community,
sortType,
})
: EMPTY_RAW_BOARD_CATALOG_STATE,
[account?.id, communitiesPages, community, enabled, sortType],
);
const removedHiddenThreadCids = useMemo(() => {
if (!enabled || boardAddressKeys.size === 0 || !rawBoardCatalogState.isFullyLoaded) {
return [];
}
return hiddenThreadCandidates
.filter((comment) => {
const cid = comment?.cid;
return (
cid &&
!isCommentArchived(comment) &&
isBoardAddressInScope(getCommentCommunityAddress(comment), boardAddressKeys, directories) &&
!rawBoardCatalogState.rootThreadCids.has(cid)
);
})
.map((comment) => comment.cid)
.filter((cid): cid is string => typeof cid === 'string')
.sort();
}, [boardAddressKeys, directories, enabled, hiddenThreadCandidates, rawBoardCatalogState]);
useEffect(() => {
if (!enabled || removedHiddenThreadCids.length === 0) {
return;
}
for (const cid of removedHiddenThreadCids) {
if (pendingPruneCidsRef.current.has(cid)) {
continue;
}
pendingPruneCidsRef.current.add(cid);
void accountsStore
.getState()
.accountsActions.unblockCid(cid)
.catch((error: unknown) => {
const { accounts, activeAccountId } = accountsStore.getState();
if (activeAccountId && accounts?.[activeAccountId]?.blockedCids?.[cid]) {
console.error('Failed to remove stale hidden thread from account', error);
}
})
.finally(() => {
pendingPruneCidsRef.current.delete(cid);
});
}
}, [enabled, removedHiddenThreadCids]);
};
export default usePruneHiddenCatalogThreads;