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 { useBoardPseudonymityMode } from '../use-board-pseudonymity-mode';
import useCountLinksInReplies from '../use-count-links-in-replies'; import useCountLinksInReplies from '../use-count-links-in-replies';
import { useFilteredDirectoryAddresses } from '../use-filtered-directory-addresses'; import { useFilteredDirectoryAddresses } from '../use-filtered-directory-addresses';
import { useModeratedCommunityAddressInputs, useModeratedCommunityAddressesForInputs } from '../use-moderated-community-addresses';
import useAllFeedFilterStore from '../../stores/use-all-feed-filter-store'; import useAllFeedFilterStore from '../../stores/use-all-feed-filter-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;
@@ -17,6 +18,7 @@ const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise
const testState = vi.hoisted(() => ({ const testState = vi.hoisted(() => ({
account: undefined as unknown, account: undefined as unknown,
accountCommunities: {} as Record<string, unknown>, accountCommunities: {} as Record<string, unknown>,
liveCommunities: {} as Record<string, unknown>,
directories: [] as Array<{ address: string; nsfw?: boolean }>, directories: [] as Array<{ address: string; nsfw?: boolean }>,
directoryLookup: {} as Record<string, unknown>, directoryLookup: {} as Record<string, unknown>,
flattenedReplies: [] as 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', () => ({ vi.mock('@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js', () => ({
default: ( 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, equalityFn?: (previous: unknown, next: unknown) => boolean,
) => { ) => {
const nextValue = selector({ const nextValue = selector({
activeAccountId: 'active', activeAccountId: 'active',
accounts: { accounts: {
active: { active: {
...(testState.account as Record<string, unknown> | undefined),
communities: testState.accountCommunities, 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', () => ({ vi.mock('@bitsocial/bitsocial-react-hooks/dist/lib/community-address.js', () => ({
getEquivalentCommunityAddressGroupKey: (address: string) => (address.endsWith('.eth') ? address.slice(0, -4) + '.bso' : address), getEquivalentCommunityAddressGroupKey: (address: string) => (address.endsWith('.eth') ? address.slice(0, -4) + '.bso' : address),
pickPreferredEquivalentCommunityAddress: (addresses: string[]) => addresses.find((address) => address.endsWith('.bso')) || addresses[0], 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', () => ({ vi.mock('../use-directories', () => ({
normalizeBoardAddress: (address: string | undefined) => address?.toLowerCase() ?? '',
useDirectories: () => testState.directories, useDirectories: () => testState.directories,
useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryLookup[address] : undefined), useDirectoryByAddress: (address: string | undefined) => (address ? testState.directoryLookup[address] : undefined),
})); }));
@@ -110,12 +135,15 @@ describe('selector hooks', () => {
vi.clearAllMocks(); vi.clearAllMocks();
testState.account = undefined; testState.account = undefined;
testState.accountCommunities = {}; testState.accountCommunities = {};
testState.liveCommunities = {};
testState.directories = []; testState.directories = [];
testState.directoryLookup = {}; testState.directoryLookup = {};
testState.flattenedReplies = []; testState.flattenedReplies = [];
testState.communitySnapshot = undefined; testState.communitySnapshot = undefined;
accountsStoreSelectorCache.hasValue = false; accountsStoreSelectorCache.hasValue = false;
accountsStoreSelectorCache.value = undefined; accountsStoreSelectorCache.value = undefined;
communitiesStoreSelectorCache.hasValue = false;
communitiesStoreSelectorCache.value = undefined;
useAllFeedFilterStore.getState().setFilter('all'); useAllFeedFilterStore.getState().setFilter('all');
container = document.createElement('div'); container = document.createElement('div');
@@ -166,6 +194,65 @@ describe('selector hooks', () => {
expect(addressesWithNewBoard).toEqual(['biz.eth', 'music.eth', 'tech.eth']); 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', () => { it('computes moderator privileges and whether the current account authored the comment', () => {
testState.account = { author: { address: '0xme' } }; testState.account = { author: { address: '0xme' } };
testState.communitySnapshot = { testState.communitySnapshot = {
+4 -1
View File
@@ -12,10 +12,13 @@ type AccountsStoreState = {
const EMPTY_ACCOUNT_COMMUNITY_ADDRESSES: string[] = []; 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) { if (previous === next) {
return true; return true;
} }
if (!previous || !next) {
return previous === next;
}
if (previous.length !== next.length) { if (previous.length !== next.length) {
return false; return false;
} }
+91 -14
View File
@@ -1,9 +1,58 @@
import { useMemo } from 'react'; 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 { useDirectories } from './use-directories';
import { useCommunityIdentifiers } from './use-community-identifiers'; import { areStringArraysEqual, useAccountCommunityAddresses } from './use-account-community-addresses';
import { useAccountCommunityAddresses } from './use-account-community-addresses';
import { getModeratedCommunityAddresses } from '../lib/utils/mod-queue-utils'; 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) => { const addUniqueAddress = (addresses: string[], address: string | undefined) => {
if (address && !addresses.includes(address)) { if (address && !addresses.includes(address)) {
@@ -11,9 +60,21 @@ const addUniqueAddress = (addresses: string[], address: string | undefined) => {
} }
}; };
export const useModeratedCommunityAddresses = (): string[] => { const getCommunityByAddress = (communities: Record<string, Community | undefined>, communityAddress: string): Community | undefined => {
const account = useAccount(); const exactMatch = communities[communityAddress];
const accountAddress = account?.author?.address; 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 accountCommunityAddresses = useAccountCommunityAddresses();
const directories = useDirectories(); const directories = useDirectories();
@@ -25,23 +86,39 @@ export const useModeratedCommunityAddresses = (): string[] => {
for (const directory of directories) { for (const directory of directories) {
addUniqueAddress(addresses, directory.address); addUniqueAddress(addresses, directory.address);
} }
for (const address of account?.subscriptions ?? []) { for (const address of subscriptions) {
addUniqueAddress(addresses, address); addUniqueAddress(addresses, address);
} }
return addresses; return addresses;
}, [account?.subscriptions, accountCommunityAddresses, directories]); }, [accountCommunityAddresses, directories, subscriptions]);
const candidateCommunities = useCommunityIdentifiers(candidateCommunityAddresses);
const { communities } = useCommunities(candidateCommunities.length > 0 ? { communities: candidateCommunities } : undefined);
return useMemo( return useMemo(
() => () => ({
accountAddress,
accountCommunityAddresses,
candidateCommunityAddresses,
}),
[accountAddress, accountCommunityAddresses, candidateCommunityAddresses],
);
};
export const useModeratedCommunityAddressesForInputs = ({
accountAddress,
accountCommunityAddresses,
candidateCommunityAddresses,
}: ModeratedCommunityAddressInputs): string[] =>
useCommunitiesStore(
(state: CommunitiesStoreState) =>
getModeratedCommunityAddresses({ getModeratedCommunityAddresses({
accountAddress, accountAddress,
accountCommunityAddresses, accountCommunityAddresses,
candidateCommunityAddresses, 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);
}; };
+24 -8
View File
@@ -2,7 +2,7 @@ import React, { useMemo, useState, useEffect, useCallback, memo } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useParams, Link } from 'react-router-dom'; 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 useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js';
import { useFloating, offset, shift, size, flip, autoUpdate } from '@floating-ui/react'; import { useFloating, offset, shift, size, flip, autoUpdate } from '@floating-ui/react';
import { Virtuoso, type Components } from 'react-virtuoso'; import { Virtuoso, type Components } from 'react-virtuoso';
@@ -49,7 +49,7 @@ import capitalize from 'lodash/capitalize';
import lowerCase from 'lodash/lowerCase'; import lowerCase from 'lodash/lowerCase';
import { PageFooterDesktop, PageFooterMobile, StyleOnlyFooterFirstRow } from '../../components/footer/footer'; import { PageFooterDesktop, PageFooterMobile, StyleOnlyFooterFirstRow } from '../../components/footer/footer';
import footerStyles from '../../components/footer/footer.module.css'; 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) */ /** 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 => { 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 MOD_QUEUE_VIRTUOSO_INCREASE_VIEWPORT_BY = { bottom: 600, top: 600 };
const NOOP_LOAD_MORE = () => undefined; 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 { interface ModQueueFooterProps {
hasMore: boolean; hasMore: boolean;
loadingStateString: string; loadingStateString: string;
@@ -1119,9 +1125,9 @@ const ModQueueButtonContent = ({ feed, alertThresholdSeconds, boardIdentifier, i
export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => { export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => {
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds); const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
const account = useAccount(); const moderatedCommunityAddressInputs = useModeratedCommunityAddressInputs();
const accountAddress = account?.author?.address; const accountAddress = moderatedCommunityAddressInputs.accountAddress;
const rawAccountCommunityAddresses = useModeratedCommunityAddresses(); const rawAccountCommunityAddresses = useModeratedCommunityAddressesForInputs(moderatedCommunityAddressInputs);
const accountCommunityAddressesKey = getAddressListKey(rawAccountCommunityAddresses); const accountCommunityAddressesKey = getAddressListKey(rawAccountCommunityAddresses);
const accountCommunityAddresses = useMemo(() => getAddressListFromKey(accountCommunityAddressesKey), [accountCommunityAddressesKey]); const accountCommunityAddresses = useMemo(() => getAddressListFromKey(accountCommunityAddressesKey), [accountCommunityAddressesKey]);
@@ -1175,15 +1181,21 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp
[feedCommunities], [feedCommunities],
); );
const { feed } = useFeed(feedOptions); const { feed } = useFeed(feedOptions);
const metadataLoader = <ModQueueCommunityMetadataLoader candidateCommunityAddresses={moderatedCommunityAddressInputs.candidateCommunityAddresses} />;
if (!shouldFetch || communityAddresses.length === 0) { if (!shouldFetch || communityAddresses.length === 0) {
return null; return metadataLoader;
} }
const alertThresholdSeconds = getAlertThresholdSeconds(); const alertThresholdSeconds = getAlertThresholdSeconds();
// Remount when switching boards so memoized counts reset cleanly. // Remount when switching boards so memoized counts reset cleanly.
const contentKey = communityAddresses.join(','); 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) => { const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => {
@@ -1195,7 +1207,8 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
const rememberCommentsInQueue = useModQueueStore((state) => state.rememberCommentsInQueue); const rememberCommentsInQueue = useModQueueStore((state) => state.rememberCommentsInQueue);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const rawAccountCommunityAddresses = useModeratedCommunityAddresses(); const moderatedCommunityAddressInputs = useModeratedCommunityAddressInputs();
const rawAccountCommunityAddresses = useModeratedCommunityAddressesForInputs(moderatedCommunityAddressInputs);
const accountCommunityAddressesKey = getAddressListKey(rawAccountCommunityAddresses); const accountCommunityAddressesKey = getAddressListKey(rawAccountCommunityAddresses);
const accountCommunityAddresses = useMemo(() => getAddressListFromKey(accountCommunityAddressesKey), [accountCommunityAddressesKey]); const accountCommunityAddresses = useMemo(() => getAddressListFromKey(accountCommunityAddressesKey), [accountCommunityAddressesKey]);
@@ -1329,6 +1342,8 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
const visibleVirtuosoFooterContext = isQueueEmpty ? null : virtuosoFooterContext; const visibleVirtuosoFooterContext = isQueueEmpty ? null : virtuosoFooterContext;
return ( return (
<>
<ModQueueCommunityMetadataLoader candidateCommunityAddresses={moderatedCommunityAddressInputs.candidateCommunityAddresses} />
<ModQueueContent <ModQueueContent
accountCommunityAddresses={accountCommunityAddresses} accountCommunityAddresses={accountCommunityAddresses}
addressToPathMap={addressToPathMap} addressToPathMap={addressToPathMap}
@@ -1351,6 +1366,7 @@ const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProp
viewMode={viewMode} viewMode={viewMode}
virtuosoFooterContext={visibleVirtuosoFooterContext} virtuosoFooterContext={visibleVirtuosoFooterContext}
/> />
</>
); );
}; };