2026-02-27 17:38:36 +08:00
|
|
|
import React, { useMemo, useState, useEffect, useCallback, memo } from 'react';
|
2026-01-07 16:03:02 +01:00
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
|
import { useParams, Link } from 'react-router-dom';
|
2026-03-13 16:37:57 +08:00
|
|
|
import { useFeed, Comment, usePublishCommentModeration, useEditedComment, useCommunity, useAccount } from '@bitsocialnet/bitsocial-react-hooks';
|
2026-01-07 16:03:02 +01:00
|
|
|
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';
|
2026-01-12 17:59:03 +01:00
|
|
|
import ErrorDisplay from '../../components/error-display/error-display';
|
2026-01-07 16:03:02 +01:00
|
|
|
import { useFeedStateString } from '../../hooks/use-state-string';
|
2026-04-17 10:48:27 +07:00
|
|
|
import { getCommunityAddress, getBoardPath, extractDirectoryFromTitle, areSameBoardAddress } from '../../lib/utils/route-utils';
|
2026-01-28 16:51:27 +08:00
|
|
|
import { useDirectories, DirectoryCommunity } from '../../hooks/use-directories';
|
2026-02-27 17:38:36 +08:00
|
|
|
import getShortAddress from '../../lib/get-short-address';
|
|
|
|
|
import { BOARD_CODE_GROUPS } from '../../constants/board-codes';
|
2026-01-07 16:03:02 +01:00
|
|
|
import { getHasThumbnail, getCommentMediaInfo } from '../../lib/utils/media-utils';
|
2026-03-09 19:05:29 +08:00
|
|
|
import {
|
|
|
|
|
approvePendingCommentModeration,
|
|
|
|
|
isPendingApprovalAwaiting,
|
|
|
|
|
isPendingApprovalRejected,
|
|
|
|
|
rejectPendingCommentModeration,
|
|
|
|
|
} from '../../lib/utils/pending-approval-moderation';
|
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';
|
2026-04-17 10:48:27 +07:00
|
|
|
import { getCommentCommunityAddress } from '../../lib/utils/comment-utils';
|
2026-01-11 16:25:09 +01:00
|
|
|
import Tooltip from '../../components/tooltip';
|
2026-03-13 13:25:10 +08:00
|
|
|
import { useAccountCommunityAddresses } from '../../hooks/use-account-community-addresses';
|
2026-04-15 12:51:21 +07:00
|
|
|
import { useCommunityIdentifier, useCommunityIdentifiers } from '../../hooks/use-community-identifiers';
|
2026-01-12 20:08:56 +01:00
|
|
|
import useIsMobile from '../../hooks/use-is-mobile';
|
2026-02-18 15:31:01 +08:00
|
|
|
import { useCurrentTime } from '../../hooks/use-current-time';
|
2026-01-25 16:29:15 +08:00
|
|
|
import { Post } from '../post/post';
|
2026-03-13 16:37:57 +08:00
|
|
|
import { canAccessBoardModQueue, hasModQueueAccessRole } from '../../lib/utils/mod-access';
|
2026-02-18 15:31:01 +08:00
|
|
|
import capitalize from 'lodash/capitalize';
|
|
|
|
|
import lowerCase from 'lodash/lowerCase';
|
2026-03-07 18:50:24 +08:00
|
|
|
import { PageFooterDesktop, PageFooterMobile, StyleOnlyFooterFirstRow } from '../../components/footer';
|
|
|
|
|
import footerStyles from '../../components/footer/footer.module.css';
|
2026-01-08 17:36:47 +01:00
|
|
|
|
|
|
|
|
const { addChallenge } = useChallengesStore.getState();
|
2026-01-07 16:03:02 +01:00
|
|
|
|
2026-03-07 18:50:24 +08:00
|
|
|
/** 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;
|
|
|
|
|
};
|
|
|
|
|
|
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;
|
2026-03-13 13:25:10 +08:00
|
|
|
communityAddresses: string[];
|
2026-01-08 17:16:29 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Defined outside ModQueueView to preserve component identity across renders (Virtuoso optimization)
|
2026-01-11 15:39:46 +01:00
|
|
|
// 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
|
2026-03-13 13:25:10 +08:00
|
|
|
const ModQueueFooter = ({ hasMore, communityAddresses }: ModQueueFooterProps) => {
|
2026-01-11 15:39:46 +01:00
|
|
|
const { t } = useTranslation();
|
2026-03-13 13:25:10 +08:00
|
|
|
const loadingStateString = useFeedStateString(communityAddresses) || t('loading');
|
2026-01-11 15:39:46 +01:00
|
|
|
|
2026-01-08 17:16:29 +01:00
|
|
|
return hasMore ? (
|
2026-01-12 16:45:24 +01:00
|
|
|
<div className={styles.footer}>
|
2026-01-08 17:16:29 +01:00
|
|
|
<LoadingEllipsis string={loadingStateString} />
|
|
|
|
|
</div>
|
|
|
|
|
) : null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-07 16:03:02 +01:00
|
|
|
interface ModQueueRowProps {
|
|
|
|
|
comment: Comment;
|
2026-01-11 16:19:51 +01:00
|
|
|
isOdd?: boolean;
|
2026-02-27 17:38:36 +08:00
|
|
|
showBoard?: boolean;
|
2026-03-07 18:50:24 +08:00
|
|
|
/** Board path for URLs (directory code or full address) */
|
2026-02-27 17:38:36 +08:00
|
|
|
boardPath: string | undefined;
|
2026-03-07 18:50:24 +08:00
|
|
|
/** Board path for display (shortened when long IPNS key with no TLD) */
|
|
|
|
|
boardDisplayPath: string | undefined;
|
2026-01-07 16:03:02 +01:00
|
|
|
}
|
|
|
|
|
|
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-25 16:29:15 +08:00
|
|
|
interface ModQueueActionState {
|
|
|
|
|
status: 'approved' | 'rejected' | 'failed' | null;
|
|
|
|
|
errorMessage?: string;
|
|
|
|
|
isPublishing: boolean;
|
|
|
|
|
handleApprove: () => Promise<void>;
|
|
|
|
|
handleReject: () => Promise<void>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-24 15:15:44 +08:00
|
|
|
interface ModQueueActionsProps {
|
|
|
|
|
status: 'approved' | 'rejected' | 'failed' | null;
|
|
|
|
|
errorMessage?: string;
|
|
|
|
|
isPublishing: boolean;
|
|
|
|
|
handleApprove: () => Promise<void>;
|
|
|
|
|
handleReject: () => Promise<void>;
|
|
|
|
|
variant: 'row' | 'card';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const ModQueueActions = ({ status, errorMessage, isPublishing, handleApprove, handleReject, variant }: ModQueueActionsProps) => {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
|
|
|
|
|
if (status === 'approved') {
|
|
|
|
|
const content = <span className={`${styles.button} ${styles.approve}`}>{t('approved')}</span>;
|
|
|
|
|
return variant === 'card' ? <div className={styles.cardActions}>{content}</div> : content;
|
|
|
|
|
}
|
|
|
|
|
if (status === 'rejected') {
|
|
|
|
|
const content = <span className={`${styles.button} ${styles.reject}`}>{t('rejected')}</span>;
|
|
|
|
|
return variant === 'card' ? <div className={styles.cardActions}>{content}</div> : content;
|
|
|
|
|
}
|
|
|
|
|
if (status === 'failed') {
|
|
|
|
|
const content = (
|
|
|
|
|
<span className={`${styles.button} ${styles.reject}`}>
|
|
|
|
|
{t('failed')}
|
|
|
|
|
{errorMessage ? `: ${errorMessage}` : ''}
|
|
|
|
|
</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}>
|
2026-03-07 18:50:24 +08:00
|
|
|
<button className={`button ${styles.cardApproveButton}`} onClick={handleApprove} disabled={isPublishing}>
|
2026-02-24 15:15:44 +08:00
|
|
|
{t('approve')}
|
|
|
|
|
</button>
|
2026-03-07 18:50:24 +08:00
|
|
|
<button className={`button ${styles.cardRejectButton}`} onClick={handleReject} disabled={isPublishing}>
|
2026-02-24 15:15:44 +08:00
|
|
|
{t('reject')}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return buttons;
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-25 16:29:15 +08:00
|
|
|
const useModQueueActions = (comment: Comment): ModQueueActionState => {
|
2026-01-07 16:03:02 +01:00
|
|
|
const { t } = useTranslation();
|
2026-03-13 13:25:10 +08:00
|
|
|
const { cid, approved, removed, pendingApproval } = comment || {};
|
2026-04-17 10:48:27 +07:00
|
|
|
const communityAddress = getCommentCommunityAddress(comment);
|
2026-01-08 17:36:47 +01:00
|
|
|
const [initiatedAction, setInitiatedAction] = useState<ModerationAction>(null);
|
2026-01-07 16:03:02 +01:00
|
|
|
|
2026-01-08 17:59:41 +01:00
|
|
|
const alreadyApproved = approved === true;
|
2026-03-09 19:05:29 +08:00
|
|
|
const alreadyRejected = isPendingApprovalRejected({ approved, removed, pendingApproval });
|
2026-01-07 16:03:02 +01:00
|
|
|
|
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,
|
2026-03-13 13:25:10 +08:00
|
|
|
communityAddress,
|
2026-03-09 19:05:29 +08:00
|
|
|
commentModeration: approvePendingCommentModeration,
|
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,
|
2026-03-13 13:25:10 +08:00
|
|
|
communityAddress,
|
2026-03-09 19:05:29 +08:00
|
|
|
commentModeration: rejectPendingCommentModeration,
|
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
|
|
|
});
|
|
|
|
|
|
2026-01-25 16:29:15 +08:00
|
|
|
const handleApprove = useCallback(async () => {
|
2026-01-12 20:11:54 +01:00
|
|
|
const confirm = window.confirm(t('double_confirm'));
|
|
|
|
|
if (!confirm) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-01-25 16:29:15 +08:00
|
|
|
}, [approve, t]);
|
2026-01-07 16:03:02 +01:00
|
|
|
|
2026-01-25 16:29:15 +08:00
|
|
|
const handleReject = useCallback(async () => {
|
2026-01-12 20:11:54 +01:00
|
|
|
const confirm = window.confirm(t('double_confirm'));
|
|
|
|
|
if (!confirm) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
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-25 16:29:15 +08:00
|
|
|
}, [reject, t]);
|
2026-01-07 16:03:02 +01:00
|
|
|
|
2026-01-08 17:36:47 +01:00
|
|
|
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-25 16:29:15 +08:00
|
|
|
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 };
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-07 18:50:24 +08:00
|
|
|
const ModQueueRow = memo(({ comment, isOdd = false, showBoard = false, boardPath, boardDisplayPath }: ModQueueRowProps) => {
|
2026-01-25 16:29:15 +08:00
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const { getAlertThresholdSeconds } = useModQueueStore();
|
|
|
|
|
const isMobile = useIsMobile();
|
2026-02-18 15:31:01 +08:00
|
|
|
const currentTime = useCurrentTime();
|
2026-01-25 16:29:15 +08:00
|
|
|
|
|
|
|
|
const { editedComment } = useEditedComment({ comment });
|
|
|
|
|
const displayComment = editedComment || comment;
|
|
|
|
|
|
2026-04-17 10:48:27 +07:00
|
|
|
const { content, title, timestamp, cid, threadCid, link, thumbnailUrl, linkWidth, linkHeight, number, parentCid } = displayComment;
|
2026-01-25 16:29:15 +08:00
|
|
|
|
2026-02-18 15:31:01 +08:00
|
|
|
const timeWaiting = currentTime - timestamp;
|
2026-01-25 16:29:15 +08:00
|
|
|
const alertThresholdSeconds = getAlertThresholdSeconds();
|
|
|
|
|
const isOverThreshold = timeWaiting > alertThresholdSeconds;
|
|
|
|
|
|
|
|
|
|
// Only show alert animation for comments awaiting approval (not approved or rejected)
|
2026-03-09 19:05:29 +08:00
|
|
|
const isAwaitingApproval = isPendingApprovalAwaiting(displayComment);
|
2026-01-25 16:29:15 +08:00
|
|
|
|
|
|
|
|
const { status, errorMessage, isPublishing, handleApprove, handleReject } = useModQueueActions(displayComment);
|
2026-01-11 17:44:56 +01:00
|
|
|
const hasTitle = title && title.trim().length > 0;
|
|
|
|
|
const hasContent = content && content.trim().length > 0;
|
|
|
|
|
const hasLink = link && link.length > 0;
|
2026-01-26 16:31:15 +08:00
|
|
|
const isReply = !!parentCid;
|
|
|
|
|
const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
|
|
|
|
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
2026-03-07 18:50:24 +08:00
|
|
|
const rawExcerpt = (
|
2026-01-26 16:31:15 +08:00
|
|
|
(hasTitle && hasContent ? `${title}: ${content}` : null) ||
|
2026-01-11 17:44:56 +01:00
|
|
|
(hasTitle ? title : null) ||
|
|
|
|
|
(hasContent ? content : null) ||
|
|
|
|
|
(hasLink ? link : null) ||
|
2026-01-26 16:31:15 +08:00
|
|
|
(hasThumbnail ? t('image') : null) ||
|
2026-03-07 18:50:24 +08:00
|
|
|
t('no_content')
|
|
|
|
|
).trim();
|
2026-01-12 20:08:56 +01:00
|
|
|
// Only truncate excerpt on desktop, allow wrapping on mobile
|
|
|
|
|
const excerpt = !isMobile && 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-03-07 16:02:19 +08:00
|
|
|
const modQueueUrl = boardPath ? `/${boardPath}/mod/queue` : undefined;
|
2026-02-27 17:38:36 +08:00
|
|
|
|
2026-01-07 16:03:02 +01:00
|
|
|
return (
|
2026-01-11 16:19:51 +01:00
|
|
|
<div className={`${styles.row} ${isOdd ? styles.rowOdd : ''}`}>
|
|
|
|
|
<div className={styles.number}>{number ?? 'N/A'}</div>
|
2026-03-07 18:50:24 +08:00
|
|
|
{showBoard && (
|
|
|
|
|
<div className={styles.board}>{modQueueUrl ? <Link to={modQueueUrl}>/{boardDisplayPath ?? '—'}/</Link> : <span>/{boardDisplayPath ?? '—'}/</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}>
|
2026-01-12 20:08:56 +01:00
|
|
|
{isMobile ? (
|
|
|
|
|
// On mobile, show shorter time ago format without tooltip
|
|
|
|
|
isAwaitingApproval && isOverThreshold ? (
|
2026-01-13 15:16:30 +01:00
|
|
|
<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>
|
2026-01-12 20:08:56 +01:00
|
|
|
) : (
|
|
|
|
|
<span>{getFormattedTimeAgo(timestamp)}</span>
|
|
|
|
|
)
|
|
|
|
|
) : // On desktop, show full date with tooltip
|
|
|
|
|
isAwaitingApproval && isOverThreshold ? (
|
2026-01-11 12:28:21 +01:00
|
|
|
<>
|
2026-02-24 15:15:44 +08:00
|
|
|
<Tooltip content={getFormattedTimeAgo(timestamp)}>
|
|
|
|
|
<span>{getFormattedDate(timestamp)}</span>
|
|
|
|
|
</Tooltip>
|
2026-01-12 20:08:56 +01:00
|
|
|
<span className={styles.alertWrapper}>
|
|
|
|
|
{' '}
|
|
|
|
|
(<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
|
|
|
|
|
</span>
|
2026-01-11 12:28:21 +01:00
|
|
|
</>
|
|
|
|
|
) : (
|
2026-02-24 15:15:44 +08:00
|
|
|
<Tooltip content={getFormattedTimeAgo(timestamp)}>
|
|
|
|
|
<span>{getFormattedDate(timestamp)}</span>
|
|
|
|
|
</Tooltip>
|
2026-01-11 12:28:21 +01:00
|
|
|
)}
|
2026-01-10 18:11:38 +01:00
|
|
|
</div>
|
2026-02-11 19:01:09 +08:00
|
|
|
<div className={styles.type}>{isReply ? capitalize(t('reply')) : capitalize(t('post'))}</div>
|
2026-01-26 16:31:15 +08:00
|
|
|
<div className={styles.image}>{hasThumbnail ? t('yes') : t('no')}</div>
|
2026-02-24 15:15:44 +08:00
|
|
|
<div className={styles.actions}>
|
|
|
|
|
<ModQueueActions
|
|
|
|
|
status={status}
|
|
|
|
|
errorMessage={errorMessage}
|
|
|
|
|
isPublishing={isPublishing}
|
|
|
|
|
handleApprove={handleApprove}
|
|
|
|
|
handleReject={handleReject}
|
|
|
|
|
variant='row'
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-01-07 16:03:02 +01:00
|
|
|
</div>
|
|
|
|
|
);
|
2026-02-27 17:38:36 +08:00
|
|
|
});
|
|
|
|
|
ModQueueRow.displayName = 'ModQueueRow';
|
2026-01-07 16:03:02 +01:00
|
|
|
|
2026-01-25 16:29:15 +08:00
|
|
|
interface ModQueueCardProps {
|
|
|
|
|
comment: Comment;
|
2026-02-27 17:38:36 +08:00
|
|
|
showBoard?: boolean;
|
2026-03-07 18:50:24 +08:00
|
|
|
/** Board path for URLs (directory code or full address) */
|
2026-02-27 17:38:36 +08:00
|
|
|
boardPath: string | undefined;
|
2026-03-07 18:50:24 +08:00
|
|
|
/** Board path for display (shortened when long IPNS key with no TLD) */
|
|
|
|
|
boardDisplayPath: string | undefined;
|
2026-01-25 16:29:15 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-07 18:50:24 +08:00
|
|
|
const ModQueueCard = memo(({ comment, showBoard = false, boardPath, boardDisplayPath }: ModQueueCardProps) => {
|
2026-01-25 16:29:15 +08:00
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const { getAlertThresholdSeconds } = useModQueueStore();
|
2026-02-18 15:31:01 +08:00
|
|
|
const currentTime = useCurrentTime();
|
2026-01-25 16:29:15 +08:00
|
|
|
|
|
|
|
|
const { editedComment } = useEditedComment({ comment });
|
|
|
|
|
const displayComment = editedComment || comment;
|
|
|
|
|
|
2026-04-17 10:48:27 +07:00
|
|
|
const { content, title, timestamp, cid, threadCid, link, thumbnailUrl, linkWidth, linkHeight, number, parentCid } = displayComment;
|
2026-01-25 16:29:15 +08:00
|
|
|
|
2026-02-18 15:31:01 +08:00
|
|
|
const timeWaiting = currentTime - timestamp;
|
2026-01-25 16:29:15 +08:00
|
|
|
const alertThresholdSeconds = getAlertThresholdSeconds();
|
|
|
|
|
const isOverThreshold = timeWaiting > alertThresholdSeconds;
|
2026-03-09 19:05:29 +08:00
|
|
|
const isAwaitingApproval = isPendingApprovalAwaiting(displayComment);
|
2026-01-25 16:29:15 +08:00
|
|
|
|
|
|
|
|
const { status, errorMessage, isPublishing, handleApprove, handleReject } = useModQueueActions(displayComment);
|
|
|
|
|
const hasTitle = title && title.trim().length > 0;
|
|
|
|
|
const hasContent = content && content.trim().length > 0;
|
|
|
|
|
const hasLink = link && link.length > 0;
|
2026-01-26 16:31:15 +08:00
|
|
|
const isReply = !!parentCid;
|
|
|
|
|
const commentMediaInfo = getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
|
|
|
|
|
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
|
2026-03-07 18:50:24 +08:00
|
|
|
const rawExcerpt = (
|
2026-01-26 16:31:15 +08:00
|
|
|
(hasTitle && hasContent ? `${title}: ${content}` : null) ||
|
2026-01-25 16:29:15 +08:00
|
|
|
(hasTitle ? title : null) ||
|
|
|
|
|
(hasContent ? content : null) ||
|
|
|
|
|
(hasLink ? link : null) ||
|
2026-01-26 16:31:15 +08:00
|
|
|
(hasThumbnail ? t('image') : null) ||
|
2026-03-07 18:50:24 +08:00
|
|
|
t('no_content')
|
|
|
|
|
).trim();
|
2026-01-25 16:29:15 +08:00
|
|
|
const excerpt = rawExcerpt.length > 140 ? rawExcerpt.slice(0, 137) + '...' : rawExcerpt;
|
|
|
|
|
const threadTargetCid = threadCid || cid;
|
|
|
|
|
const postUrl = boardPath && threadTargetCid ? `/${boardPath}/thread/${threadTargetCid}` : undefined;
|
|
|
|
|
|
2026-03-07 16:02:19 +08:00
|
|
|
const modQueueUrl = boardPath ? `/${boardPath}/mod/queue` : undefined;
|
2026-02-27 17:38:36 +08:00
|
|
|
|
2026-01-25 16:29:15 +08:00
|
|
|
return (
|
|
|
|
|
<div className={styles.mobileCard}>
|
|
|
|
|
<div className={styles.cardHeader}>
|
2026-03-07 18:50:24 +08:00
|
|
|
<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>
|
2026-01-25 16:29:15 +08:00
|
|
|
<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} title={excerpt}>
|
|
|
|
|
{excerpt}
|
|
|
|
|
</Link>
|
|
|
|
|
) : (
|
|
|
|
|
<span title={excerpt}>{excerpt}</span>
|
2026-01-26 16:31:15 +08:00
|
|
|
)}{' '}
|
2026-02-11 19:01:09 +08:00
|
|
|
/ {t('type')}: {isReply ? t('reply') : t('post')} / {capitalize(t('image'))}: {hasThumbnail ? lowerCase(t('yes')) : lowerCase(t('no'))}
|
2026-01-25 16:29:15 +08:00
|
|
|
</div>
|
2026-02-24 15:15:44 +08:00
|
|
|
<ModQueueActions status={status} errorMessage={errorMessage} isPublishing={isPublishing} handleApprove={handleApprove} handleReject={handleReject} variant='card' />
|
2026-01-25 16:29:15 +08:00
|
|
|
</div>
|
|
|
|
|
);
|
2026-02-27 17:38:36 +08:00
|
|
|
});
|
|
|
|
|
ModQueueCard.displayName = 'ModQueueCard';
|
2026-01-25 16:29:15 +08:00
|
|
|
|
|
|
|
|
const ModQueueFeedPost = ({ comment }: { comment: Comment }) => {
|
|
|
|
|
const { editedComment } = useEditedComment({ comment });
|
|
|
|
|
const displayComment = editedComment || comment;
|
|
|
|
|
const { status, errorMessage, isPublishing, handleApprove, handleReject } = useModQueueActions(displayComment);
|
|
|
|
|
|
|
|
|
|
return (
|
2026-03-07 20:33:08 +08:00
|
|
|
<Post
|
|
|
|
|
post={displayComment}
|
|
|
|
|
showAllReplies={false}
|
|
|
|
|
showReplies={false}
|
|
|
|
|
isModQueue={true}
|
|
|
|
|
modQueueStatus={status}
|
|
|
|
|
modQueueError={errorMessage}
|
|
|
|
|
isPublishing={isPublishing}
|
|
|
|
|
onApprove={handleApprove}
|
|
|
|
|
onReject={handleReject}
|
|
|
|
|
/>
|
2026-01-25 16:29:15 +08:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-27 17:38:36 +08:00
|
|
|
interface ModQueueBoardSummaryProps {
|
|
|
|
|
feed: Comment[];
|
|
|
|
|
directories: DirectoryCommunity[];
|
2026-03-13 13:25:10 +08:00
|
|
|
accountCommunityAddresses: string[];
|
2026-01-07 16:03:02 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-27 17:38:36 +08:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-03-13 13:25:10 +08:00
|
|
|
const ModQueueBoardSummary = ({ feed, directories, accountCommunityAddresses }: ModQueueBoardSummaryProps) => {
|
2026-01-07 16:03:02 +01:00
|
|
|
const { t } = useTranslation();
|
2026-02-27 17:38:36 +08:00
|
|
|
const { selectedBoardFilter, setSelectedBoardFilter, getAlertThresholdSeconds } = useModQueueStore();
|
|
|
|
|
const currentTime = useCurrentTime();
|
|
|
|
|
const alertThresholdSeconds = getAlertThresholdSeconds();
|
2026-03-13 13:25:10 +08:00
|
|
|
const modAddressSet = useMemo(() => new Set(accountCommunityAddresses), [accountCommunityAddresses]);
|
2026-01-07 16:03:02 +01:00
|
|
|
|
2026-02-27 17:38:36 +08:00
|
|
|
const boardCounts = useMemo(() => {
|
|
|
|
|
const counts = new Map<string, { normal: number; urgent: number }>();
|
2026-03-13 13:25:10 +08:00
|
|
|
for (const address of accountCommunityAddresses) {
|
2026-02-27 17:38:36 +08:00
|
|
|
counts.set(address, { normal: 0, urgent: 0 });
|
2026-01-11 17:40:50 +01:00
|
|
|
}
|
2026-02-27 17:38:36 +08:00
|
|
|
for (const item of feed) {
|
2026-03-13 13:25:10 +08:00
|
|
|
const addr = getCommentCommunityAddress(item);
|
2026-02-27 17:38:36 +08:00
|
|
|
if (!addr) continue;
|
|
|
|
|
const entry = counts.get(addr);
|
|
|
|
|
if (!entry) continue;
|
2026-03-09 19:05:29 +08:00
|
|
|
const isAwaiting = isPendingApprovalAwaiting(item);
|
2026-02-27 17:38:36 +08:00
|
|
|
if (!isAwaiting) continue;
|
|
|
|
|
const timeWaiting = currentTime - (item.timestamp ?? 0);
|
|
|
|
|
const isUrgent = timeWaiting > alertThresholdSeconds;
|
|
|
|
|
if (isUrgent) entry.urgent++;
|
|
|
|
|
else entry.normal++;
|
|
|
|
|
}
|
|
|
|
|
return counts;
|
2026-03-13 13:25:10 +08:00
|
|
|
}, [feed, accountCommunityAddresses, currentTime, alertThresholdSeconds]);
|
2026-01-11 17:40:50 +01:00
|
|
|
|
2026-02-27 17:38:36 +08:00
|
|
|
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)
|
2026-03-13 13:25:10 +08:00
|
|
|
for (const addr of accountCommunityAddresses) {
|
2026-02-27 17:38:36 +08:00
|
|
|
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)
|
2026-03-13 13:25:10 +08:00
|
|
|
for (const addr of accountCommunityAddresses) {
|
2026-02-27 17:38:36 +08:00
|
|
|
if (!seen.has(addr)) {
|
|
|
|
|
ordered.push(addr);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ordered;
|
2026-03-13 13:25:10 +08:00
|
|
|
}, [accountCommunityAddresses, directories, modAddressSet]);
|
2026-02-27 17:38:36 +08:00
|
|
|
|
|
|
|
|
const handleSelectAll = useCallback(() => setSelectedBoardFilter(null), [setSelectedBoardFilter]);
|
|
|
|
|
const handleSelectBoard = useCallback((address: string) => setSelectedBoardFilter(address), [setSelectedBoardFilter]);
|
|
|
|
|
|
2026-03-13 13:25:10 +08:00
|
|
|
if (accountCommunityAddresses.length === 0) {
|
2026-02-18 15:31:01 +08:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 17:38:36 +08:00
|
|
|
const renderCount = (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>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-07 16:03:02 +01:00
|
|
|
return (
|
2026-02-27 17:38:36 +08:00
|
|
|
<span className={styles.boardSummary}>
|
|
|
|
|
<button type='button' className={`${styles.boardSummaryLink} ${!selectedBoardFilter ? styles.boardSummaryLinkSelected : ''}`} onClick={handleSelectAll}>
|
|
|
|
|
{t('all')}
|
|
|
|
|
{totalNormal + totalUrgent > 0 && <> {renderCount(totalNormal, 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 && <> {renderCount(normal, urgent)}</>}
|
|
|
|
|
</button>
|
|
|
|
|
</React.Fragment>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</span>
|
2026-01-07 16:03:02 +01:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface ModQueueButtonProps {
|
|
|
|
|
boardIdentifier?: string;
|
|
|
|
|
isMobile?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-11 12:35:59 +01:00
|
|
|
interface ModQueueCountItemProps {
|
|
|
|
|
comment: Comment;
|
2026-01-11 15:20:48 +01:00
|
|
|
alertThresholdSeconds: number;
|
2026-01-11 12:35:59 +01:00
|
|
|
onStatusChange: (cid: string, status: { awaiting: boolean; urgent: boolean }) => void;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-11 15:20:48 +01:00
|
|
|
const ModQueueCountItem = ({ comment, alertThresholdSeconds, onStatusChange }: ModQueueCountItemProps) => {
|
2026-01-11 12:35:59 +01:00
|
|
|
const { editedComment } = useEditedComment({ comment });
|
|
|
|
|
const displayComment = editedComment || comment;
|
2026-02-18 15:31:01 +08:00
|
|
|
const currentTime = useCurrentTime();
|
2026-01-11 12:35:59 +01:00
|
|
|
|
2026-03-09 19:05:29 +08:00
|
|
|
const { cid, timestamp } = displayComment;
|
|
|
|
|
const isAwaiting = isPendingApprovalAwaiting(displayComment);
|
2026-02-18 15:31:01 +08:00
|
|
|
const timeWaiting = currentTime - timestamp;
|
2026-01-11 15:20:48 +01:00
|
|
|
const isUrgent = isAwaiting && timeWaiting > alertThresholdSeconds;
|
2026-01-11 12:35:59 +01:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
onStatusChange(cid, { awaiting: isAwaiting, urgent: isUrgent });
|
|
|
|
|
}, [cid, isAwaiting, isUrgent, onStatusChange]);
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface ModQueueButtonContentProps {
|
|
|
|
|
feed: Comment[];
|
2026-01-11 15:20:48 +01:00
|
|
|
alertThresholdSeconds: number;
|
2026-01-11 12:35:59 +01:00
|
|
|
boardIdentifier?: string;
|
|
|
|
|
isMobile?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-11 15:20:48 +01:00
|
|
|
const ModQueueButtonContent = ({ feed, alertThresholdSeconds, 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;
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-01-12 16:52:15 +01:00
|
|
|
// 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]);
|
|
|
|
|
|
2026-01-11 12:35:59 +01:00
|
|
|
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;
|
2026-03-07 16:02:19 +08:00
|
|
|
const to = boardIdentifier ? `/${boardIdentifier}/mod/queue` : '/mod/queue';
|
2026-01-11 12:35:59 +01:00
|
|
|
|
|
|
|
|
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 (
|
|
|
|
|
<>
|
|
|
|
|
{feed.map((item) => (
|
2026-01-11 15:20:48 +01:00
|
|
|
<ModQueueCountItem key={item.cid} comment={item} alertThresholdSeconds={alertThresholdSeconds} onStatusChange={handleStatusChange} />
|
2026-01-11 12:35:59 +01:00
|
|
|
))}
|
|
|
|
|
{isMobile ? buttonContent : <>[{buttonContent}]</>}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => {
|
2026-01-11 15:20:48 +01:00
|
|
|
const { getAlertThresholdSeconds } = useModQueueStore();
|
2026-01-11 15:39:46 +01:00
|
|
|
|
2026-03-13 16:37:57 +08:00
|
|
|
const account = useAccount();
|
|
|
|
|
const accountAddress = account?.author?.address;
|
2026-03-13 13:25:10 +08:00
|
|
|
const accountCommunityAddresses = useAccountCommunityAddresses();
|
2026-01-11 15:39:46 +01:00
|
|
|
|
2026-01-28 16:51:27 +08:00
|
|
|
const directories = useDirectories();
|
2026-01-08 16:43:14 +01:00
|
|
|
|
|
|
|
|
const resolvedAddress = useMemo(() => {
|
|
|
|
|
if (boardIdentifier) {
|
2026-04-17 10:48:27 +07:00
|
|
|
return getCommunityAddress(boardIdentifier, directories);
|
2026-01-08 16:43:14 +01:00
|
|
|
}
|
|
|
|
|
return undefined;
|
2026-01-28 16:51:27 +08:00
|
|
|
}, [boardIdentifier, directories]);
|
2026-04-15 12:51:21 +07:00
|
|
|
const resolvedCommunity = useCommunityIdentifier(resolvedAddress);
|
|
|
|
|
const community = useCommunity(resolvedCommunity ? { community: resolvedCommunity } : undefined);
|
2026-01-07 16:03:02 +01:00
|
|
|
|
2026-03-13 13:25:10 +08:00
|
|
|
const communityAddresses = useMemo(() => {
|
2026-01-08 16:43:14 +01:00
|
|
|
if (resolvedAddress) {
|
|
|
|
|
return [resolvedAddress];
|
2026-01-07 16:03:02 +01:00
|
|
|
}
|
2026-03-13 13:25:10 +08:00
|
|
|
return accountCommunityAddresses;
|
|
|
|
|
}, [resolvedAddress, accountCommunityAddresses]);
|
2026-01-07 16:03:02 +01:00
|
|
|
|
2026-03-13 16:37:57 +08:00
|
|
|
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';
|
2026-01-07 16:03:02 +01:00
|
|
|
|
|
|
|
|
// Only fetch if we have addresses to check and permissions
|
2026-03-13 16:37:57 +08:00
|
|
|
const shouldFetch = !isBoardAccessLoading && communityAddresses.length > 0 && hasBoardAccess;
|
2026-01-07 16:03:02 +01:00
|
|
|
|
2026-03-13 13:25:10 +08:00
|
|
|
const feedAddresses = shouldFetch ? communityAddresses : [];
|
2026-04-15 12:51:21 +07:00
|
|
|
const feedCommunities = useCommunityIdentifiers(feedAddresses);
|
2026-02-26 16:42:36 +08:00
|
|
|
const feedOptions = useMemo(
|
|
|
|
|
() => ({
|
2026-04-15 12:51:21 +07:00
|
|
|
communities: feedCommunities,
|
2026-02-26 16:42:36 +08:00
|
|
|
modQueue: ['pendingApproval'],
|
|
|
|
|
sortType: 'new' as const,
|
|
|
|
|
postsPerPage: 200,
|
|
|
|
|
}),
|
2026-04-15 12:51:21 +07:00
|
|
|
[feedCommunities],
|
2026-02-26 16:42:36 +08:00
|
|
|
);
|
|
|
|
|
const { feed } = useFeed(feedOptions);
|
2026-01-07 16:03:02 +01:00
|
|
|
|
2026-03-13 13:25:10 +08:00
|
|
|
if (!shouldFetch || communityAddresses.length === 0) {
|
2026-01-07 16:03:02 +01:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-11 15:20:48 +01:00
|
|
|
const alertThresholdSeconds = getAlertThresholdSeconds();
|
2026-01-11 17:23:29 +01:00
|
|
|
// Use key to reset statusMap state when switching boards (prevents stale counts from previous board)
|
2026-03-13 13:25:10 +08:00
|
|
|
const contentKey = communityAddresses.join(',');
|
2026-01-11 17:23:29 +01:00
|
|
|
return <ModQueueButtonContent key={contentKey} feed={feed} alertThresholdSeconds={alertThresholdSeconds} boardIdentifier={boardIdentifier} isMobile={isMobile} />;
|
2026-01-07 16:03:02 +01:00
|
|
|
};
|
|
|
|
|
|
2026-02-24 15:15:44 +08:00
|
|
|
const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => {
|
2026-01-07 16:03:02 +01:00
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const params = useParams();
|
2026-01-25 16:29:15 +08:00
|
|
|
const { selectedBoardFilter, viewMode } = useModQueueStore();
|
|
|
|
|
const isMobile = useIsMobile();
|
2026-01-11 15:39:46 +01:00
|
|
|
|
2026-03-13 13:25:10 +08:00
|
|
|
const accountCommunityAddresses = useAccountCommunityAddresses();
|
2026-01-11 15:39:46 +01:00
|
|
|
|
2026-01-28 16:51:27 +08:00
|
|
|
const directories = useDirectories();
|
2026-01-07 16:03:02 +01:00
|
|
|
|
|
|
|
|
const boardIdentifier = propBoardIdentifier || params.boardIdentifier;
|
|
|
|
|
|
|
|
|
|
const resolvedAddress = useMemo(() => {
|
|
|
|
|
if (boardIdentifier) {
|
2026-04-17 10:48:27 +07:00
|
|
|
return getCommunityAddress(boardIdentifier, directories);
|
2026-01-07 16:03:02 +01:00
|
|
|
}
|
|
|
|
|
return undefined;
|
2026-01-28 16:51:27 +08:00
|
|
|
}, [boardIdentifier, directories]);
|
2026-01-07 16:03:02 +01:00
|
|
|
|
2026-03-13 13:25:10 +08:00
|
|
|
const communityAddresses = useMemo(() => {
|
2026-02-27 17:38:36 +08:00
|
|
|
if (resolvedAddress) return [resolvedAddress];
|
2026-03-13 13:25:10 +08:00
|
|
|
return accountCommunityAddresses;
|
|
|
|
|
}, [resolvedAddress, accountCommunityAddresses]);
|
2026-04-15 12:51:21 +07:00
|
|
|
const communities = useCommunityIdentifiers(communityAddresses);
|
2026-01-07 16:03:02 +01:00
|
|
|
|
2026-03-13 13:25:10 +08:00
|
|
|
const communityAddress = communityAddresses[0];
|
2026-04-15 12:51:21 +07:00
|
|
|
const communityIdentifier = useCommunityIdentifier(communityAddress);
|
|
|
|
|
const community = useCommunity(communityIdentifier ? { community: communityIdentifier } : undefined);
|
2026-03-13 13:25:10 +08:00
|
|
|
const { error: communityError } = community || {};
|
2026-01-12 17:59:03 +01:00
|
|
|
|
2026-02-26 16:42:36 +08:00
|
|
|
const feedOptions = useMemo(
|
|
|
|
|
() => ({
|
2026-04-15 12:51:21 +07:00
|
|
|
communities,
|
2026-02-26 16:42:36 +08:00
|
|
|
modQueue: ['pendingApproval'],
|
|
|
|
|
postsPerPage: 50,
|
|
|
|
|
}),
|
2026-04-15 12:51:21 +07:00
|
|
|
[communities],
|
2026-02-26 16:42:36 +08:00
|
|
|
);
|
|
|
|
|
const { feed, hasMore, loadMore, reset } = useFeed(feedOptions);
|
2026-01-07 16:03:02 +01:00
|
|
|
|
2026-02-27 17:38:36 +08:00
|
|
|
const filteredFeed = useMemo(() => {
|
|
|
|
|
if (!selectedBoardFilter) return feed;
|
2026-03-13 13:25:10 +08:00
|
|
|
return feed.filter((item) => getCommentCommunityAddress(item) === selectedBoardFilter);
|
2026-02-27 17:38:36 +08:00
|
|
|
}, [feed, selectedBoardFilter]);
|
|
|
|
|
|
|
|
|
|
const addressToPathMap = useMemo(() => {
|
|
|
|
|
const map = new Map<string, string>();
|
2026-03-13 13:25:10 +08:00
|
|
|
for (const addr of communityAddresses) {
|
2026-02-27 17:38:36 +08:00
|
|
|
map.set(addr, getBoardPath(addr, directories));
|
|
|
|
|
}
|
|
|
|
|
return map;
|
2026-03-13 13:25:10 +08:00
|
|
|
}, [communityAddresses, directories]);
|
2026-02-27 17:38:36 +08:00
|
|
|
|
|
|
|
|
const showBoardColumn = !resolvedAddress;
|
|
|
|
|
const compactRowItemContent = useCallback(
|
2026-03-07 18:50:24 +08:00
|
|
|
(index: number, comment: Comment) => {
|
2026-03-13 13:25:10 +08:00
|
|
|
const commentCommunityAddress = getCommentCommunityAddress(comment);
|
|
|
|
|
const path = addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined);
|
2026-03-07 18:50:24 +08:00
|
|
|
return (
|
|
|
|
|
<ModQueueRow
|
|
|
|
|
key={comment.cid}
|
|
|
|
|
comment={comment}
|
|
|
|
|
isOdd={index % 2 === 0}
|
|
|
|
|
showBoard={showBoardColumn}
|
|
|
|
|
boardPath={path}
|
2026-03-13 13:25:10 +08:00
|
|
|
boardDisplayPath={path && commentCommunityAddress ? getBoardDisplayPath(commentCommunityAddress, path) : undefined}
|
2026-03-07 18:50:24 +08:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
[addressToPathMap, showBoardColumn, directories],
|
2026-02-27 17:38:36 +08:00
|
|
|
);
|
|
|
|
|
const compactCardItemContent = useCallback(
|
2026-03-07 18:50:24 +08:00
|
|
|
(_index: number, comment: Comment) => {
|
2026-03-13 13:25:10 +08:00
|
|
|
const commentCommunityAddress = getCommentCommunityAddress(comment);
|
|
|
|
|
const path = addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined);
|
2026-03-07 18:50:24 +08:00
|
|
|
return (
|
|
|
|
|
<ModQueueCard
|
|
|
|
|
key={comment.cid}
|
|
|
|
|
comment={comment}
|
|
|
|
|
showBoard={showBoardColumn}
|
|
|
|
|
boardPath={path}
|
2026-03-13 13:25:10 +08:00
|
|
|
boardDisplayPath={path && commentCommunityAddress ? getBoardDisplayPath(commentCommunityAddress, path) : undefined}
|
2026-03-07 18:50:24 +08:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
[addressToPathMap, showBoardColumn, directories],
|
2026-02-27 17:38:36 +08:00
|
|
|
);
|
|
|
|
|
|
2026-01-07 16:03:02 +01:00
|
|
|
const setResetFunction = useFeedResetStore((state) => state.setResetFunction);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setResetFunction(reset);
|
|
|
|
|
}, [reset, setResetFunction]);
|
|
|
|
|
|
2026-01-08 17:16:29 +01:00
|
|
|
// Memoize footer components object to preserve identity across renders (Virtuoso optimization)
|
2026-01-11 15:39:46 +01:00
|
|
|
// Note: useFeedStateString is called inside ModQueueFooter to isolate re-renders from backend state changes
|
2026-01-08 17:16:29 +01:00
|
|
|
const footerComponents = useMemo(
|
|
|
|
|
() => ({
|
2026-01-12 17:59:03 +01:00
|
|
|
Footer: () => (
|
|
|
|
|
<>
|
2026-03-13 13:25:10 +08:00
|
|
|
{communityError?.message && feed.length === 0 && (
|
2026-01-12 17:59:03 +01:00
|
|
|
<div className={styles.error}>
|
2026-03-13 13:25:10 +08:00
|
|
|
<ErrorDisplay error={communityError} />
|
2026-01-12 17:59:03 +01:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-13 13:25:10 +08:00
|
|
|
<ModQueueFooter hasMore={hasMore} communityAddresses={communityAddresses} />
|
2026-01-12 17:59:03 +01:00
|
|
|
</>
|
|
|
|
|
),
|
2026-01-08 17:16:29 +01:00
|
|
|
}),
|
2026-03-13 13:25:10 +08:00
|
|
|
[hasMore, communityAddresses, communityError, feed.length],
|
2026-01-08 17:16:29 +01:00
|
|
|
);
|
2026-01-07 16:03:02 +01:00
|
|
|
|
2026-03-07 18:50:24 +08:00
|
|
|
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>
|
2026-01-25 16:29:15 +08:00
|
|
|
</div>
|
2026-01-07 16:03:02 +01:00
|
|
|
</div>
|
2026-03-07 18:50:24 +08:00
|
|
|
</PageFooterMobile>
|
|
|
|
|
</>
|
|
|
|
|
);
|
2026-01-07 16:03:02 +01:00
|
|
|
|
2026-03-07 18:50:24 +08:00
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
<div className={styles.container}>
|
|
|
|
|
{!resolvedAddress && (
|
|
|
|
|
<div className={styles.controls}>
|
|
|
|
|
<div className={styles.controlsLeft}>
|
2026-03-13 13:25:10 +08:00
|
|
|
<ModQueueBoardSummary feed={feed} directories={directories} accountCommunityAddresses={accountCommunityAddresses} />
|
2026-03-07 18:50:24 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-25 16:29:15 +08:00
|
|
|
|
2026-03-07 18:50:24 +08:00
|
|
|
{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>
|
2026-01-25 16:29:15 +08:00
|
|
|
|
2026-03-07 18:50:24 +08:00
|
|
|
{hasMore ? (
|
|
|
|
|
<Virtuoso
|
|
|
|
|
useWindowScroll
|
|
|
|
|
data={filteredFeed}
|
|
|
|
|
totalCount={filteredFeed.length}
|
|
|
|
|
endReached={loadMore}
|
|
|
|
|
increaseViewportBy={{ bottom: 600, top: 600 }}
|
|
|
|
|
itemContent={compactRowItemContent}
|
|
|
|
|
components={footerComponents}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
{filteredFeed.map((comment, index) => {
|
2026-03-13 13:25:10 +08:00
|
|
|
const commentCommunityAddress = getCommentCommunityAddress(comment);
|
|
|
|
|
const path =
|
|
|
|
|
addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined);
|
2026-03-07 18:50:24 +08:00
|
|
|
return (
|
|
|
|
|
<ModQueueRow
|
|
|
|
|
key={comment.cid}
|
|
|
|
|
comment={comment}
|
|
|
|
|
isOdd={index % 2 === 0}
|
|
|
|
|
showBoard={showBoardColumn}
|
|
|
|
|
boardPath={path}
|
2026-03-13 13:25:10 +08:00
|
|
|
boardDisplayPath={path && commentCommunityAddress ? getBoardDisplayPath(commentCommunityAddress, path) : undefined}
|
2026-03-07 18:50:24 +08:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-03-13 13:25:10 +08:00
|
|
|
{communityError?.message && feed.length === 0 && (
|
2026-03-07 18:50:24 +08:00
|
|
|
<div className={styles.error}>
|
2026-03-13 13:25:10 +08:00
|
|
|
<ErrorDisplay error={communityError} />
|
2026-03-07 18:50:24 +08:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-13 13:25:10 +08:00
|
|
|
<ModQueueFooter hasMore={hasMore} communityAddresses={communityAddresses} />
|
2026-03-07 18:50:24 +08:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-01-25 16:29:15 +08:00
|
|
|
|
2026-03-07 18:50:24 +08:00
|
|
|
{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) => {
|
2026-03-13 13:25:10 +08:00
|
|
|
const commentCommunityAddress = getCommentCommunityAddress(comment);
|
|
|
|
|
const path =
|
|
|
|
|
addressToPathMap.get(commentCommunityAddress || '') ?? (commentCommunityAddress ? getBoardPath(commentCommunityAddress, directories) : undefined);
|
2026-03-07 18:50:24 +08:00
|
|
|
return (
|
|
|
|
|
<ModQueueCard
|
|
|
|
|
key={comment.cid}
|
|
|
|
|
comment={comment}
|
|
|
|
|
showBoard={showBoardColumn}
|
|
|
|
|
boardPath={path}
|
2026-03-13 13:25:10 +08:00
|
|
|
boardDisplayPath={path && commentCommunityAddress ? getBoardDisplayPath(commentCommunityAddress, path) : undefined}
|
2026-03-07 18:50:24 +08:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-03-13 13:25:10 +08:00
|
|
|
{communityError?.message && feed.length === 0 && (
|
2026-03-07 18:50:24 +08:00
|
|
|
<div className={styles.error}>
|
2026-03-13 13:25:10 +08:00
|
|
|
<ErrorDisplay error={communityError} />
|
2026-03-07 18:50:24 +08:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-13 13:25:10 +08:00
|
|
|
<ModQueueFooter hasMore={hasMore} communityAddresses={communityAddresses} />
|
2026-03-07 18:50:24 +08:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{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} />
|
|
|
|
|
))}
|
2026-03-13 13:25:10 +08:00
|
|
|
{communityError?.message && feed.length === 0 && (
|
2026-03-07 18:50:24 +08:00
|
|
|
<div className={styles.error}>
|
2026-03-13 13:25:10 +08:00
|
|
|
<ErrorDisplay error={communityError} />
|
2026-03-07 18:50:24 +08:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-03-13 13:25:10 +08:00
|
|
|
<ModQueueFooter hasMore={hasMore} communityAddresses={communityAddresses} />
|
2026-03-07 18:50:24 +08:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
{pageFooter}
|
|
|
|
|
</>
|
2026-01-07 16:03:02 +01:00
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default ModQueueView;
|