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