import React, { useMemo, useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams, Link } from 'react-router-dom'; import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useSubplebbit, useComment } from '@plebbit/plebbit-react-hooks'; import useAccountsStore from '@plebbit/plebbit-react-hooks/dist/stores/accounts'; import { Virtuoso } from 'react-virtuoso'; import styles from './mod-queue.module.css'; import useModQueueStore from '../../stores/use-mod-queue-store'; import { useAccountSubplebbitsWithMetadata } from '../../hooks/use-account-subplebbits-with-metadata'; import LoadingEllipsis from '../../components/loading-ellipsis'; import ErrorDisplay from '../../components/error-display/error-display'; import { useFeedStateString } from '../../hooks/use-state-string'; import { getSubplebbitAddress, getBoardPath } from '../../lib/utils/route-utils'; import { useDirectories, DirectoryCommunity } from '../../hooks/use-directories'; import { useBoardPath } from '../../hooks/use-resolved-subplebbit-address'; import { getHasThumbnail, getCommentMediaInfo } from '../../lib/utils/media-utils'; 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 Tooltip from '../../components/tooltip'; import useIsMobile from '../../hooks/use-is-mobile'; import { Post } from '../post/post'; import { capitalize, lowerCase } from 'lodash'; const { addChallenge } = useChallengesStore.getState(); interface ModQueueViewProps { boardIdentifier?: string; // If provided, shows queue for single board } interface ModQueueFooterProps { hasMore: boolean; subplebbitAddresses: 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, subplebbitAddresses }: ModQueueFooterProps) => { const { t } = useTranslation(); const loadingStateString = useFeedStateString(subplebbitAddresses) || t('loading'); return hasMore ? (
) : null; }; interface ModQueueRowProps { comment: Comment; isOdd?: boolean; } // Track which action was initiated to show appropriate completion message type ModerationAction = 'approve' | 'reject' | null; interface ModQueueActionState { status: 'approved' | 'rejected' | 'failed' | null; errorMessage?: string; isPublishing: boolean; handleApprove: () => Promise; handleReject: () => Promise; } const useModQueueActions = (comment: Comment): ModQueueActionState => { const { t } = useTranslation(); const { cid, subplebbitAddress, approved, removed } = comment || {}; const [initiatedAction, setInitiatedAction] = useState(null); const alreadyApproved = approved === true; const alreadyRejected = removed === true; const { publishCommentModeration: approve, state: approveState, error: approveError, } = usePublishCommentModeration({ commentCid: cid, subplebbitAddress, commentModeration: { approved: true }, onChallenge: async (...args: any) => { addChallenge([...args, comment]); }, onChallengeVerification: async (challengeVerification, comment) => { alertChallengeVerificationFailed(challengeVerification, comment); }, onError: (error: Error) => { console.error('Approve failed:', error); }, }); const { publishCommentModeration: reject, state: rejectState, error: rejectError, } = usePublishCommentModeration({ commentCid: cid, subplebbitAddress, commentModeration: { removed: true }, onChallenge: async (...args: any) => { addChallenge([...args, comment]); }, onChallengeVerification: async (challengeVerification, comment) => { alertChallengeVerificationFailed(challengeVerification, comment); }, onError: (error: Error) => { console.error('Reject failed:', error); }, }); const handleApprove = useCallback(async () => { const confirm = window.confirm(t('double_confirm')); if (!confirm) { return; } setInitiatedAction('approve'); try { await approve(); } catch (e) { console.error(e); } }, [approve, t]); const handleReject = useCallback(async () => { const confirm = window.confirm(t('double_confirm')); if (!confirm) { return; } setInitiatedAction('reject'); try { await reject(); } catch (e) { console.error(e); } }, [reject, t]); 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 errorMessage = approveFailed ? approveError?.message : rejectFailed ? rejectError?.message : undefined; return { status, errorMessage, isPublishing, handleApprove, handleReject }; }; const ModQueueRow = ({ comment, isOdd = false }: ModQueueRowProps) => { const { t } = useTranslation(); const { getAlertThresholdSeconds } = useModQueueStore(); const isMobile = useIsMobile(); const { editedComment } = useEditedComment({ comment }); const displayComment = editedComment || comment; const { content, title, timestamp, subplebbitAddress, cid, threadCid, link, thumbnailUrl, linkWidth, linkHeight, removed, approved, number, parentCid } = displayComment; // Check if already moderated (from previous session or API update) // Note: `approved` and `removed` are direct fields on the comment from CommentUpdate, // not nested under commentModeration (which is the options object for publishing moderation actions) const alreadyApproved = approved === true; const alreadyRejected = removed === true; const timeWaiting = Date.now() / 1000 - timestamp; const alertThresholdSeconds = getAlertThresholdSeconds(); const isOverThreshold = timeWaiting > alertThresholdSeconds; // Only show alert animation for comments awaiting approval (not approved or rejected) const isAwaitingApproval = !alreadyApproved && !alreadyRejected; const { status, errorMessage, isPublishing, handleApprove, handleReject } = useModQueueActions(displayComment); const boardPath = useBoardPath(subplebbitAddress); 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'); // Only truncate excerpt on desktop, allow wrapping on mobile const excerpt = !isMobile && rawExcerpt.length > 101 ? rawExcerpt.slice(0, 98) + '...' : rawExcerpt; const threadTargetCid = threadCid || cid; const postUrl = boardPath && threadTargetCid ? `/${boardPath}/thread/${threadTargetCid}` : undefined; // Render the status or action buttons const renderActions = () => { // Check existing moderation state first (from API/previous sessions) if (status === 'approved') { return {t('approved')}; } if (status === 'rejected') { return {t('rejected')}; } if (status === 'failed') { return ( {t('failed')} {errorMessage ? `: ${errorMessage}` : ''} ); } if (isPublishing) { return ; } return (
[ ] [ ]
); }; return (
{number ?? 'N/A'}
{postUrl ? ( {excerpt} ) : ( {excerpt} )}
{isMobile ? ( // On mobile, show shorter time ago format without tooltip isAwaitingApproval && isOverThreshold ? ( {getFormattedTimeAgo(timestamp)} ) : ( {getFormattedTimeAgo(timestamp)} ) ) : // On desktop, show full date with tooltip isAwaitingApproval && isOverThreshold ? ( <> {getFormattedDate(timestamp)}} content={getFormattedTimeAgo(timestamp)} /> {' '} ({getFormattedTimeAgo(timestamp)}) ) : ( {getFormattedDate(timestamp)}} content={getFormattedTimeAgo(timestamp)} /> )}
{isReply ? capitalize(t('reply')) : capitalize(t('post'))}
{hasThumbnail ? t('yes') : t('no')}
{renderActions()}
); }; interface ModQueueCardProps { comment: Comment; } const ModQueueCard = ({ comment }: ModQueueCardProps) => { const { t } = useTranslation(); const { getAlertThresholdSeconds } = useModQueueStore(); const { editedComment } = useEditedComment({ comment }); const displayComment = editedComment || comment; const { content, title, timestamp, subplebbitAddress, cid, threadCid, link, thumbnailUrl, linkWidth, linkHeight, removed, approved, number, parentCid } = displayComment; const alreadyApproved = approved === true; const alreadyRejected = removed === true; const timeWaiting = Date.now() / 1000 - timestamp; const alertThresholdSeconds = getAlertThresholdSeconds(); const isOverThreshold = timeWaiting > alertThresholdSeconds; const isAwaitingApproval = !alreadyApproved && !alreadyRejected; const { status, errorMessage, isPublishing, handleApprove, handleReject } = useModQueueActions(displayComment); const boardPath = useBoardPath(subplebbitAddress); 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'); const excerpt = rawExcerpt.length > 140 ? rawExcerpt.slice(0, 137) + '...' : rawExcerpt; const threadTargetCid = threadCid || cid; const postUrl = boardPath && threadTargetCid ? `/${boardPath}/thread/${threadTargetCid}` : undefined; const renderActions = () => { if (status === 'approved') { return (
{t('approved')}
); } if (status === 'rejected') { return (
{t('rejected')}
); } if (status === 'failed') { return (
{t('failed')} {errorMessage ? `: ${errorMessage}` : ''}
); } if (isPublishing) { return (
); } return (
); }; return (
No. {number ?? 'N/A'} {isAwaitingApproval && isOverThreshold ? ( <> {getFormattedDate(timestamp)} ({getFormattedTimeAgo(timestamp)}) ) : ( getFormattedDate(timestamp) )}
{t('excerpt')}:{' '} {postUrl ? ( {excerpt} ) : ( {excerpt} )}{' '} / {t('type')}: {isReply ? t('reply') : t('post')} / {capitalize(t('image'))}: {hasThumbnail ? lowerCase(t('yes')) : lowerCase(t('no'))}
{renderActions()}
); }; const ModQueueFeedPost = ({ comment }: { comment: Comment }) => { const { editedComment } = useEditedComment({ comment }); const displayComment = editedComment || comment; const { status, errorMessage, isPublishing, handleApprove, handleReject } = useModQueueActions(displayComment); const { parentCid } = displayComment; // Fetch parent comment if this is a reply const parentComment = useComment({ commentCid: parentCid }); return ( <> {parentCid && parentComment && (
)} ); }; interface ModQueueBoardFilterProps { communities: DirectoryCommunity[]; } const ModQueueBoardFilter = ({ communities }: ModQueueBoardFilterProps) => { const { t } = useTranslation(); const { selectedBoardFilter, setSelectedBoardFilter } = useModQueueStore(); const handleChange = (e: React.ChangeEvent) => { const value = e.target.value; setSelectedBoardFilter(value); }; if (!communities || communities.length === 0) { return null; } // Default to first board if none selected const firstBoardAddress = communities.find((sub) => sub.address)?.address; const currentFilter = selectedBoardFilter || firstBoardAddress || ''; // Auto-select first board if none is selected useEffect(() => { if (!selectedBoardFilter && firstBoardAddress) { setSelectedBoardFilter(firstBoardAddress); } }, [selectedBoardFilter, firstBoardAddress, setSelectedBoardFilter]); return (
); }; interface ModQueueButtonProps { boardIdentifier?: string; isMobile?: boolean; } interface ModQueueCountItemProps { comment: Comment; alertThresholdSeconds: number; onStatusChange: (cid: string, status: { awaiting: boolean; urgent: boolean }) => void; } const ModQueueCountItem = ({ comment, alertThresholdSeconds, onStatusChange }: ModQueueCountItemProps) => { const { editedComment } = useEditedComment({ comment }); const displayComment = editedComment || comment; const { cid, approved, removed, timestamp } = displayComment; const isAwaiting = approved !== true && removed !== true; const timeWaiting = Date.now() / 1000 - timestamp; const isUrgent = isAwaiting && timeWaiting > alertThresholdSeconds; useEffect(() => { onStatusChange(cid, { awaiting: isAwaiting, urgent: isUrgent }); }, [cid, isAwaiting, isUrgent, onStatusChange]); return null; }; interface ModQueueButtonContentProps { feed: Comment[]; alertThresholdSeconds: number; boardIdentifier?: string; isMobile?: boolean; } const ModQueueButtonContent = ({ feed, alertThresholdSeconds, boardIdentifier, isMobile }: ModQueueButtonContentProps) => { const { t } = useTranslation(); const [statusMap, setStatusMap] = useState>(new Map()); const handleStatusChange = React.useCallback((cid: string, status: { awaiting: boolean; urgent: boolean }) => { setStatusMap((prev) => { const next = new Map(prev); next.set(cid, status); return next; }); }, []); // Clean up stale entries when comments leave the feed to prevent memory leaks const feedCids = useMemo(() => new Set(feed.map((item) => item.cid)), [feed]); useEffect(() => { setStatusMap((prev) => { const staleKeys = [...prev.keys()].filter((cid) => !feedCids.has(cid)); if (staleKeys.length === 0) return prev; const next = new Map(prev); for (const key of staleKeys) { next.delete(key); } return next; }); }, [feedCids]); const { normalCount, urgentCount } = useMemo(() => { let normal = 0; let urgent = 0; for (const { awaiting, urgent: isUrgent } of statusMap.values()) { if (awaiting) { if (isUrgent) urgent++; else normal++; } } return { normalCount: normal, urgentCount: urgent }; }, [statusMap]); const totalCount = normalCount + urgentCount; const to = boardIdentifier ? `/${boardIdentifier}/modqueue` : '/mod/modqueue'; const buttonContent = ( ); return ( <> {feed.map((item) => ( ))} {isMobile ? buttonContent : <>[{buttonContent}]} ); }; export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => { const { getAlertThresholdSeconds } = useModQueueStore(); const accountSubplebbitAddresses = useAccountsStore( (state) => { const activeAccountId = state.activeAccountId; const activeAccount = activeAccountId ? state.accounts[activeAccountId] : undefined; const accountSubplebbits = activeAccount?.subplebbits || {}; return Object.keys(accountSubplebbits); }, (prev, next) => { if (prev.length !== next.length) return false; return prev.every((val, idx) => val === next[idx]); }, ); const directories = useDirectories(); const resolvedAddress = useMemo(() => { if (boardIdentifier) { return getSubplebbitAddress(boardIdentifier, directories); } return undefined; }, [boardIdentifier, directories]); const subplebbitAddresses = useMemo(() => { if (resolvedAddress) { return [resolvedAddress]; } return accountSubplebbitAddresses; }, [resolvedAddress, accountSubplebbitAddresses]); // If specific board, check if user is mod using resolved address const isModOfBoard = resolvedAddress ? accountSubplebbitAddresses.includes(resolvedAddress) : true; // Only fetch if we have addresses to check and permissions const shouldFetch = subplebbitAddresses.length > 0 && isModOfBoard; const { feed } = useFeed({ subplebbitAddresses: shouldFetch ? subplebbitAddresses : [], modQueue: ['pendingApproval'], sortType: 'new', postsPerPage: 200, // Fetch more items to get accurate pending count for the badge }); if (!shouldFetch || subplebbitAddresses.length === 0) { return null; } const alertThresholdSeconds = getAlertThresholdSeconds(); // Use key to reset statusMap state when switching boards (prevents stale counts from previous board) const contentKey = subplebbitAddresses.join(','); return ; }; export const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => { const { t } = useTranslation(); const params = useParams(); const { selectedBoardFilter, viewMode } = useModQueueStore(); const isMobile = useIsMobile(); const accountSubplebbitAddresses = useAccountsStore( (state) => { const activeAccountId = state.activeAccountId; const activeAccount = activeAccountId ? state.accounts[activeAccountId] : undefined; const accountSubplebbits = activeAccount?.subplebbits || {}; return Object.keys(accountSubplebbits); }, (prev, next) => { if (prev.length !== next.length) return false; return prev.every((val, idx) => val === next[idx]); }, ); const directories = useDirectories(); const boardIdentifier = propBoardIdentifier || params.boardIdentifier; const resolvedAddress = useMemo(() => { if (boardIdentifier) { return getSubplebbitAddress(boardIdentifier, directories); } return undefined; }, [boardIdentifier, directories]); const communitiesWithMetadata = useAccountSubplebbitsWithMetadata(); const subplebbitAddresses = useMemo(() => { if (resolvedAddress) { return [resolvedAddress]; } // Always require a board filter when viewing /mod/modqueue (no boardIdentifier) if (selectedBoardFilter) { return [selectedBoardFilter]; } // Default to first board if none selected const firstBoardAddress = accountSubplebbitAddresses[0]; if (firstBoardAddress) { return [firstBoardAddress]; } return []; }, [resolvedAddress, selectedBoardFilter, accountSubplebbitAddresses]); const subplebbitAddress = subplebbitAddresses[0]; const subplebbit = useSubplebbit({ subplebbitAddress }); const { error: subplebbitError } = subplebbit || {}; const { feed, hasMore, loadMore, reset } = useFeed({ subplebbitAddresses, modQueue: ['pendingApproval'], postsPerPage: 50, }); // Register reset function with feed reset store so refresh button works const setResetFunction = useFeedResetStore((state) => state.setResetFunction); useEffect(() => { setResetFunction(reset); }, [reset, setResetFunction]); // Auto-select first board if viewing /mod/modqueue without a boardIdentifier and no filter is set useEffect(() => { if (!resolvedAddress && !selectedBoardFilter && accountSubplebbitAddresses.length > 0) { const { setSelectedBoardFilter } = useModQueueStore.getState(); setSelectedBoardFilter(accountSubplebbitAddresses[0]); } }, [resolvedAddress, selectedBoardFilter, accountSubplebbitAddresses]); // 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: () => ( <> {subplebbitError?.message && feed.length === 0 && (
)} ), }), [hasMore, subplebbitAddresses, subplebbitError, feed.length], ); return (
{!resolvedAddress && (
)} {feed.length === 0 && !hasMore ? (
{t('queue_is_empty')}
) : ( <> {viewMode === 'compact' && !isMobile && ( <>
No.
{t('excerpt')}
{t('submitted')}
{t('type')}
{t('image')}
{t('actions')}
{hasMore ? ( } components={footerComponents} /> ) : ( <> {feed.map((comment, index) => ( ))} {subplebbitError?.message && feed.length === 0 && (
)} )} )} {viewMode === 'compact' && isMobile && ( <> {hasMore ? ( } components={footerComponents} /> ) : ( <> {feed.map((comment) => ( ))} {subplebbitError?.message && feed.length === 0 && (
)} )} )} {viewMode === 'feed' && ( <> {hasMore ? ( } components={footerComponents} /> ) : ( <> {feed.map((comment) => ( ))} {subplebbitError?.message && feed.length === 0 && (
)} )} )} )}
); }; export default ModQueueView;