diff --git a/src/hooks/use-moderated-community-addresses.ts b/src/hooks/use-moderated-community-addresses.ts new file mode 100644 index 00000000..75a156c8 --- /dev/null +++ b/src/hooks/use-moderated-community-addresses.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react'; +import { useAccount, useCommunities } from '@bitsocial/bitsocial-react-hooks'; +import { useDirectories } from './use-directories'; +import { useCommunityIdentifiers } from './use-community-identifiers'; +import { useAccountCommunityAddresses } from './use-account-community-addresses'; +import { getModeratedCommunityAddresses } from '../lib/utils/mod-queue-utils'; + +const addUniqueAddress = (addresses: string[], address: string | undefined) => { + if (address && !addresses.includes(address)) { + addresses.push(address); + } +}; + +export const useModeratedCommunityAddresses = (): string[] => { + const account = useAccount(); + const accountAddress = account?.author?.address; + const accountCommunityAddresses = useAccountCommunityAddresses(); + const directories = useDirectories(); + + const candidateCommunityAddresses = useMemo(() => { + const addresses: string[] = []; + for (const address of accountCommunityAddresses) { + addUniqueAddress(addresses, address); + } + for (const directory of directories) { + addUniqueAddress(addresses, directory.address); + } + for (const address of account?.subscriptions ?? []) { + addUniqueAddress(addresses, address); + } + return addresses; + }, [account?.subscriptions, accountCommunityAddresses, directories]); + + const candidateCommunities = useCommunityIdentifiers(candidateCommunityAddresses); + const { communities } = useCommunities(candidateCommunities.length > 0 ? { communities: candidateCommunities } : undefined); + + return useMemo( + () => + getModeratedCommunityAddresses({ + accountAddress, + accountCommunityAddresses, + candidateCommunityAddresses, + communities, + }), + [accountAddress, accountCommunityAddresses, candidateCommunityAddresses, communities], + ); +}; diff --git a/src/lib/utils/__tests__/mod-queue-utils.test.ts b/src/lib/utils/__tests__/mod-queue-utils.test.ts index 08a4b426..646230a8 100644 --- a/src/lib/utils/__tests__/mod-queue-utils.test.ts +++ b/src/lib/utils/__tests__/mod-queue-utils.test.ts @@ -1,13 +1,22 @@ import { describe, expect, it } from 'vitest'; import { filterVisibleModQueueFeed, + getModQueueBoardFilterGroups, getModQueueCommentRoute, + getModQueueSelectedBoardAddresses, + getModeratedCommunityAddresses, getQueuedCommentRouteState, getVisibleQueuedCommentHistory, shouldKeepQueuedCommentHistory, } from '../mod-queue-utils'; describe('mod queue utils', () => { + const directories = [ + { address: 'anime-primary.bso', publicKey: 'a-primary', directoryCode: 'a', title: '/a/ - Anime & Manga' }, + { address: 'anime-backup.bso', publicKey: 'a-backup', directoryCode: 'a', title: '/a/ - Anime & Manga' }, + { address: 'tech-primary.bso', publicKey: 'g-primary', directoryCode: 'g', title: '/g/ - Technology' }, + ]; + it('keeps all queue comments unless they were locally dismissed', () => { const feed = [ { cid: 'pending', communityAddress: 'tech.eth', pendingApproval: true }, @@ -37,6 +46,57 @@ describe('mod queue utils', () => { ]); }); + it('applies grouped board filters to all directory aliases', () => { + const feed = [ + { cid: 'a-primary-pending', communityAddress: 'a-primary', pendingApproval: true }, + { cid: 'a-backup-pending', communityAddress: 'a-backup', pendingApproval: true }, + { cid: 'g-pending', communityAddress: 'g-primary', pendingApproval: true }, + ]; + + expect(filterVisibleModQueueFeed(feed, 'a', new Set(['a-primary-pending']), ['a-primary', 'a-backup'])).toEqual([ + { cid: 'a-backup-pending', communityAddress: 'a-backup', pendingApproval: true }, + ]); + }); + + it('dedupes mod queue board filters by directory path', () => { + expect(getModQueueBoardFilterGroups(['a-backup', 'custom.eth', 'g-primary', 'a-primary'], directories, [['g', 'a']])).toEqual([ + { + addresses: ['g-primary'], + boardPath: 'g', + filterKey: 'g', + isDirectory: true, + }, + { + addresses: ['a-backup', 'a-primary'], + boardPath: 'a', + filterKey: 'a', + isDirectory: true, + }, + { + addresses: ['custom.eth'], + boardPath: 'custom.eth', + filterKey: 'custom.eth', + isDirectory: false, + }, + ]); + }); + + it('expands selected directory filters to every matching moderated address', () => { + expect(getModQueueSelectedBoardAddresses(['a-primary', 'g-primary', 'a-backup'], 'a', directories)).toEqual(['a-primary', 'a-backup']); + expect(getModQueueSelectedBoardAddresses(['a-primary', 'g-primary', 'a-backup'], 'a-primary', directories)).toEqual(['a-primary', 'a-backup']); + }); + + it('adds live role communities that are not cached on the account', () => { + expect( + getModeratedCommunityAddresses({ + accountAddress: 'plebeius.bso', + accountCommunityAddresses: ['tech-primary.bso'], + candidateCommunityAddresses: ['tech-primary.bso', 'paranormal-posting.bso', 'viewer-board.bso'], + communities: [undefined, { roles: { 'plebeius.bso': { role: 'moderator' } } }, { roles: { 'plebeius.bso': { role: 'viewer' } } }], + }), + ).toEqual(['tech-primary.bso', 'paranormal-posting.bso']); + }); + it('keeps only terminal local moderation states in queue history', () => { expect(shouldKeepQueuedCommentHistory({ cid: 'pending', pendingApproval: true })).toBe(false); expect(shouldKeepQueuedCommentHistory({ cid: 'published', pendingApproval: false })).toBe(false); diff --git a/src/lib/utils/mod-queue-utils.ts b/src/lib/utils/mod-queue-utils.ts index b8857d5e..08bf0497 100644 --- a/src/lib/utils/mod-queue-utils.ts +++ b/src/lib/utils/mod-queue-utils.ts @@ -1,7 +1,9 @@ import type { Comment } from '@bitsocial/bitsocial-react-hooks'; +import type { DirectoryCommunity } from '../../hooks/use-directories'; import { getCommentCommunityAddress } from './comment-utils'; +import { hasModQueueAccessRole } from './mod-access'; import { isPendingApprovalRejected } from './pending-approval-moderation'; -import { areSameBoardAddress } from './route-utils'; +import { areSameBoardAddress, getBoardPath } from './route-utils'; import { getThreadTopNavigationState } from './thread-scroll-utils'; type ModQueueCommentLike = { @@ -59,9 +61,130 @@ export type QueuedCommentSnapshot = { title?: Comment['title']; }; +export interface ModQueueBoardFilterGroup { + addresses: string[]; + boardPath: string; + filterKey: string; + isDirectory: boolean; +} + +interface ModQueueCommunityRoleSource { + roles?: Record; +} + export const getModQueueCommentRoute = (boardPath: string | undefined, commentCid: string | undefined): string | undefined => boardPath && commentCid ? `/${boardPath}/thread/${commentCid}` : undefined; +export const getModQueueBoardFilterKey = (communityAddress: string, directories: DirectoryCommunity[]): string => { + const boardPath = getBoardPath(communityAddress, directories); + return boardPath !== communityAddress ? boardPath : communityAddress; +}; + +const addUniqueBoardAddress = (addresses: string[], address: string) => { + if (!addresses.some((existingAddress) => areSameBoardAddress(existingAddress, address))) { + addresses.push(address); + } +}; + +export const getModQueueBoardFilterGroups = ( + accountCommunityAddresses: readonly string[], + directories: DirectoryCommunity[], + boardCodeGroups: readonly (readonly string[])[], +): ModQueueBoardFilterGroup[] => { + const groupsByFilterKey = new Map(); + const filterKeyByAddress = new Map(); + + for (const address of accountCommunityAddresses) { + const boardPath = getBoardPath(address, directories); + const filterKey = boardPath !== address ? boardPath : address; + const existingGroup = groupsByFilterKey.get(filterKey); + if (existingGroup) { + addUniqueBoardAddress(existingGroup.addresses, address); + } else { + groupsByFilterKey.set(filterKey, { + addresses: [address], + boardPath, + filterKey, + isDirectory: boardPath !== address, + }); + } + filterKeyByAddress.set(address, filterKey); + } + + const orderedGroups: ModQueueBoardFilterGroup[] = []; + const seenFilterKeys = new Set(); + const addGroup = (filterKey: string | undefined) => { + if (!filterKey || seenFilterKeys.has(filterKey)) { + return; + } + const group = groupsByFilterKey.get(filterKey); + if (!group) { + return; + } + orderedGroups.push(group); + seenFilterKeys.add(filterKey); + }; + + for (const group of boardCodeGroups) { + for (const code of group) { + addGroup(code); + } + } + + for (const address of accountCommunityAddresses) { + addGroup(filterKeyByAddress.get(address)); + } + + return orderedGroups; +}; + +export const getModQueueSelectedBoardAddresses = ( + communityAddresses: readonly string[], + selectedBoardFilter: string | null, + directories: DirectoryCommunity[], +): string[] | null => { + if (!selectedBoardFilter) { + return null; + } + + const selectedFilterKey = getModQueueBoardFilterKey(selectedBoardFilter, directories); + const selectedAddresses = communityAddresses.filter((address) => { + if (areSameBoardAddress(address, selectedBoardFilter)) { + return true; + } + return getModQueueBoardFilterKey(address, directories) === selectedFilterKey; + }); + + return selectedAddresses.length > 0 ? selectedAddresses : [selectedBoardFilter]; +}; + +export const getModeratedCommunityAddresses = ({ + accountAddress, + accountCommunityAddresses, + candidateCommunityAddresses, + communities, +}: { + accountAddress: string | undefined; + accountCommunityAddresses: readonly string[]; + candidateCommunityAddresses: readonly string[]; + communities: readonly (ModQueueCommunityRoleSource | undefined)[]; +}): string[] => { + const moderatedAddresses = [...accountCommunityAddresses]; + + if (!accountAddress) { + return moderatedAddresses; + } + + candidateCommunityAddresses.forEach((candidateAddress, index) => { + const role = communities[index]?.roles?.[accountAddress]?.role; + if (hasModQueueAccessRole(role)) { + addUniqueBoardAddress(moderatedAddresses, candidateAddress); + } + }); + + return moderatedAddresses; +}; + export const getQueuedCommentSnapshot = (comment: ModQueueCommentLike | undefined): QueuedCommentSnapshot | undefined => { if (!comment?.cid) { return undefined; @@ -129,6 +252,7 @@ export const filterVisibleModQueueFeed = ( feed: T[], selectedBoardFilter: string | null, dismissedCommentCids: ReadonlySet = emptyDismissedCommentCids, + selectedBoardFilterAddresses: readonly string[] | null = null, ): T[] => feed.filter((comment) => { if (comment.cid && dismissedCommentCids.has(comment.cid)) { @@ -139,5 +263,14 @@ export const filterVisibleModQueueFeed = ( return true; } - return getCommentCommunityAddress(comment) === selectedBoardFilter; + const commentCommunityAddress = getCommentCommunityAddress(comment); + if (!commentCommunityAddress) { + return false; + } + + if (selectedBoardFilterAddresses?.length) { + return selectedBoardFilterAddresses.some((address) => areSameBoardAddress(address, commentCommunityAddress)); + } + + return areSameBoardAddress(commentCommunityAddress, selectedBoardFilter); }); diff --git a/src/views/mod-queue/mod-queue.tsx b/src/views/mod-queue/mod-queue.tsx index 36dde000..1206e006 100644 --- a/src/views/mod-queue/mod-queue.tsx +++ b/src/views/mod-queue/mod-queue.tsx @@ -9,7 +9,7 @@ import useModQueueStore from '../../stores/use-mod-queue-store'; import LoadingEllipsis from '../../components/loading-ellipsis'; import ErrorDisplay from '../../components/error-display/error-display'; import { useFeedStateString } from '../../hooks/use-state-string'; -import { getCommunityAddress, getBoardPath, extractDirectoryFromTitle, areSameBoardAddress } from '../../lib/utils/route-utils'; +import { getCommunityAddress, getBoardPath, areSameBoardAddress } from '../../lib/utils/route-utils'; import { useDirectories, DirectoryCommunity } from '../../hooks/use-directories'; import getShortAddress from '../../lib/get-short-address'; import { BOARD_CODE_GROUPS } from '../../constants/board-codes'; @@ -28,14 +28,16 @@ import { getCommentCommunityAddress } from '../../lib/utils/comment-utils'; import { formatErrorForDisplay } from '../../lib/utils/error-utils'; import { filterVisibleModQueueFeed, + getModQueueBoardFilterGroups, + getModQueueBoardFilterKey, getModQueueCommentRoute, + getModQueueSelectedBoardAddresses, getQueuedCommentRouteState, getQueuedCommentSnapshot, getVisibleQueuedCommentHistory, shouldKeepQueuedCommentHistory, } from '../../lib/utils/mod-queue-utils'; import Tooltip from '../../components/tooltip'; -import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses'; import { useCommunityIdentifier, useCommunityIdentifiers } from '../../hooks/use-community-identifiers'; import useIsMobile from '../../hooks/use-is-mobile'; import { useCurrentTime } from '../../hooks/use-current-time'; @@ -45,6 +47,7 @@ import capitalize from 'lodash/capitalize'; import lowerCase from 'lodash/lowerCase'; import { PageFooterDesktop, PageFooterMobile, StyleOnlyFooterFirstRow } from '../../components/footer'; import footerStyles from '../../components/footer/footer.module.css'; +import { useModeratedCommunityAddresses } from '../../hooks/use-moderated-community-addresses'; const { addChallenge } = useChallengesStore.getState(); @@ -590,15 +593,6 @@ interface ModQueueBoardSummaryProps { accountCommunityAddresses: string[]; } -const findBoardAddressByCode = (code: string, dirs: DirectoryCommunity[]): string | null => { - const entry = dirs.find((sub) => { - if (!sub.title) return false; - const directory = extractDirectoryFromTitle(sub.title); - return directory === code; - }); - return entry?.address || null; -}; - const ModQueueBoardCount = ({ normal, urgent }: { normal: number; urgent: number }) => { const total = normal + urgent; if (total === 0) return null; @@ -627,18 +621,22 @@ const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }: const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds); const currentTime = useCurrentTime(); const alertThresholdSeconds = getAlertThresholdSeconds(); - const modAddressSet = useMemo(() => new Set(accountCommunityAddresses), [accountCommunityAddresses]); const locallyModeratedFeed = useLocallyModeratedModQueueFeed(feed, currentTime); + const boardGroups = useMemo(() => getModQueueBoardFilterGroups(accountCommunityAddresses, directories, BOARD_CODE_GROUPS), [accountCommunityAddresses, directories]); + const selectedBoardFilterAddresses = useMemo( + () => getModQueueSelectedBoardAddresses(accountCommunityAddresses, selectedBoardFilter, directories), + [accountCommunityAddresses, selectedBoardFilter, directories], + ); const boardCounts = useMemo(() => { const counts = new Map(); - for (const address of accountCommunityAddresses) { - counts.set(address, { normal: 0, urgent: 0 }); + for (const group of boardGroups) { + counts.set(group.filterKey, { normal: 0, urgent: 0 }); } for (const item of locallyModeratedFeed) { const addr = getCommentCommunityAddress(item); if (!addr) continue; - const entry = counts.get(addr); + const entry = counts.get(getModQueueBoardFilterKey(addr, directories)); if (!entry) continue; const isAwaiting = isPendingApprovalAwaiting(item); if (!isAwaiting) continue; @@ -648,7 +646,7 @@ const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }: else entry.normal++; } return counts; - }, [locallyModeratedFeed, accountCommunityAddresses, currentTime, alertThresholdSeconds]); + }, [locallyModeratedFeed, boardGroups, currentTime, alertThresholdSeconds, directories]); const { totalNormal, totalUrgent } = useMemo(() => { let normal = 0; @@ -660,41 +658,10 @@ const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }: return { totalNormal: normal, totalUrgent: urgent }; }, [boardCounts]); - // Order: All first, then BOARD_CODE_GROUPS order (directory boards), then non-directory boards - const orderedAddresses = useMemo(() => { - const ordered: string[] = []; - const seen = new Set(); - - for (const group of BOARD_CODE_GROUPS) { - for (const code of group) { - const address = findBoardAddressByCode(code, directories); - if (address && modAddressSet.has(address) && !seen.has(address)) { - ordered.push(address); - seen.add(address); - } - } - } - // Directory boards not in BOARD_CODE_GROUPS (custom dirs) - for (const addr of accountCommunityAddresses) { - const path = getBoardPath(addr, directories); - if (path !== addr && !seen.has(addr)) { - ordered.push(addr); - seen.add(addr); - } - } - // Non-directory boards (own category, like subscriptions in boardsbar) - for (const addr of accountCommunityAddresses) { - if (!seen.has(addr)) { - ordered.push(addr); - } - } - return ordered; - }, [accountCommunityAddresses, directories, modAddressSet]); - const handleSelectAll = useCallback(() => setSelectedBoardFilter(null), [setSelectedBoardFilter]); - const handleSelectBoard = useCallback((address: string) => setSelectedBoardFilter(address), [setSelectedBoardFilter]); + const handleSelectBoard = useCallback((filterKey: string) => setSelectedBoardFilter(filterKey), [setSelectedBoardFilter]); - if (accountCommunityAddresses.length === 0) { + if (boardGroups.length === 0) { return null; } @@ -709,20 +676,23 @@ const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }: )} - {orderedAddresses.map((address) => { - const boardPath = getBoardPath(address, directories); - const isInDirectory = boardPath !== address; - const displayText = isInDirectory ? boardPath : address.endsWith('.eth') || address.endsWith('.sol') ? address : getShortAddress(address) || address; - const isSelected = selectedBoardFilter === address; - const { normal, urgent } = boardCounts.get(address) ?? { normal: 0, urgent: 0 }; + {boardGroups.map((group) => { + const displayText = + group.isDirectory || group.filterKey.endsWith('.eth') || group.filterKey.endsWith('.sol') + ? group.filterKey + : getShortAddress(group.filterKey) || group.filterKey; + const isSelected = + selectedBoardFilter === group.filterKey || + group.addresses.some((address) => selectedBoardFilterAddresses?.some((selectedAddress) => areSameBoardAddress(address, selectedAddress))); + const { normal, urgent } = boardCounts.get(group.filterKey) ?? { normal: 0, urgent: 0 }; return ( - + {' / '}