mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
perf(mod queue): stabilize moderation subscriptions
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user