2026-01-07 16:03:02 +01:00
|
|
|
import React, { useMemo, useState, useEffect } from 'react';
|
|
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
|
import { useParams, Link } from 'react-router-dom';
|
2026-01-11 12:28:21 +01:00
|
|
|
import { useAccountSubplebbits, useFeed, Comment, usePublishCommentModeration, useEditedComment } from '@plebbit/plebbit-react-hooks';
|
2026-01-07 16:03:02 +01:00
|
|
|
import { Virtuoso } from 'react-virtuoso';
|
|
|
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
|
|
|
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 { useFeedStateString } from '../../hooks/use-state-string';
|
|
|
|
|
import { getSubplebbitAddress, getBoardPath } from '../../lib/utils/route-utils';
|
|
|
|
|
import { useDefaultSubplebbits, MultisubSubplebbit } from '../../hooks/use-default-subplebbits';
|
|
|
|
|
import { useBoardPath } from '../../hooks/use-resolved-subplebbit-address';
|
|
|
|
|
import { getHasThumbnail, getCommentMediaInfo } from '../../lib/utils/media-utils';
|
2026-01-11 12:28:21 +01:00
|
|
|
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
|
2026-01-07 16:03:02 +01:00
|
|
|
import useFeedResetStore from '../../stores/use-feed-reset-store';
|
2026-01-08 17:36:47 +01:00
|
|
|
import useChallengesStore from '../../stores/use-challenges-store';
|
|
|
|
|
import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
|
|
|
|
|
|
|
|
|
|
const { addChallenge } = useChallengesStore.getState();
|
2026-01-07 16:03:02 +01:00
|
|
|
|
|
|
|
|
interface ModQueueViewProps {
|
|
|
|
|
boardIdentifier?: string; // If provided, shows queue for single board
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-08 17:16:29 +01:00
|
|
|
interface ModQueueFooterProps {
|
|
|
|
|
hasMore: boolean;
|
|
|
|
|
loadingStateString: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Defined outside ModQueueView to preserve component identity across renders (Virtuoso optimization)
|
|
|
|
|
const ModQueueFooter = ({ hasMore, loadingStateString }: ModQueueFooterProps) => {
|
|
|
|
|
return hasMore ? (
|
|
|
|
|
<div style={{ padding: '10px', textAlign: 'center' }}>
|
|
|
|
|
<LoadingEllipsis string={loadingStateString} />
|
|
|
|
|
</div>
|
|
|
|
|
) : null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-07 16:03:02 +01:00
|
|
|
interface ModQueueRowProps {
|
|
|
|
|
comment: Comment;
|
|
|
|
|
showBoardColumn?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-08 17:36:47 +01:00
|
|
|
// Track which action was initiated to show appropriate completion message
|
|
|
|
|
type ModerationAction = 'approve' | 'reject' | null;
|
|
|
|
|
|
2026-01-07 16:03:02 +01:00
|
|
|
const ModQueueRow = ({ comment, showBoardColumn = false }: ModQueueRowProps) => {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const { alertThresholdHours } = useModQueueStore();
|
2026-01-08 17:36:47 +01:00
|
|
|
const [initiatedAction, setInitiatedAction] = useState<ModerationAction>(null);
|
2026-01-07 16:03:02 +01:00
|
|
|
|
2026-01-11 12:28:21 +01:00
|
|
|
// handle pending mod or author edit
|
|
|
|
|
const { editedComment } = useEditedComment({ comment });
|
|
|
|
|
const displayComment = editedComment || comment;
|
|
|
|
|
|
|
|
|
|
const { content, title, timestamp, subplebbitAddress, cid, threadCid, link, thumbnailUrl, linkWidth, linkHeight, removed, approved, number } = displayComment;
|
2026-01-08 17:59:41 +01:00
|
|
|
|
|
|
|
|
// 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;
|
2026-01-07 16:03:02 +01:00
|
|
|
|
|
|
|
|
const boardPath = useBoardPath(subplebbitAddress);
|
|
|
|
|
const timeWaiting = Date.now() / 1000 - timestamp;
|
|
|
|
|
const isOverThreshold = timeWaiting > alertThresholdHours * 3600;
|
|
|
|
|
|
2026-01-10 18:11:38 +01:00
|
|
|
// Only show alert animation for comments awaiting approval (not approved or rejected)
|
|
|
|
|
const isAwaitingApproval = !alreadyApproved && !alreadyRejected;
|
|
|
|
|
|
2026-01-08 17:36:47 +01:00
|
|
|
const {
|
|
|
|
|
publishCommentModeration: approve,
|
|
|
|
|
state: approveState,
|
|
|
|
|
error: approveError,
|
|
|
|
|
} = usePublishCommentModeration({
|
2026-01-07 16:03:02 +01:00
|
|
|
commentCid: cid,
|
|
|
|
|
subplebbitAddress,
|
|
|
|
|
commentModeration: { approved: true },
|
2026-01-08 18:56:43 +01:00
|
|
|
onChallenge: async (...args: any) => {
|
|
|
|
|
addChallenge([...args, comment]);
|
|
|
|
|
},
|
|
|
|
|
onChallengeVerification: async (challengeVerification, comment) => {
|
|
|
|
|
alertChallengeVerificationFailed(challengeVerification, comment);
|
|
|
|
|
},
|
2026-01-08 17:36:47 +01:00
|
|
|
onError: (error: Error) => {
|
|
|
|
|
console.error('Approve failed:', error);
|
|
|
|
|
},
|
2026-01-07 16:03:02 +01:00
|
|
|
});
|
|
|
|
|
|
2026-01-08 17:36:47 +01:00
|
|
|
const {
|
|
|
|
|
publishCommentModeration: reject,
|
|
|
|
|
state: rejectState,
|
|
|
|
|
error: rejectError,
|
|
|
|
|
} = usePublishCommentModeration({
|
2026-01-07 16:03:02 +01:00
|
|
|
commentCid: cid,
|
|
|
|
|
subplebbitAddress,
|
|
|
|
|
commentModeration: { removed: true },
|
2026-01-08 18:56:43 +01:00
|
|
|
onChallenge: async (...args: any) => {
|
|
|
|
|
addChallenge([...args, comment]);
|
|
|
|
|
},
|
|
|
|
|
onChallengeVerification: async (challengeVerification, comment) => {
|
|
|
|
|
alertChallengeVerificationFailed(challengeVerification, comment);
|
|
|
|
|
},
|
2026-01-08 17:36:47 +01:00
|
|
|
onError: (error: Error) => {
|
|
|
|
|
console.error('Reject failed:', error);
|
|
|
|
|
},
|
2026-01-07 16:03:02 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const handleApprove = async () => {
|
2026-01-08 17:36:47 +01:00
|
|
|
setInitiatedAction('approve');
|
2026-01-07 16:03:02 +01:00
|
|
|
try {
|
|
|
|
|
await approve();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleReject = async () => {
|
2026-01-08 17:36:47 +01:00
|
|
|
setInitiatedAction('reject');
|
2026-01-07 16:03:02 +01:00
|
|
|
try {
|
|
|
|
|
await reject();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-08 17:36:47 +01:00
|
|
|
// Determine the current moderation state based on which action was initiated
|
|
|
|
|
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';
|
|
|
|
|
|
2026-01-11 12:39:36 +01:00
|
|
|
const rawExcerpt = title || content || (getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link) ? t('image') : t('no_content'));
|
|
|
|
|
const excerpt = rawExcerpt.length > 101 ? rawExcerpt.slice(0, 98) + '...' : rawExcerpt;
|
2026-01-08 17:59:41 +01:00
|
|
|
const threadTargetCid = threadCid || cid;
|
|
|
|
|
const postUrl = boardPath && threadTargetCid ? `/${boardPath}/thread/${threadTargetCid}` : undefined;
|
2026-01-07 16:03:02 +01:00
|
|
|
|
2026-01-08 17:36:47 +01:00
|
|
|
// Render the status or action buttons
|
|
|
|
|
const renderActions = () => {
|
2026-01-08 17:59:41 +01:00
|
|
|
// Check existing moderation state first (from API/previous sessions)
|
|
|
|
|
if (alreadyApproved || approveSucceeded) {
|
2026-01-08 17:36:47 +01:00
|
|
|
return <span className={`${styles.button} ${styles.approve}`}>{t('approved')}</span>;
|
|
|
|
|
}
|
2026-01-08 17:59:41 +01:00
|
|
|
if (alreadyRejected || rejectSucceeded) {
|
2026-01-08 17:36:47 +01:00
|
|
|
return <span className={`${styles.button} ${styles.reject}`}>{t('rejected')}</span>;
|
|
|
|
|
}
|
|
|
|
|
if (approveFailed) {
|
|
|
|
|
return (
|
|
|
|
|
<span className={`${styles.button} ${styles.reject}`}>
|
|
|
|
|
{t('failed')}: {approveError?.message}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (rejectFailed) {
|
|
|
|
|
return (
|
|
|
|
|
<span className={`${styles.button} ${styles.reject}`}>
|
|
|
|
|
{t('failed')}: {rejectError?.message}
|
|
|
|
|
</span>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (isPublishing) {
|
|
|
|
|
return <LoadingEllipsis string={t('publishing')} />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
2026-01-08 18:09:44 +01:00
|
|
|
[
|
|
|
|
|
<button className={styles.button} onClick={handleApprove} disabled={isPublishing}>
|
2026-01-08 17:36:47 +01:00
|
|
|
{t('approve')}
|
|
|
|
|
</button>
|
2026-01-08 18:09:44 +01:00
|
|
|
] [
|
|
|
|
|
<button className={styles.button} onClick={handleReject} disabled={isPublishing}>
|
2026-01-08 17:36:47 +01:00
|
|
|
{t('reject')}
|
|
|
|
|
</button>
|
2026-01-09 15:47:56 +01:00
|
|
|
]
|
2026-01-08 17:36:47 +01:00
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-07 16:03:02 +01:00
|
|
|
return (
|
|
|
|
|
<div className={styles.row}>
|
2026-01-11 12:28:21 +01:00
|
|
|
<div className={styles.number}>{number ?? '—'}</div>
|
2026-01-08 17:59:41 +01:00
|
|
|
{showBoardColumn && <div className={styles.board}>{boardPath ? <Link to={`/${boardPath}`}>/{boardPath}/</Link> : <span>—</span>}</div>}
|
2026-01-07 16:03:02 +01:00
|
|
|
<div className={styles.excerpt}>
|
2026-01-08 17:59:41 +01:00
|
|
|
{postUrl ? (
|
|
|
|
|
<Link to={postUrl} title={excerpt}>
|
|
|
|
|
{excerpt}
|
|
|
|
|
</Link>
|
|
|
|
|
) : (
|
|
|
|
|
<span title={excerpt}>{excerpt}</span>
|
|
|
|
|
)}
|
2026-01-07 16:03:02 +01:00
|
|
|
</div>
|
2026-01-11 12:28:21 +01:00
|
|
|
<div className={styles.time}>
|
|
|
|
|
{isAwaitingApproval ? (
|
|
|
|
|
<>
|
|
|
|
|
{getFormattedDate(timestamp)} (<span className={`${isOverThreshold ? styles.alert : ''}`}>{getFormattedTimeAgo(timestamp)}</span>)
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
getFormattedDate(timestamp)
|
|
|
|
|
)}
|
2026-01-10 18:11:38 +01:00
|
|
|
</div>
|
2026-01-08 17:36:47 +01:00
|
|
|
<div className={styles.actions}>{renderActions()}</div>
|
2026-01-07 16:03:02 +01:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface ModQueueBoardFilterProps {
|
|
|
|
|
subplebbits: MultisubSubplebbit[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ModQueueBoardFilter = ({ subplebbits }: ModQueueBoardFilterProps) => {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const { selectedBoardFilter, setSelectedBoardFilter } = useModQueueStore();
|
|
|
|
|
|
|
|
|
|
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
|
|
|
|
const value = e.target.value;
|
|
|
|
|
setSelectedBoardFilter(value === '' ? null : value);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!subplebbits || subplebbits.length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={styles.filterContainer}>
|
|
|
|
|
<label>{t('filter_by_board')}:</label>
|
|
|
|
|
<select className={styles.filterSelect} value={selectedBoardFilter || ''} onChange={handleChange}>
|
|
|
|
|
<option value=''>{t('all_boards')}</option>
|
|
|
|
|
{subplebbits.map((sub) => {
|
|
|
|
|
const address = sub.address;
|
|
|
|
|
if (!address) return null;
|
|
|
|
|
return (
|
|
|
|
|
<option key={address} value={address}>
|
|
|
|
|
/{getBoardPath(address, subplebbits)}/
|
|
|
|
|
</option>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface ModQueueButtonProps {
|
|
|
|
|
boardIdentifier?: string;
|
|
|
|
|
isMobile?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-11 12:35:59 +01:00
|
|
|
// Counter item that uses useEditedComment to get real-time moderation status
|
|
|
|
|
// and reports its status via callback
|
|
|
|
|
interface ModQueueCountItemProps {
|
|
|
|
|
comment: Comment;
|
|
|
|
|
alertThresholdHours: number;
|
|
|
|
|
onStatusChange: (cid: string, status: { awaiting: boolean; urgent: boolean }) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ModQueueCountItem = ({ comment, alertThresholdHours, 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 > alertThresholdHours * 3600;
|
|
|
|
|
|
|
|
|
|
// Report status changes to parent - useEffect is appropriate here for syncing with parent state
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
onStatusChange(cid, { awaiting: isAwaiting, urgent: isUrgent });
|
|
|
|
|
}, [cid, isAwaiting, isUrgent, onStatusChange]);
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Inner component that handles counting with useEditedComment for each item
|
|
|
|
|
interface ModQueueButtonContentProps {
|
|
|
|
|
feed: Comment[];
|
|
|
|
|
alertThresholdHours: number;
|
|
|
|
|
boardIdentifier?: string;
|
|
|
|
|
isMobile?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ModQueueButtonContent = ({ feed, alertThresholdHours, boardIdentifier, isMobile }: ModQueueButtonContentProps) => {
|
2026-01-07 16:03:02 +01:00
|
|
|
const { t } = useTranslation();
|
2026-01-11 12:35:59 +01:00
|
|
|
const [statusMap, setStatusMap] = useState<Map<string, { awaiting: boolean; urgent: boolean }>>(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;
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
// Calculate counts from status map
|
|
|
|
|
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}/queue` : '/mod/queue';
|
|
|
|
|
|
|
|
|
|
const buttonContent = (
|
|
|
|
|
<button className='button'>
|
|
|
|
|
<Link 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>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{/* Render counter items to track real-time moderation status */}
|
|
|
|
|
{feed.map((item) => (
|
|
|
|
|
<ModQueueCountItem key={item.cid} comment={item} alertThresholdHours={alertThresholdHours} onStatusChange={handleStatusChange} />
|
|
|
|
|
))}
|
|
|
|
|
{isMobile ? buttonContent : <>[{buttonContent}]</>}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => {
|
2026-01-07 16:03:02 +01:00
|
|
|
const { alertThresholdHours } = useModQueueStore();
|
|
|
|
|
const { accountSubplebbits } = useAccountSubplebbits();
|
|
|
|
|
const accountSubplebbitAddresses = useMemo(() => Object.keys(accountSubplebbits || {}), [accountSubplebbits]);
|
2026-01-08 16:43:14 +01:00
|
|
|
const defaultSubplebbits = useDefaultSubplebbits();
|
|
|
|
|
|
|
|
|
|
// Resolve boardIdentifier to address if it exists
|
|
|
|
|
const resolvedAddress = useMemo(() => {
|
|
|
|
|
if (boardIdentifier) {
|
|
|
|
|
return getSubplebbitAddress(boardIdentifier, defaultSubplebbits);
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
}, [boardIdentifier, defaultSubplebbits]);
|
2026-01-07 16:03:02 +01:00
|
|
|
|
|
|
|
|
const subplebbitAddresses = useMemo(() => {
|
2026-01-08 16:43:14 +01:00
|
|
|
if (resolvedAddress) {
|
|
|
|
|
return [resolvedAddress];
|
2026-01-07 16:03:02 +01:00
|
|
|
}
|
|
|
|
|
return accountSubplebbitAddresses;
|
2026-01-08 16:43:14 +01:00
|
|
|
}, [resolvedAddress, accountSubplebbitAddresses]);
|
2026-01-07 16:03:02 +01:00
|
|
|
|
2026-01-08 16:43:14 +01:00
|
|
|
// If specific board, check if user is mod using resolved address
|
|
|
|
|
const isModOfBoard = resolvedAddress ? accountSubplebbitAddresses.includes(resolvedAddress) : true;
|
2026-01-07 16:03:02 +01:00
|
|
|
|
|
|
|
|
// Only fetch if we have addresses to check and permissions
|
|
|
|
|
const shouldFetch = subplebbitAddresses.length > 0 && isModOfBoard;
|
|
|
|
|
|
|
|
|
|
const { feed } = useFeed({
|
|
|
|
|
subplebbitAddresses: shouldFetch ? subplebbitAddresses : [],
|
|
|
|
|
modQueue: ['pendingApproval'],
|
2026-01-10 18:11:38 +01:00
|
|
|
sortType: 'new',
|
2026-01-07 16:03:02 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!shouldFetch || subplebbitAddresses.length === 0) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-11 12:35:59 +01:00
|
|
|
return <ModQueueButtonContent feed={feed} alertThresholdHours={alertThresholdHours} boardIdentifier={boardIdentifier} isMobile={isMobile} />;
|
2026-01-07 16:03:02 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const params = useParams();
|
|
|
|
|
const { selectedBoardFilter, alertThresholdHours, setAlertThresholdHours } = useModQueueStore();
|
|
|
|
|
const { accountSubplebbits } = useAccountSubplebbits();
|
|
|
|
|
const accountSubplebbitAddresses = Object.keys(accountSubplebbits);
|
|
|
|
|
const defaultSubplebbits = useDefaultSubplebbits();
|
|
|
|
|
|
|
|
|
|
const boardIdentifier = propBoardIdentifier || params.boardIdentifier;
|
|
|
|
|
|
|
|
|
|
// Resolve boardIdentifier to address if it exists
|
|
|
|
|
const resolvedAddress = useMemo(() => {
|
|
|
|
|
if (boardIdentifier) {
|
|
|
|
|
return getSubplebbitAddress(boardIdentifier, defaultSubplebbits);
|
|
|
|
|
}
|
|
|
|
|
return undefined;
|
|
|
|
|
}, [boardIdentifier, defaultSubplebbits]);
|
|
|
|
|
|
|
|
|
|
// Get metadata for filter dropdown
|
|
|
|
|
const subplebbitsWithMetadata = useAccountSubplebbitsWithMetadata();
|
|
|
|
|
|
|
|
|
|
const subplebbitAddresses = useMemo(() => {
|
|
|
|
|
if (resolvedAddress) {
|
|
|
|
|
return [resolvedAddress];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (selectedBoardFilter) {
|
|
|
|
|
return [selectedBoardFilter];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return accountSubplebbitAddresses;
|
|
|
|
|
}, [resolvedAddress, selectedBoardFilter, accountSubplebbitAddresses]);
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
2026-01-11 15:10:13 +01:00
|
|
|
// Check if any feed items need the blinking animation (awaiting approval + over threshold)
|
|
|
|
|
// This is a heuristic based on raw feed data - real-time edits might change this,
|
|
|
|
|
// but it's good enough to avoid running the interval when nothing needs to blink
|
|
|
|
|
const hasBlinkingItems = useMemo(() => {
|
|
|
|
|
const thresholdSeconds = alertThresholdHours * 3600;
|
|
|
|
|
const now = Date.now() / 1000;
|
|
|
|
|
return feed.some((comment) => {
|
|
|
|
|
const isAwaiting = comment.approved !== true && comment.removed !== true;
|
|
|
|
|
const timeWaiting = now - comment.timestamp;
|
|
|
|
|
return isAwaiting && timeWaiting > thresholdSeconds;
|
|
|
|
|
});
|
|
|
|
|
}, [feed, alertThresholdHours]);
|
|
|
|
|
|
2026-01-09 17:20:59 +01:00
|
|
|
// Synchronize blinking animation across all rows
|
|
|
|
|
// Set a CSS variable with the current animation phase so all elements start from the same point
|
|
|
|
|
// Update frequently to ensure elements rendered at different times stay synchronized
|
2026-01-11 15:10:13 +01:00
|
|
|
// Only run when there are items that actually need the blinking animation
|
2026-01-09 17:20:59 +01:00
|
|
|
useEffect(() => {
|
2026-01-11 15:10:13 +01:00
|
|
|
if (!hasBlinkingItems) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-09 17:20:59 +01:00
|
|
|
const ANIMATION_DURATION = 2000; // 2 seconds
|
|
|
|
|
const UPDATE_INTERVAL = 100; // Update every 100ms for smooth synchronization
|
|
|
|
|
|
|
|
|
|
const updateAnimationPhase = () => {
|
|
|
|
|
// Calculate current phase in the animation cycle (0 to 2 seconds)
|
|
|
|
|
const phase = (Date.now() % ANIMATION_DURATION) / 1000;
|
|
|
|
|
document.documentElement.style.setProperty('--mod-queue-blink-phase', `${phase}s`);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Update immediately and then at regular intervals
|
|
|
|
|
updateAnimationPhase();
|
|
|
|
|
const interval = setInterval(updateAnimationPhase, UPDATE_INTERVAL);
|
|
|
|
|
return () => clearInterval(interval);
|
2026-01-11 15:10:13 +01:00
|
|
|
}, [hasBlinkingItems]);
|
2026-01-09 17:20:59 +01:00
|
|
|
|
2026-01-07 16:03:02 +01:00
|
|
|
const loadingStateString = useFeedStateString(subplebbitAddresses) || t('loading');
|
|
|
|
|
const showBoardColumn = !resolvedAddress && !selectedBoardFilter;
|
|
|
|
|
|
2026-01-08 17:16:29 +01:00
|
|
|
// Memoize footer components object to preserve identity across renders (Virtuoso optimization)
|
|
|
|
|
const footerComponents = useMemo(
|
|
|
|
|
() => ({
|
|
|
|
|
Footer: () => <ModQueueFooter hasMore={hasMore} loadingStateString={loadingStateString} />,
|
|
|
|
|
}),
|
|
|
|
|
[hasMore, loadingStateString],
|
|
|
|
|
);
|
2026-01-07 16:03:02 +01:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={styles.container}>
|
|
|
|
|
<div className={styles.header}>
|
|
|
|
|
<div className={styles.title}>{t('moderation_queue')}</div>
|
|
|
|
|
<div className={styles.alertThresholdSetting}>
|
|
|
|
|
<label>
|
|
|
|
|
{t('mod_queue_alert_threshold')}:
|
|
|
|
|
<input
|
|
|
|
|
type='number'
|
|
|
|
|
min='1'
|
|
|
|
|
value={alertThresholdHours}
|
|
|
|
|
onChange={(e) => setAlertThresholdHours(Number(e.target.value))}
|
|
|
|
|
className={styles.alertThresholdInput}
|
|
|
|
|
/>{' '}
|
|
|
|
|
{t('hours')}
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{!resolvedAddress && <ModQueueBoardFilter subplebbits={subplebbitsWithMetadata} />}
|
|
|
|
|
|
2026-01-10 18:11:38 +01:00
|
|
|
{feed.length === 0 && !hasMore ? (
|
2026-01-07 16:03:02 +01:00
|
|
|
<div className={styles.empty}>{t('queue_is_empty')}</div>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className={styles.tableHeader}>
|
2026-01-11 12:28:21 +01:00
|
|
|
<div className={styles.numberHeader}>No.</div>
|
2026-01-07 16:03:02 +01:00
|
|
|
{showBoardColumn && <div className={styles.boardHeader}>{t('board')}</div>}
|
|
|
|
|
<div className={styles.excerptHeader}>{t('excerpt')}</div>
|
2026-01-11 12:28:21 +01:00
|
|
|
<div className={styles.timeHeader}>{t('submitted')}</div>
|
2026-01-07 16:03:02 +01:00
|
|
|
<div className={styles.actionsHeader}>{t('actions')}</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Virtuoso
|
|
|
|
|
useWindowScroll
|
2026-01-10 18:11:38 +01:00
|
|
|
data={feed}
|
|
|
|
|
totalCount={feed.length}
|
2026-01-07 16:03:02 +01:00
|
|
|
endReached={loadMore}
|
|
|
|
|
itemContent={(index, comment) => <ModQueueRow key={comment.cid} comment={comment} showBoardColumn={showBoardColumn} />}
|
2026-01-08 17:16:29 +01:00
|
|
|
components={footerComponents}
|
2026-01-07 16:03:02 +01:00
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default ModQueueView;
|