perf(mod queue): stabilize moderation subscriptions

This commit is contained in:
Tommaso Casaburi
2026-06-15 18:10:58 +07:00
parent 75dd7a8496
commit f3565f0dda
4 changed files with 229 additions and 46 deletions
+88 -1
View File
@@ -9,6 +9,7 @@ import { useBoardFeedPageSize } from '../use-board-feed-page-size';
import { useBoardPseudonymityMode } from '../use-board-pseudonymity-mode';
import useCountLinksInReplies from '../use-count-links-in-replies';
import { useFilteredDirectoryAddresses } from '../use-filtered-directory-addresses';
import { useModeratedCommunityAddressInputs, useModeratedCommunityAddressesForInputs } from '../use-moderated-community-addresses';
import useAllFeedFilterStore from '../../stores/use-all-feed-filter-store';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -17,6 +18,7 @@ const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise
const testState = vi.hoisted(() => ({
account: undefined as unknown,
accountCommunities: {} as Record<string, unknown>,
liveCommunities: {} as Record<string, unknown>,
directories: [] as Array<{ address: string; nsfw?: boolean }>,
directoryLookup: {} as Record<string, unknown>,
flattenedReplies: [] as unknown[],
@@ -35,13 +37,14 @@ vi.mock('@bitsocial/bitsocial-react-hooks', () => ({
vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js', () => ({
default: (
selector: (state: { activeAccountId?: string; accounts: Record<string, { communities?: typeof testState.accountCommunities }> }) => unknown,
selector: (state: { activeAccountId?: string; accounts: Record<string, Record<string, unknown> & { communities?: typeof testState.accountCommunities }> }) => unknown,
equalityFn?: (previous: unknown, next: unknown) => boolean,
) => {
const nextValue = selector({
activeAccountId: 'active',
accounts: {
active: {
...(testState.account as Record<string, unknown> | undefined),
communities: testState.accountCommunities,
},
},
@@ -57,6 +60,27 @@ vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js', () =>
},
}));
const communitiesStoreSelectorCache = vi.hoisted(() => ({
hasValue: false,
value: undefined as unknown,
}));
vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/communities', () => ({
default: (selector: (state: { communities: Record<string, unknown> }) => unknown, equalityFn?: (previous: unknown, next: unknown) => boolean) => {
const nextValue = selector({
communities: testState.liveCommunities,
});
if (communitiesStoreSelectorCache.hasValue && equalityFn?.(communitiesStoreSelectorCache.value, nextValue)) {
return communitiesStoreSelectorCache.value;
}
communitiesStoreSelectorCache.hasValue = true;
communitiesStoreSelectorCache.value = nextValue;
return nextValue;
},
}));
vi.mock('@bitsocial/bitsocial-react-hooks/dist/lib/community-address.js', () => ({
getEquivalentCommunityAddressGroupKey: (address: string) => (address.endsWith('.eth') ? address.slice(0, -4) + '.bso' : address),
pickPreferredEquivalentCommunityAddress: (addresses: string[]) => addresses.find((address) => address.endsWith('.bso')) || addresses[0],
@@ -67,6 +91,7 @@ vi.mock('@bitsocial/bitsocial-react-hooks/dist/lib/utils', () => ({
}));
vi.mock('../use-directories', () => ({
normalizeBoardAddress: (address: string | undefined) => address?.toLowerCase() ?? '',
useDirectories: () => testState.directories,
useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryLookup[address] : undefined),
}));
@@ -110,12 +135,15 @@ describe('selector hooks', () => {
vi.clearAllMocks();
testState.account = undefined;
testState.accountCommunities = {};
testState.liveCommunities = {};
testState.directories = [];
testState.directoryLookup = {};
testState.flattenedReplies = [];
testState.communitySnapshot = undefined;
accountsStoreSelectorCache.hasValue = false;
accountsStoreSelectorCache.value = undefined;
communitiesStoreSelectorCache.hasValue = false;
communitiesStoreSelectorCache.value = undefined;
useAllFeedFilterStore.getState().setFilter('all');
container = document.createElement('div');
@@ -166,6 +194,65 @@ describe('selector hooks', () => {
expect(addressesWithNewBoard).toEqual(['biz.eth', 'music.eth', 'tech.eth']);
});
it('keeps moderated board address identity stable when transient community state changes', () => {
testState.account = {
author: { address: '0xme' },
subscriptions: ['sub.eth'],
};
testState.accountCommunities = {
'owned.eth': { address: 'owned.eth', state: 'updating' },
};
testState.directories = [{ address: 'dir.eth' }, { address: 'sub.eth' }];
testState.liveCommunities = {
'dir.eth': {
address: 'dir.eth',
state: 'fetching-ipns',
roles: { '0xme': { role: 'moderator' } },
},
'sub.eth': {
address: 'sub.eth',
state: 'fetching-ipns',
roles: { '0xme': { role: 'member' } },
},
};
const useModeratedAddresses = () => {
const inputs = useModeratedCommunityAddressInputs();
return useModeratedCommunityAddressesForInputs(inputs);
};
const initialAddresses = rerenderHookValue(useModeratedAddresses);
expect(initialAddresses).toEqual(['owned.eth', 'dir.eth']);
testState.liveCommunities = {
'dir.eth': {
address: 'dir.eth',
state: 'succeeded',
roles: { '0xme': { role: 'moderator' } },
},
'sub.eth': {
address: 'sub.eth',
state: 'succeeded',
roles: { '0xme': { role: 'member' } },
},
};
expect(rerenderHookValue(useModeratedAddresses)).toBe(initialAddresses);
testState.liveCommunities = {
...testState.liveCommunities,
'sub.eth': {
address: 'sub.eth',
state: 'succeeded',
roles: { '0xme': { role: 'admin' } },
},
};
const addressesWithNewRole = rerenderHookValue(useModeratedAddresses);
expect(addressesWithNewRole).not.toBe(initialAddresses);
expect(addressesWithNewRole).toEqual(['owned.eth', 'dir.eth', 'sub.eth']);
});
it('computes moderator privileges and whether the current account authored the comment', () => {
testState.account = { author: { address: '0xme' } };
testState.communitySnapshot = {
+4 -1
View File
@@ -12,10 +12,13 @@ type AccountsStoreState = {
const EMPTY_ACCOUNT_COMMUNITY_ADDRESSES: string[] = [];
const areStringArraysEqual = (previous: string[], next: string[]) => {
export const areStringArraysEqual = (previous: readonly string[] | undefined, next: readonly string[] | undefined) => {
if (previous === next) {
return true;
}
if (!previous || !next) {
return previous === next;
}
if (previous.length !== next.length) {
return false;
}
+91 -14
View File
@@ -1,9 +1,58 @@
import { useMemo } from 'react';
import { useAccount, useCommunities } from '@bitsocial/bitsocial-react-hooks';
import type { Community } from '@bitsocial/bitsocial-react-hooks';
import useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js';
import useCommunitiesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities';
import { useDirectories } from './use-directories';
import { useCommunityIdentifiers } from './use-community-identifiers';
import { useAccountCommunityAddresses } from './use-account-community-addresses';
import { areStringArraysEqual, useAccountCommunityAddresses } from './use-account-community-addresses';
import { getModeratedCommunityAddresses } from '../lib/utils/mod-queue-utils';
import { normalizeBoardAddress } from '../lib/utils/directory-list-lookup-utils';
type AccountModerationSnapshot = {
accountAddress: string | undefined;
subscriptions: readonly string[];
};
type AccountWithModerationFields = {
author?: {
address?: string;
};
subscriptions?: string[];
};
type AccountsStoreState = {
activeAccountId?: string;
accounts: Record<string, AccountWithModerationFields | undefined>;
};
type CommunitiesStoreState = {
communities: Record<string, Community | undefined>;
};
export type ModeratedCommunityAddressInputs = {
accountAddress: string | undefined;
accountCommunityAddresses: string[];
candidateCommunityAddresses: string[];
};
const EMPTY_SUBSCRIPTIONS: readonly string[] = [];
const areAccountModerationSnapshotsEqual = (previous: AccountModerationSnapshot | undefined, next: AccountModerationSnapshot | undefined) => {
if (previous === next) {
return true;
}
if (!previous || !next) {
return previous === next;
}
return previous.accountAddress === next.accountAddress && areStringArraysEqual(previous.subscriptions, next.subscriptions);
};
const getAccountModerationSnapshot = (state: AccountsStoreState): AccountModerationSnapshot => {
const account = state.activeAccountId ? state.accounts[state.activeAccountId] : undefined;
return {
accountAddress: account?.author?.address,
subscriptions: account?.subscriptions ?? EMPTY_SUBSCRIPTIONS,
};
};
const addUniqueAddress = (addresses: string[], address: string | undefined) => {
if (address && !addresses.includes(address)) {
@@ -11,9 +60,21 @@ const addUniqueAddress = (addresses: string[], address: string | undefined) => {
}
};
export const useModeratedCommunityAddresses = (): string[] => {
const account = useAccount();
const accountAddress = account?.author?.address;
const getCommunityByAddress = (communities: Record<string, Community | undefined>, communityAddress: string): Community | undefined => {
const exactMatch = communities[communityAddress];
if (exactMatch) {
return exactMatch;
}
const normalizedAddress = normalizeBoardAddress(communityAddress);
return Object.entries(communities).find(([key, community]) => {
const candidateAddress = typeof community?.address === 'string' ? community.address : key;
return normalizeBoardAddress(candidateAddress) === normalizedAddress;
})?.[1];
};
export const useModeratedCommunityAddressInputs = (): ModeratedCommunityAddressInputs => {
const { accountAddress, subscriptions } = useAccountsStore(getAccountModerationSnapshot, areAccountModerationSnapshotsEqual);
const accountCommunityAddresses = useAccountCommunityAddresses();
const directories = useDirectories();
@@ -25,23 +86,39 @@ export const useModeratedCommunityAddresses = (): string[] => {
for (const directory of directories) {
addUniqueAddress(addresses, directory.address);
}
for (const address of account?.subscriptions ?? []) {
for (const address of subscriptions) {
addUniqueAddress(addresses, address);
}
return addresses;
}, [account?.subscriptions, accountCommunityAddresses, directories]);
const candidateCommunities = useCommunityIdentifiers(candidateCommunityAddresses);
const { communities } = useCommunities(candidateCommunities.length > 0 ? { communities: candidateCommunities } : undefined);
}, [accountCommunityAddresses, directories, subscriptions]);
return useMemo(
() =>
() => ({
accountAddress,
accountCommunityAddresses,
candidateCommunityAddresses,
}),
[accountAddress, accountCommunityAddresses, candidateCommunityAddresses],
);
};
export const useModeratedCommunityAddressesForInputs = ({
accountAddress,
accountCommunityAddresses,
candidateCommunityAddresses,
}: ModeratedCommunityAddressInputs): string[] =>
useCommunitiesStore(
(state: CommunitiesStoreState) =>
getModeratedCommunityAddresses({
accountAddress,
accountCommunityAddresses,
candidateCommunityAddresses,
communities,
communities: candidateCommunityAddresses.map((candidateAddress) => getCommunityByAddress(state.communities, candidateAddress)),
}),
[accountAddress, accountCommunityAddresses, candidateCommunityAddresses, communities],
areStringArraysEqual,
);
export const useModeratedCommunityAddresses = (): string[] => {
const inputs = useModeratedCommunityAddressInputs();
return useModeratedCommunityAddressesForInputs(inputs);
};
+46 -30
View File
@@ -2,7 +2,7 @@ import React, { useMemo, useState, useEffect, useCallback, memo } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useParams, Link } from 'react-router-dom';
import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useCommunity, useAccount } from '@bitsocial/bitsocial-react-hooks';
import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useCommunity, useCommunities } from '@bitsocial/bitsocial-react-hooks';
import useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js';
import { useFloating, offset, shift, size, flip, autoUpdate } from '@floating-ui/react';
import { Virtuoso, type Components } from 'react-virtuoso';
@@ -49,7 +49,7 @@ import capitalize from 'lodash/capitalize';
import lowerCase from 'lodash/lowerCase';
import { PageFooterDesktop, PageFooterMobile, StyleOnlyFooterFirstRow } from '../../components/footer/footer';
import footerStyles from '../../components/footer/footer.module.css';
import { useModeratedCommunityAddresses } from '../../hooks/use-moderated-community-addresses';
import { useModeratedCommunityAddressInputs, useModeratedCommunityAddressesForInputs } from '../../hooks/use-moderated-community-addresses';
/** Path for display: directory code, or full address if has TLD, or shortened for long IPNS keys (no dot) */
const getBoardDisplayPath = (address: string, path: string): string => {
@@ -124,6 +124,12 @@ const EMPTY_COMMENTS: Comment[] = [];
const MOD_QUEUE_VIRTUOSO_INCREASE_VIEWPORT_BY = { bottom: 600, top: 600 };
const NOOP_LOAD_MORE = () => undefined;
const ModQueueCommunityMetadataLoader = memo(({ candidateCommunityAddresses }: { candidateCommunityAddresses: string[] }) => {
const candidateCommunities = useCommunityIdentifiers(candidateCommunityAddresses);
useCommunities(candidateCommunities.length > 0 ? { communities: candidateCommunities } : undefined);
return null;
});
interface ModQueueFooterProps {
hasMore: boolean;
loadingStateString: string;
@@ -1119,9 +1125,9 @@ const ModQueueButtonContent = ({ feed, alertThresholdSeconds, boardIdentifier, i
export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => {
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
const account = useAccount();
const accountAddress = account?.author?.address;
const rawAccountCommunityAddresses = useModeratedCommunityAddresses();
const moderatedCommunityAddressInputs = useModeratedCommunityAddressInputs();
const accountAddress = moderatedCommunityAddressInputs.accountAddress;
const rawAccountCommunityAddresses = useModeratedCommunityAddressesForInputs(moderatedCommunityAddressInputs);
const accountCommunityAddressesKey = getAddressListKey(rawAccountCommunityAddresses);
const accountCommunityAddresses = useMemo(() => getAddressListFromKey(accountCommunityAddressesKey), [accountCommunityAddressesKey]);
@@ -1175,15 +1181,21 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp
[feedCommunities],
);
const { feed } = useFeed(feedOptions);
const metadataLoader = <ModQueueCommunityMetadataLoader candidateCommunityAddresses={moderatedCommunityAddressInputs.candidateCommunityAddresses} />;
if (!shouldFetch || communityAddresses.length === 0) {
return null;
return metadataLoader;
}
const alertThresholdSeconds = getAlertThresholdSeconds();
// Remount when switching boards so memoized counts reset cleanly.
const contentKey = communityAddresses.join(',');
return <ModQueueButtonContent key={contentKey} feed={feed} alertThresholdSeconds={alertThresholdSeconds} boardIdentifier={boardIdentifier} isMobile={isMobile} />;
return (
<>
{metadataLoader}
<ModQueueButtonContent key={contentKey} feed={feed} alertThresholdSeconds={alertThresholdSeconds} boardIdentifier={boardIdentifier} isMobile={isMobile} />
</>
);
};
const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => {
@@ -1195,7 +1207,8 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
const rememberCommentsInQueue = useModQueueStore((state) => state.rememberCommentsInQueue);
const isMobile = useIsMobile();
const rawAccountCommunityAddresses = useModeratedCommunityAddresses();
const moderatedCommunityAddressInputs = useModeratedCommunityAddressInputs();
const rawAccountCommunityAddresses = useModeratedCommunityAddressesForInputs(moderatedCommunityAddressInputs);
const accountCommunityAddressesKey = getAddressListKey(rawAccountCommunityAddresses);
const accountCommunityAddresses = useMemo(() => getAddressListFromKey(accountCommunityAddressesKey), [accountCommunityAddressesKey]);
@@ -1329,28 +1342,31 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
const visibleVirtuosoFooterContext = isQueueEmpty ? null : virtuosoFooterContext;
return (
<ModQueueContent
accountCommunityAddresses={accountCommunityAddresses}
addressToPathMap={addressToPathMap}
boardSummaryFeed={boardSummaryFeed}
compactCardItemContent={compactCardItemContent}
compactRowItemContent={compactRowItemContent}
communityError={visibleCommunityError}
directories={directories}
feedLength={visibleFeedLength}
feedPostItemContent={feedPostItemContent}
filteredFeed={visibleFilteredFeed}
hasMore={visibleHasMore}
isMobile={isMobile}
isQueueEmpty={isQueueEmpty}
loadMore={visibleLoadMore}
resolvedAddress={resolvedAddress}
selectedBoardFilter={selectedBoardFilter}
setSelectedBoardFilter={setSelectedBoardFilter}
showBoardColumn={showBoardColumn}
viewMode={viewMode}
virtuosoFooterContext={visibleVirtuosoFooterContext}
/>
<>
<ModQueueCommunityMetadataLoader candidateCommunityAddresses={moderatedCommunityAddressInputs.candidateCommunityAddresses} />
<ModQueueContent
accountCommunityAddresses={accountCommunityAddresses}
addressToPathMap={addressToPathMap}
boardSummaryFeed={boardSummaryFeed}
compactCardItemContent={compactCardItemContent}
compactRowItemContent={compactRowItemContent}
communityError={visibleCommunityError}
directories={directories}
feedLength={visibleFeedLength}
feedPostItemContent={feedPostItemContent}
filteredFeed={visibleFilteredFeed}
hasMore={visibleHasMore}
isMobile={isMobile}
isQueueEmpty={isQueueEmpty}
loadMore={visibleLoadMore}
resolvedAddress={resolvedAddress}
selectedBoardFilter={selectedBoardFilter}
setSelectedBoardFilter={setSelectedBoardFilter}
showBoardColumn={showBoardColumn}
viewMode={viewMode}
virtuosoFooterContext={visibleVirtuosoFooterContext}
/>
</>
);
};