Files
5chan/src/views/mod-queue/mod-queue.tsx
T

1170 lines
44 KiB
TypeScript

import React, { useMemo, useState, useEffect, useCallback, memo } from 'react';
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 useAccountsStore from '@bitsocial/bitsocial-react-hooks/dist/stores/accounts/index.js';
import { Virtuoso } from 'react-virtuoso';
import styles from './mod-queue.module.css';
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 { useDirectories, DirectoryCommunity } from '../../hooks/use-directories';
import getShortAddress from '../../lib/get-short-address';
import { BOARD_CODE_GROUPS } from '../../constants/board-codes';
import { getHasThumbnail, getCommentMediaInfo } from '../../lib/utils/media-utils';
import {
approvePendingCommentModeration,
isPendingApprovalAwaiting,
isPendingApprovalRejected,
rejectPendingCommentModeration,
} from '../../lib/utils/pending-approval-moderation';
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
import useFeedResetStore from '../../stores/use-feed-reset-store';
import useChallengesStore from '../../stores/use-challenges-store';
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
import { formatErrorForDisplay } from '../../lib/utils/error-utils';
import { filterVisibleModQueueFeed, getModQueueCommentRoute, getQueuedCommentRouteState, getQueuedCommentSnapshot } 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';
import { Post } from '../post/post';
import { canAccessBoardModQueue, hasModQueueAccessRole } from '../../lib/utils/mod-access';
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';
const { addChallenge } = useChallengesStore.getState();
/** 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 => {
if (path !== address) return path;
if (address.includes('.')) return address;
return getShortAddress(address) || address;
};
type LocalModerationEditSummary = Record<string, { timestamp?: number; value: unknown } | undefined>;
type LocalModerationEditSummaries = Record<string, LocalModerationEditSummary | undefined>;
const LOCAL_MODERATION_EDIT_FIELDS = ['approved', 'removed'] as const;
const LOCAL_EDIT_PENDING_SECONDS = 20 * 60;
const shouldApplyLocalModerationEdit = (
comment: Comment,
propertyName: (typeof LOCAL_MODERATION_EDIT_FIELDS)[number],
edit: { timestamp?: number; value: unknown },
now: number,
) => {
const editTimestamp = edit.timestamp ?? 0;
const updatedAt = (comment as { updatedAt?: number }).updatedAt;
const currentValue = (comment as Record<string, unknown>)[propertyName];
if (!updatedAt) {
return Object.is(currentValue, edit.value) || editTimestamp > now - LOCAL_EDIT_PENDING_SECONDS;
}
if (updatedAt < editTimestamp || Object.is(currentValue, edit.value)) {
return true;
}
return editTimestamp > now - LOCAL_EDIT_PENDING_SECONDS || updatedAt - editTimestamp < LOCAL_EDIT_PENDING_SECONDS;
};
const applyLocalModerationEdits = (comment: Comment, editSummary: LocalModerationEditSummary | undefined, now: number): Comment => {
if (!editSummary) {
return comment;
}
let editedComment: Comment | undefined;
for (const propertyName of LOCAL_MODERATION_EDIT_FIELDS) {
const edit = editSummary[propertyName];
if (!edit || edit.value === undefined || !shouldApplyLocalModerationEdit(comment, propertyName, edit, now)) {
continue;
}
editedComment = { ...(editedComment ?? comment), [propertyName]: edit.value } as Comment;
}
return editedComment ?? comment;
};
const useLocallyModeratedModQueueFeed = (feed: Comment[], currentTime: number) => {
const accountId = useAccountsStore((state) => state.activeAccountId);
const editSummaries = useAccountsStore((state) => (accountId ? state.accountsEditsSummaries[accountId] : undefined)) as LocalModerationEditSummaries | undefined;
return useMemo(() => {
if (!editSummaries) {
return feed;
}
return feed.map((comment) => (comment.cid ? applyLocalModerationEdits(comment, editSummaries[comment.cid], currentTime) : comment));
}, [currentTime, editSummaries, feed]);
};
interface ModQueueViewProps {
boardIdentifier?: string; // If provided, shows queue for single board
}
interface ModQueueFooterProps {
hasMore: boolean;
communityAddresses: string[];
}
// Defined outside ModQueueView to preserve component identity across renders (Virtuoso optimization)
// The useFeedStateString hook is called here instead of in ModQueueView to isolate re-renders
// caused by backend IPFS state changes to just this footer component
const ModQueueFooter = ({ hasMore, communityAddresses }: ModQueueFooterProps) => {
const { t } = useTranslation();
const loadingStateString = useFeedStateString(communityAddresses) || t('loading');
return hasMore ? (
<div className={styles.footer}>
<LoadingEllipsis string={loadingStateString} />
</div>
) : null;
};
interface ModQueueRowProps {
comment: Comment;
isOdd?: boolean;
showBoard?: boolean;
/** Board path for URLs (directory code or full address) */
boardPath: string | undefined;
/** Board path for display (shortened when long IPNS key with no TLD) */
boardDisplayPath: string | undefined;
}
// Track which action was initiated to show appropriate completion message
type ModerationAction = 'approve' | 'reject' | null;
interface ModQueueActionState {
status: 'approved' | 'rejected' | 'failed' | null;
error?: unknown;
errorMessage?: string;
isPublishing: boolean;
handleApprove: () => Promise<void>;
handleReject: () => Promise<void>;
handleRemove?: () => void;
}
interface ModQueueActionsProps {
status: 'approved' | 'rejected' | 'failed' | null;
error?: unknown;
errorMessage?: string;
isPublishing: boolean;
handleApprove: () => Promise<void>;
handleReject: () => Promise<void>;
handleRemove?: () => void;
variant: 'row' | 'card';
}
const ModQueueActions = ({ status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove, variant }: ModQueueActionsProps) => {
const { t } = useTranslation();
const displayError = error || errorMessage;
const removeButton = handleRemove ? (
variant === 'row' ? (
<span className={styles.buttonWrapper}>
[
<button className={styles.button} onClick={handleRemove} disabled={isPublishing}>
{t('remove')}
</button>
]
</span>
) : (
<span className={styles.cardRemoveButtonWrapper}>
[
<button className={styles.cardRemoveButton} onClick={handleRemove} disabled={isPublishing}>
{t('remove')}
</button>
]
</span>
)
) : null;
if (status === 'approved') {
const content = <span className={`${styles.button} ${styles.approve}`}>{t('approved')}</span>;
return variant === 'card' ? (
<div className={styles.cardActions}>
{content}
{removeButton}
</div>
) : (
<span className={styles.actionButtons}>
{content} {removeButton}
</span>
);
}
if (status === 'rejected') {
const content = <span className={`${styles.button} ${styles.reject}`}>{t('rejected')}</span>;
return variant === 'card' ? (
<div className={styles.cardActions}>
{content}
{removeButton}
</div>
) : (
<span className={styles.actionButtons}>
{content} {removeButton}
</span>
);
}
if (status === 'failed') {
const content = displayError ? (
<ErrorDisplay error={displayError} displayMessage={t('failed')} inline={true} showImmediately={true} />
) : (
<span className={`${styles.button} ${styles.reject}`}>{t('failed')}</span>
);
return variant === 'card' ? <div className={styles.cardActions}>{content}</div> : content;
}
if (isPublishing) {
const content = <LoadingEllipsis string={t('publishing')} />;
return variant === 'card' ? <div className={styles.cardActions}>{content}</div> : content;
}
const buttons =
variant === 'row' ? (
<div className={styles.actionButtons}>
<span className={styles.buttonWrapper}>
[
<button className={styles.button} onClick={handleApprove} disabled={isPublishing}>
{t('approve')}
</button>
]
</span>
<span className={styles.buttonWrapper}>
[
<button className={styles.button} onClick={handleReject} disabled={isPublishing}>
{t('reject')}
</button>
]
</span>
</div>
) : (
<div className={styles.cardActions}>
<button className={`button ${styles.cardApproveButton}`} onClick={handleApprove} disabled={isPublishing}>
{t('approve')}
</button>
<button className={`button ${styles.cardRejectButton}`} onClick={handleReject} disabled={isPublishing}>
{t('reject')}
</button>
</div>
);
return buttons;
};
const useModQueueActions = (comment: Comment): ModQueueActionState => {
const { t } = useTranslation();
const { cid, approved, removed, pendingApproval } = comment || {};
const communityAddress = getCommentCommunityAddress(comment);
const dismissCommentFromQueue = useModQueueStore((state) => state.dismissCommentFromQueue);
const rememberCommentsInQueue = useModQueueStore((state) => state.rememberCommentsInQueue);
const [initiatedAction, setInitiatedAction] = useState<ModerationAction>(null);
const alreadyApproved = approved === true;
const alreadyRejected = isPendingApprovalRejected({ approved, removed, pendingApproval });
const {
publishCommentModeration: approve,
state: approveState,
error: approveError,
} = usePublishCommentModeration({
commentCid: cid,
communityAddress,
commentModeration: approvePendingCommentModeration,
onChallenge: async (...args: any) => {
addChallenge([...args, comment]);
},
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
onError: (error: Error & { details?: unknown }) => {
console.error('Approve failed:', error, error.details);
},
});
const {
publishCommentModeration: reject,
state: rejectState,
error: rejectError,
} = usePublishCommentModeration({
commentCid: cid,
communityAddress,
commentModeration: rejectPendingCommentModeration,
onChallenge: async (...args: any) => {
addChallenge([...args, comment]);
},
onChallengeVerification: async (challengeVerification, comment) => {
alertChallengeVerificationFailed(challengeVerification, comment);
},
onError: (error: Error & { details?: unknown }) => {
console.error('Reject failed:', error, error.details);
},
});
const handleApprove = async () => {
const confirm = window.confirm(t('double_confirm'));
if (!confirm) {
return;
}
setInitiatedAction('approve');
try {
await approve();
const approvedSnapshot = getQueuedCommentSnapshot({ ...comment, approved: true, pendingApproval: false });
if (approvedSnapshot) {
rememberCommentsInQueue([approvedSnapshot]);
}
} catch (e) {
console.error(e);
}
};
const handleReject = async () => {
const confirm = window.confirm(t('double_confirm'));
if (!confirm) {
return;
}
setInitiatedAction('reject');
try {
await reject();
const rejectedSnapshot = getQueuedCommentSnapshot({ ...comment, approved: false, pendingApproval: false });
if (rejectedSnapshot) {
rememberCommentsInQueue([rejectedSnapshot]);
}
} catch (e) {
console.error(e);
}
};
const handleRemove = () => {
if (cid) {
dismissCommentFromQueue(cid);
}
};
const isApproving = initiatedAction === 'approve' && approveState !== 'initializing' && approveState !== 'succeeded' && approveState !== 'failed';
const isRejecting = initiatedAction === 'reject' && rejectState !== 'initializing' && rejectState !== 'succeeded' && rejectState !== 'failed';
const isPublishing = isApproving || isRejecting;
const approveSucceeded = initiatedAction === 'approve' && approveState === 'succeeded';
const rejectSucceeded = initiatedAction === 'reject' && rejectState === 'succeeded';
const approveFailed = initiatedAction === 'approve' && approveState === 'failed';
const rejectFailed = initiatedAction === 'reject' && rejectState === 'failed';
const status = alreadyApproved || approveSucceeded ? 'approved' : alreadyRejected || rejectSucceeded ? 'rejected' : approveFailed || rejectFailed ? 'failed' : null;
const error = approveFailed ? approveError : rejectFailed ? rejectError : undefined;
const errorMessage = formatErrorForDisplay(error);
return { status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove: status ? handleRemove : undefined };
};
const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath, boardDisplayPath }: ModQueueRowProps) => {
const { t } = useTranslation();
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
const isMobile = useIsMobile();
const currentTime = useCurrentTime();
const { editedComment } = useEditedComment({ comment });
const displayComment = editedComment || comment;
const { content, title, timestamp, cid, link, thumbnailUrl, linkWidth, linkHeight, number, parentCid } = displayComment;
const timeWaiting = currentTime - timestamp;
const alertThresholdSeconds = getAlertThresholdSeconds();
const isOverThreshold = timeWaiting > alertThresholdSeconds;
// Only show alert animation for comments awaiting approval (not approved or rejected)
const isAwaitingApproval = isPendingApprovalAwaiting(displayComment);
const { status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove } = useModQueueActions(comment);
const hasTitle = title && title.trim().length > 0;
const hasContent = content && content.trim().length > 0;
const hasLink = link && link.length > 0;
const isReply = !!parentCid;
const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
const rawExcerpt = (
(hasTitle && hasContent ? `${title}: ${content}` : null) ||
(hasTitle ? title : null) ||
(hasContent ? content : null) ||
(hasLink ? link : null) ||
(hasThumbnail ? t('image') : null) ||
t('no_content')
).trim();
// Only truncate excerpt on desktop, allow wrapping on mobile
const excerpt = !isMobile && rawExcerpt.length > 101 ? rawExcerpt.slice(0, 98) + '...' : rawExcerpt;
const postUrl = getModQueueCommentRoute(boardPath, comment.cid || cid);
const postUrlState = getQueuedCommentRouteState(comment);
const modQueueUrl = boardPath ? `/${boardPath}/mod/queue` : undefined;
return (
<div className={`${styles.row} ${isOdd ? styles.rowOdd : ''}`}>
<div className={styles.number}>{number ?? 'N/A'}</div>
{showBoard && (
<div className={styles.board}>{modQueueUrl ? <Link to={modQueueUrl}>/{boardDisplayPath ?? '—'}/</Link> : <span>/{boardDisplayPath ?? '—'}/</span>}</div>
)}
<div className={styles.excerpt}>
{postUrl ? (
<Link to={postUrl} state={postUrlState} title={excerpt}>
{excerpt}
</Link>
) : (
<span title={excerpt}>{excerpt}</span>
)}
</div>
<div className={styles.time}>
{isMobile ? (
// On mobile, show shorter time ago format without tooltip
isAwaitingApproval && isOverThreshold ? (
<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>
) : (
<span>{getFormattedTimeAgo(timestamp)}</span>
)
) : // On desktop, show full date with tooltip
isAwaitingApproval && isOverThreshold ? (
<>
<Tooltip content={getFormattedTimeAgo(timestamp)}>
<span>{getFormattedDate(timestamp)}</span>
</Tooltip>
<span className={styles.alertWrapper}>
{' '}
(<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
</span>
</>
) : (
<Tooltip content={getFormattedTimeAgo(timestamp)}>
<span>{getFormattedDate(timestamp)}</span>
</Tooltip>
)}
</div>
<div className={styles.type}>{isReply ? capitalize(t('reply')) : capitalize(t('post'))}</div>
<div className={styles.image}>{hasThumbnail ? t('yes') : t('no')}</div>
<div className={styles.actions}>
<ModQueueActions
status={status}
error={error}
errorMessage={errorMessage}
isPublishing={isPublishing}
handleApprove={handleApprove}
handleReject={handleReject}
handleRemove={handleRemove}
variant='row'
/>
</div>
</div>
);
});
ModQueueRow.displayName = 'ModQueueRow';
interface ModQueueCardProps {
comment: Comment;
showBoard?: boolean;
/** Board path for URLs (directory code or full address) */
boardPath: string | undefined;
/** Board path for display (shortened when long IPNS key with no TLD) */
boardDisplayPath: string | undefined;
}
const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplayPath }: ModQueueCardProps) => {
const { t } = useTranslation();
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
const currentTime = useCurrentTime();
const { editedComment } = useEditedComment({ comment });
const displayComment = editedComment || comment;
const { content, title, timestamp, cid, link, thumbnailUrl, linkWidth, linkHeight, number, parentCid } = displayComment;
const timeWaiting = currentTime - timestamp;
const alertThresholdSeconds = getAlertThresholdSeconds();
const isOverThreshold = timeWaiting > alertThresholdSeconds;
const isAwaitingApproval = isPendingApprovalAwaiting(displayComment);
const { status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove } = useModQueueActions(comment);
const hasTitle = title && title.trim().length > 0;
const hasContent = content && content.trim().length > 0;
const hasLink = link && link.length > 0;
const isReply = !!parentCid;
const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
const rawExcerpt = (
(hasTitle && hasContent ? `${title}: ${content}` : null) ||
(hasTitle ? title : null) ||
(hasContent ? content : null) ||
(hasLink ? link : null) ||
(hasThumbnail ? t('image') : null) ||
t('no_content')
).trim();
const excerpt = rawExcerpt.length > 140 ? rawExcerpt.slice(0, 137) + '...' : rawExcerpt;
const postUrl = getModQueueCommentRoute(boardPath, comment.cid || cid);
const postUrlState = getQueuedCommentRouteState(comment);
const modQueueUrl = boardPath ? `/${boardPath}/mod/queue` : undefined;
return (
<div className={styles.mobileCard}>
<div className={styles.cardHeader}>
<span className={styles.cardHeaderLeft}>
<span className={styles.cardNumber}>No. {number ?? 'N/A'}</span>
{showBoard && boardPath && (
<>
<span className={styles.cardBoardSeparator}> - </span>
<span className={styles.cardBoard}>{modQueueUrl ? <Link to={modQueueUrl}>/{boardDisplayPath}/</Link> : <span>/{boardDisplayPath}/</span>}</span>
</>
)}
</span>
<span className={styles.cardTime}>
{isAwaitingApproval && isOverThreshold ? (
<>
{getFormattedDate(timestamp)} (<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
</>
) : (
getFormattedDate(timestamp)
)}
</span>
</div>
<div className={styles.cardContent}>
{t('excerpt')}:{' '}
{postUrl ? (
<Link to={postUrl} state={postUrlState} title={excerpt}>
{excerpt}
</Link>
) : (
<span title={excerpt}>{excerpt}</span>
)}{' '}
/ {t('type')}: {isReply ? t('reply') : t('post')} / {capitalize(t('image'))}: {hasThumbnail ? lowerCase(t('yes')) : lowerCase(t('no'))}
</div>
<ModQueueActions
status={status}
error={error}
errorMessage={errorMessage}
isPublishing={isPublishing}
handleApprove={handleApprove}
handleReject={handleReject}
handleRemove={handleRemove}
variant='card'
/>
</div>
);
});
ModQueueCard.displayName = 'ModQueueCard';
const ModQueueFeedPost = ({ comment }: { comment: Comment }) => {
const { editedComment } = useEditedComment({ comment });
const displayComment = editedComment || comment;
const { status, error, errorMessage, isPublishing, handleApprove, handleReject, handleRemove } = useModQueueActions(comment);
return (
<Post
post={displayComment}
showAllReplies={false}
showReplies={false}
isModQueue={true}
modQueueStatus={status}
modQueueError={error || errorMessage}
isPublishing={isPublishing}
onApprove={handleApprove}
onReject={handleReject}
onRemoveFromModQueue={handleRemove}
/>
);
};
interface ModQueueBoardSummaryProps {
feed: Comment[];
directories: DirectoryCommunity[];
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;
return (
<strong>
(
{urgent > 0 && normal > 0 ? (
<>
<span className={styles.modQueueButtonCount}>{normal}</span>
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>+{urgent}</span>
</>
) : urgent > 0 ? (
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>{urgent}</span>
) : (
<span className={styles.modQueueButtonCount}>{total}</span>
)}
)
</strong>
);
};
const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }: ModQueueBoardSummaryProps) => {
const { t } = useTranslation();
const selectedBoardFilter = useModQueueStore((state) => state.selectedBoardFilter);
const setSelectedBoardFilter = useModQueueStore((state) => state.setSelectedBoardFilter);
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 boardCounts = useMemo(() => {
const counts = new Map<string, { normal: number; urgent: number }>();
for (const address of accountCommunityAddresses) {
counts.set(address, { normal: 0, urgent: 0 });
}
for (const item of locallyModeratedFeed) {
const addr = getCommentCommunityAddress(item);
if (!addr) continue;
const entry = counts.get(addr);
if (!entry) continue;
const isAwaiting = isPendingApprovalAwaiting(item);
if (!isAwaiting) continue;
const timeWaiting = currentTime - (item.timestamp ?? 0);
const isUrgent = timeWaiting > alertThresholdSeconds;
if (isUrgent) entry.urgent++;
else entry.normal++;
}
return counts;
}, [locallyModeratedFeed, accountCommunityAddresses, currentTime, alertThresholdSeconds]);
const { totalNormal, totalUrgent } = useMemo(() => {
let normal = 0;
let urgent = 0;
for (const entry of boardCounts.values()) {
normal += entry.normal;
urgent += entry.urgent;
}
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<string>();
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]);
if (accountCommunityAddresses.length === 0) {
return null;
}
return (
<span className={styles.boardSummary}>
<button type='button' className={`${styles.boardSummaryLink} ${!selectedBoardFilter ? styles.boardSummaryLinkSelected : ''}`} onClick={handleSelectAll}>
{t('all')}
{totalNormal + totalUrgent > 0 && (
<>
{' '}
<ModQueueBoardCount normal={totalNormal} urgent={totalUrgent} />
</>
)}
</button>
{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 };
return (
<React.Fragment key={address}>
{' / '}
<button
type='button'
className={`${styles.boardSummaryLink} ${isSelected ? styles.boardSummaryLinkSelected : ''}`}
onClick={() => handleSelectBoard(address)}
>
{displayText}
{normal + urgent > 0 && (
<>
{' '}
<ModQueueBoardCount normal={normal} urgent={urgent} />
</>
)}
</button>
</React.Fragment>
);
})}
</span>
);
};
interface ModQueueButtonProps {
boardIdentifier?: string;
isMobile?: boolean;
}
interface ModQueueButtonContentProps {
feed: Comment[];
alertThresholdSeconds: number;
boardIdentifier?: string;
isMobile?: boolean;
}
const ModQueueButtonContent = ({ feed, alertThresholdSeconds, boardIdentifier, isMobile }: ModQueueButtonContentProps) => {
const { t } = useTranslation();
const currentTime = useCurrentTime();
const locallyModeratedFeed = useLocallyModeratedModQueueFeed(feed, currentTime);
const { normalCount, urgentCount } = useMemo(() => {
let normal = 0;
let urgent = 0;
for (const comment of locallyModeratedFeed) {
if (!isPendingApprovalAwaiting(comment)) continue;
const timeWaiting = currentTime - (comment.timestamp ?? 0);
if (timeWaiting > alertThresholdSeconds) urgent++;
else normal++;
}
return { normalCount: normal, urgentCount: urgent };
}, [alertThresholdSeconds, currentTime, locallyModeratedFeed]);
const totalCount = normalCount + urgentCount;
const to = boardIdentifier ? `/${boardIdentifier}/mod/queue` : '/mod/queue';
const buttonContent = (
<Link className='button' to={to}>
{t('mod_queue')}
{totalCount > 0 && (
<strong>
(
{urgentCount > 0 && normalCount > 0 ? (
<>
<span className={styles.modQueueButtonCount}>{normalCount}</span>
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>
{'+'}
{urgentCount}
</span>
</>
) : urgentCount > 0 ? (
<span className={`${styles.modQueueButtonCount} ${styles.modQueueButtonCountAlert}`}>{urgentCount}</span>
) : (
<span className={styles.modQueueButtonCount}>{totalCount}</span>
)}
)
</strong>
)}
</Link>
);
return isMobile ? buttonContent : <>[{buttonContent}]</>;
};
export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => {
const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
const account = useAccount();
const accountAddress = account?.author?.address;
const accountCommunityAddresses = useAccountCommunityAddresses();
const directories = useDirectories();
const resolvedAddress = useMemo(() => {
if (boardIdentifier) {
return getCommunityAddress(boardIdentifier, directories);
}
return undefined;
}, [boardIdentifier, directories]);
const resolvedCommunity = useCommunityIdentifier(resolvedAddress);
const community = useCommunity(resolvedCommunity ? { community: resolvedCommunity } : undefined);
const communityAddresses = useMemo(() => {
if (resolvedAddress) {
return [resolvedAddress];
}
return accountCommunityAddresses;
}, [resolvedAddress, accountCommunityAddresses]);
const accountRole = accountAddress ? community?.roles?.[accountAddress]?.role : undefined;
const hasBoardAccessFromAccountCommunities = resolvedAddress
? accountCommunityAddresses.some((address) => areSameBoardAddress(address, resolvedAddress))
: accountCommunityAddresses.length > 0;
const hasBoardAccess = canAccessBoardModQueue({
boardAddress: resolvedAddress,
accountCommunityAddresses,
accountRole,
});
const isBoardAccessLoading =
Boolean(resolvedAddress) &&
Boolean(accountAddress) &&
!hasModQueueAccessRole(accountRole) &&
!hasBoardAccessFromAccountCommunities &&
community?.state !== 'succeeded' &&
community?.state !== 'failed';
// Only fetch if we have addresses to check and permissions
const shouldFetch = !isBoardAccessLoading && communityAddresses.length > 0 && hasBoardAccess;
const feedAddresses = shouldFetch ? communityAddresses : [];
const feedCommunities = useCommunityIdentifiers(feedAddresses);
const feedOptions = useMemo(
() => ({
communities: feedCommunities,
modQueue: ['pendingApproval'],
sortType: 'new' as const,
postsPerPage: 200,
}),
[feedCommunities],
);
const { feed } = useFeed(feedOptions);
if (!shouldFetch || communityAddresses.length === 0) {
return null;
}
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} />;
};
const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => {
const { t } = useTranslation();
const params = useParams();
const selectedBoardFilter = useModQueueStore((state) => state.selectedBoardFilter);
const viewMode = useModQueueStore((state) => state.viewMode);
const dismissedCommentCids = useModQueueStore((state) => state.dismissedCommentCids);
const queuedCommentHistory = useModQueueStore((state) => state.queuedCommentHistory);
const rememberCommentsInQueue = useModQueueStore((state) => state.rememberCommentsInQueue);
const isMobile = useIsMobile();
const accountCommunityAddresses = useAccountCommunityAddresses();
const directories = useDirectories();
const boardIdentifier = propBoardIdentifier || params.boardIdentifier;
const resolvedAddress = useMemo(() => {
if (boardIdentifier) {
return getCommunityAddress(boardIdentifier, directories);
}
return undefined;
}, [boardIdentifier, directories]);
const communityAddresses = useMemo(() => {
if (resolvedAddress) return [resolvedAddress];
return accountCommunityAddresses;
}, [resolvedAddress, accountCommunityAddresses]);
const communities = useCommunityIdentifiers(communityAddresses);
const communityAddress = communityAddresses[0];
const communityIdentifier = useCommunityIdentifier(communityAddress);
const community = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined);
const { error: communityError } = community || {};
const feedOptions = useMemo(
() => ({
communities,
modQueue: ['pendingApproval'],
postsPerPage: 50,
}),
[communities],
);
const { feed, hasMore, loadMore, reset } = useFeed(feedOptions);
const queuedCommentSnapshots = useMemo(
() =>
feed.flatMap((comment) => {
const snapshot = getQueuedCommentSnapshot(comment);
return snapshot ? [snapshot] : [];
}),
[feed],
);
useEffect(() => {
if (queuedCommentSnapshots.length > 0) {
rememberCommentsInQueue(queuedCommentSnapshots);
}
}, [queuedCommentSnapshots, rememberCommentsInQueue]);
const feedWithHistory = useMemo(() => {
const liveCids = feed.reduce<Set<string>>((cids, comment) => {
if (comment.cid) cids.add(comment.cid);
return cids;
}, new Set());
return [
...feed,
...(queuedCommentHistory.filter((comment) => {
const commentCommunityAddress = getCommentCommunityAddress(comment);
return (
comment.cid &&
!liveCids.has(comment.cid) &&
commentCommunityAddress &&
communityAddresses.some((communityAddress) => areSameBoardAddress(communityAddress, commentCommunityAddress))
);
}) as Comment[]),
];
}, [communityAddresses, feed, queuedCommentHistory]);
const dismissedCommentCidSet = useMemo(() => new Set(dismissedCommentCids), [dismissedCommentCids]);
const filteredFeed = useMemo(
() => filterVisibleModQueueFeed(feedWithHistory, selectedBoardFilter, dismissedCommentCidSet),
[feedWithHistory, selectedBoardFilter, dismissedCommentCidSet],
);
const addressToPathMap = useMemo(() => {
const map = new Map<string, string>();
for (const addr of communityAddresses) {
map.set(addr, getBoardPath(addr, directories));
}
return map;
}, [communityAddresses, directories]);
const showBoardColumn = !resolvedAddress;
const compactRowItemContent = useCallback(
(index: number, comment: Comment) => {
const commentCommunityAddress = getCommentCommunityAddress(comment);
const path = addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined);
return (
<ModQueueRow
key={comment.cid}
comment={comment}
isOdd={index % 2 === 0}
showBoard={showBoardColumn}
boardPath={path}
boardDisplayPath={path && commentCommunityAddress ? getBoardDisplayPath(commentCommunityAddress, path) : undefined}
/>
);
},
[addressToPathMap, showBoardColumn, directories],
);
const compactCardItemContent = useCallback(
(_index: number, comment: Comment) => {
const commentCommunityAddress = getCommentCommunityAddress(comment);
const path = addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined);
return (
<ModQueueCard
key={comment.cid}
comment={comment}
showBoard={showBoardColumn}
boardPath={path}
boardDisplayPath={path && commentCommunityAddress ? getBoardDisplayPath(commentCommunityAddress, path) : undefined}
/>
);
},
[addressToPathMap, showBoardColumn, directories],
);
const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
useEffect(() => {
setResetFunction(reset);
}, [reset, setResetFunction]);
// Memoize footer components object to preserve identity across renders (Virtuoso optimization)
// Note: useFeedStateString is called inside ModQueueFooter to isolate re-renders from backend state changes
const footerComponents = useMemo(
() => ({
Footer: () => (
<>
{communityError?.message && feed.length === 0 && (
<div className={styles.error}>
<ErrorDisplay error={communityError} />
</div>
)}
<ModQueueFooter hasMore={hasMore} communityAddresses={communityAddresses} />
</>
),
}),
[hasMore, communityAddresses, communityError, feed.length],
);
const pageFooter = (
<>
<PageFooterDesktop firstRow={<StyleOnlyFooterFirstRow />} />
<PageFooterMobile>
<div>
<div className={footerStyles.mobileFooterButtons}>
<button type='button' className='button' onClick={() => window.scrollTo({ top: 0, left: 0, behavior: 'instant' })}>
{t('top')}
</button>
<button type='button' className='button' onClick={() => reset?.()}>
{t('refresh')}
</button>
</div>
</div>
</PageFooterMobile>
</>
);
return (
<>
<div className={styles.container}>
{!resolvedAddress && (
<div className={styles.controls}>
<div className={styles.controlsLeft}>
<ModQueueBoardSummary feed={feed} directories={directories} accountCommunityAddresses={accountCommunityAddresses} />
</div>
</div>
)}
{filteredFeed.length === 0 && !hasMore ? (
<div className={styles.empty}>{t('queue_is_empty')}</div>
) : (
<>
{viewMode === 'compact' && !isMobile && (
<>
<div className={styles.tableHeader}>
<div className={styles.numberHeader}>No.</div>
{!resolvedAddress && <div className={styles.boardHeader}>{t('board')}</div>}
<div className={styles.excerptHeader}>{t('excerpt')}</div>
<div className={styles.timeHeader}>{t('submitted')}</div>
<div className={styles.typeHeader}>{t('type')}</div>
<div className={styles.imageHeader}>{t('image')}</div>
<div className={styles.actionsHeader}>{t('actions')}</div>
</div>
{hasMore ? (
<Virtuoso
useWindowScroll
data={filteredFeed}
totalCount={filteredFeed.length}
endReached={loadMore}
increaseViewportBy={{ bottom: 600, top: 600 }}
itemContent={compactRowItemContent}
components={footerComponents}
/>
) : (
<>
{filteredFeed.map((comment, index) => {
const commentCommunityAddress = getCommentCommunityAddress(comment);
const path =
addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined);
return (
<ModQueueRow
key={comment.cid}
comment={comment}
isOdd={index % 2 === 0}
showBoard={showBoardColumn}
boardPath={path}
boardDisplayPath={path && commentCommunityAddress ? getBoardDisplayPath(commentCommunityAddress, path) : undefined}
/>
);
})}
{communityError?.message && feed.length === 0 && (
<div className={styles.error}>
<ErrorDisplay error={communityError} />
</div>
)}
<ModQueueFooter hasMore={hasMore} communityAddresses={communityAddresses} />
</>
)}
</>
)}
{viewMode === 'compact' && isMobile && (
<>
{hasMore ? (
<Virtuoso
useWindowScroll
data={filteredFeed}
totalCount={filteredFeed.length}
endReached={loadMore}
increaseViewportBy={{ bottom: 600, top: 600 }}
itemContent={compactCardItemContent}
components={footerComponents}
/>
) : (
<>
{filteredFeed.map((comment) => {
const commentCommunityAddress = getCommentCommunityAddress(comment);
const path =
addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined);
return (
<ModQueueCard
key={comment.cid}
comment={comment}
showBoard={showBoardColumn}
boardPath={path}
boardDisplayPath={path && commentCommunityAddress ? getBoardDisplayPath(commentCommunityAddress, path) : undefined}
/>
);
})}
{communityError?.message && feed.length === 0 && (
<div className={styles.error}>
<ErrorDisplay error={communityError} />
</div>
)}
<ModQueueFooter hasMore={hasMore} communityAddresses={communityAddresses} />
</>
)}
</>
)}
{viewMode === 'feed' && (
<>
{hasMore ? (
<Virtuoso
useWindowScroll
data={filteredFeed}
totalCount={filteredFeed.length}
endReached={loadMore}
increaseViewportBy={{ bottom: 600, top: 600 }}
itemContent={(_index, comment) => <ModQueueFeedPost key={comment.cid} comment={comment} />}
components={footerComponents}
/>
) : (
<>
{filteredFeed.map((comment) => (
<ModQueueFeedPost key={comment.cid} comment={comment} />
))}
{communityError?.message && feed.length === 0 && (
<div className={styles.error}>
<ErrorDisplay error={communityError} />
</div>
)}
<ModQueueFooter hasMore={hasMore} communityAddresses={communityAddresses} />
</>
)}
</>
)}
</>
)}
</div>
{pageFooter}
</>
);
};
export default ModQueueView;