From 14e8c92fa0fd2cc6eb9d5bad7646020207389373 Mon Sep 17 00:00:00 2001 From: plebeius Date: Sun, 25 Jan 2026 16:29:15 +0800 Subject: [PATCH] feat(mod-queue): add view mode selection and integrate with post components --- src/app.tsx | 5 +- .../board-buttons/board-buttons.module.css | 24 ++ .../board-buttons/board-buttons.tsx | 59 +++ src/components/post-desktop/post-desktop.tsx | 104 ++++- src/components/post-form/post-form.module.css | 12 + src/components/post-form/post-form.tsx | 11 +- src/components/post-mobile/post-mobile.tsx | 75 +++- src/stores/use-mod-queue-store.ts | 12 +- src/views/mod-queue/mod-queue.module.css | 46 ++ src/views/mod-queue/mod-queue.tsx | 392 ++++++++++++------ src/views/post/post.module.css | 61 +++ src/views/post/post.tsx | 47 ++- 12 files changed, 703 insertions(+), 145 deletions(-) diff --git a/src/app.tsx b/src/app.tsx index d5e60359..81f5eba1 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -83,14 +83,13 @@ const BoardLayout = () => { {isMobile - ? (subplebbitAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPost?.subplebbitAddress) && - !isOnModQueueRoute && ( + ? (subplebbitAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPost?.subplebbitAddress || isOnModQueueRoute) && ( <> ) - : (subplebbitAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPost?.subplebbitAddress) && ( + : (subplebbitAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPost?.subplebbitAddress || isOnModQueueRoute) && ( <> {!(isInAllView || isInSubscriptionsView || isInModView) && !isOnModQueueRoute && } diff --git a/src/components/board-buttons/board-buttons.module.css b/src/components/board-buttons/board-buttons.module.css index f46b0f28..9ae4944e 100644 --- a/src/components/board-buttons/board-buttons.module.css +++ b/src/components/board-buttons/board-buttons.module.css @@ -66,6 +66,30 @@ align-items: center; } +.modQueueControls { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 8px; + font-size: 12px; +} + +.modQueueControls label { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.alertThresholdInput { + width: 50px; +} + +.mobileBoardButtons .modQueueControls { + display: flex; + justify-content: center; + margin: 6px 0; +} + .desktopBoardButtons .rightSideButtons select { margin-right: 5px; } diff --git a/src/components/board-buttons/board-buttons.tsx b/src/components/board-buttons/board-buttons.tsx index 3afd631b..c7ad11a7 100644 --- a/src/components/board-buttons/board-buttons.tsx +++ b/src/components/board-buttons/board-buttons.tsx @@ -11,6 +11,7 @@ import useCatalogStyleStore from '../../stores/use-catalog-style-store'; import useFeedResetStore from '../../stores/use-feed-reset-store'; import useSortingStore from '../../stores/use-sorting-store'; import useAllFeedFilterStore from '../../stores/use-all-feed-filter-store'; +import useModQueueStore from '../../stores/use-mod-queue-store'; import useCountLinksInReplies from '../../hooks/use-count-links-in-replies'; import useIsMobile from '../../hooks/use-is-mobile'; import useTimeFilter from '../../hooks/use-time-filter'; @@ -229,6 +230,58 @@ const ImageSizeOptions = () => { ); }; +const ModQueueAlertThreshold = () => { + const { t } = useTranslation(); + const { alertThresholdValue, alertThresholdUnit, setAlertThreshold } = useModQueueStore(); + + return ( +
+ +
+ ); +}; + +const ModQueueViewSelector = () => { + const { viewMode, setViewMode } = useModQueueStore(); + return ( +
+ +
+ ); +}; + const ShowOPCommentOption = () => { const { t } = useTranslation(); const { showOPComment, setShowOPComment } = useCatalogStyleStore(); @@ -353,6 +406,8 @@ export const MobileBoardButtons = () => { isInModQueueView={isInModQueueView} /> + + ) : ( <> @@ -476,6 +531,10 @@ export const DesktopBoardButtons = () => { /> ] [ ] + + + + ) : ( <> diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index 1f780380..53aade6f 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -9,7 +9,8 @@ import { CommentMediaInfo, getDisplayMediaInfoType, getHasThumbnail, getMediaDim import { hashStringToColor, getTextColorForBackground } from '../../lib/utils/post-utils'; import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils'; import { isValidURL } from '../../lib/utils/url-utils'; -import { isAllView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; +import { isAllView, isModQueueView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; +import useModQueueStore from '../../stores/use-mod-queue-store'; import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits'; import { getBoardPath } from '../../lib/utils/route-utils'; import useAvatarVisibilityStore from '../../stores/use-avatar-visibility-store'; @@ -54,7 +55,19 @@ const useShowOmittedReplies = create((set) => ({ })), })); -const PostInfo = ({ post, postReplyCount = 0, roles, isHidden, threadNumber }: PostProps) => { +const PostInfo = ({ + post, + postReplyCount = 0, + roles, + isHidden, + threadNumber, + isModQueue, + modQueueStatus, + modQueueError, + isPublishing, + onApprove, + onReject, +}: PostProps) => { const { t } = useTranslation(); const { author, cid, deleted, locked, pinned, parentCid, postCid, reason, removed, state, subplebbitAddress, timestamp } = post || {}; const title = post?.title?.trim(); @@ -74,6 +87,17 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden, threadNumber }: P const params = useParams(); const location = useLocation(); const isInPostPageView = isPostPageView(location.pathname, params); + const isInModQueueView = isModQueueView(location.pathname); + const { getAlertThresholdSeconds } = useModQueueStore(); + + // Check if post is awaiting approval and over threshold (for mod queue view) + const approved = post?.approved; + const alreadyApproved = approved === true; + const alreadyRejected = removed === true; + const isAwaitingApproval = isInModQueueView && !alreadyApproved && !alreadyRejected; + const timeWaiting = timestamp ? Date.now() / 1000 - timestamp : 0; + const alertThresholdSeconds = getAlertThresholdSeconds(); + const isOverThreshold = isAwaitingApproval && timeWaiting > alertThresholdSeconds; const userID = address && Plebbit.getShortAddress({ address }); // should not be shortened to less than 12 characters, because users can create unlimited addresses/IDs before authenticating or passing challenges, so if the ID is short enough they can spoof it to troll users with the same ID const userIDBackgroundColor = hashStringToColor(userID); @@ -169,7 +193,14 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden, threadNumber }: P ){' '} - {getFormattedDate(timestamp)}} content={getFormattedTimeAgo(timestamp)} />{' '} + {isInModQueueView && isOverThreshold ? ( + <> + {getFormattedDate(timestamp)}} content={getFormattedTimeAgo(timestamp)} /> ( + {getFormattedTimeAgo(timestamp)}) + + ) : ( + {getFormattedDate(timestamp)}} content={getFormattedTimeAgo(timestamp)} /> + )}{' '} {cid ? ( @@ -204,7 +235,7 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden, threadNumber }: P )} - {!isInPostPageView && !isReply && !isHidden && ( + {!isInPostPageView && !isReply && !isHidden && !isModQueue && ( [ !cid && e.preventDefault()}> @@ -213,8 +244,41 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden, threadNumber }: P ] )} + {isModQueue && ( + + {modQueueStatus === 'approved' ? ( + {t('approved')} + ) : modQueueStatus === 'rejected' ? ( + {t('rejected')} + ) : modQueueStatus === 'failed' ? ( + + {t('failed')} + {modQueueError ? `: ${modQueueError}` : ''} + + ) : isPublishing ? ( + + ) : ( + <> + + [ + + ] + + + [ + + ] + + + )} + + )} - {!(removed || deleted) && } + {!(removed || deleted) && !isModQueue && } {cid && parentCid && replies && @@ -376,7 +440,19 @@ const Reply = ({ postReplyCount, reply, roles, threadNumber }: PostProps) => { ); }; -const PostDesktop = ({ post, roles, showAllReplies, showReplies = true, targetReplyCid }: PostProps) => { +const PostDesktop = ({ + post, + roles, + showAllReplies, + showReplies = true, + targetReplyCid, + isModQueue, + modQueueStatus, + modQueueError, + isPublishing, + onApprove, + onReject, +}: PostProps) => { const { t } = useTranslation(); const { author, cid, content, deleted, link, linkHeight, linkWidth, pinned, postCid, removed, spoiler, state, subplebbitAddress, thumbnailUrl, parentCid } = post || {}; const params = useParams(); @@ -450,7 +526,7 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true, targetRe return (
- {showReplies ? ( + {showReplies || isModQueue ? (

@@ -485,7 +561,19 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true, targetRe isInSubscriptionsView={isInSubscriptionsView} /> )} - + {!isHidden && !content && !(deleted || removed) &&
} {!isHidden && }
diff --git a/src/components/post-form/post-form.module.css b/src/components/post-form/post-form.module.css index a16a7855..8e839b96 100644 --- a/src/components/post-form/post-form.module.css +++ b/src/components/post-form/post-form.module.css @@ -18,6 +18,18 @@ margin-top: -3px; } +.modQueueTitle { + font-size: 1.5em; + font-weight: bold; + padding: 10px 0; +} + +@media (min-width: 640px) { + .modQueueTitle { + font-size: 1em; + } +} + .closed { font-size: x-large; text-align: center; diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index fb71d823..d920e83b 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -8,7 +8,7 @@ import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/s import { getHasThumbnail, getLinkMediaInfo } from '../../lib/utils/media-utils'; import { formatMarkdown } from '../../lib/utils/post-utils'; import { isValidURL } from '../../lib/utils/url-utils'; -import { isAllView, isCatalogView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; +import { isAllView, isCatalogView, isModQueueView, isModView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits'; import { useResolvedSubplebbitAddress } from '../../hooks/use-resolved-subplebbit-address'; import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame'; @@ -373,6 +373,7 @@ const PostForm = () => { const isInPostView = isPostPageView(location.pathname, params); const isInAllView = isAllView(location.pathname); const isInModView = isModView(location.pathname); + const isInModQueueView = isModQueueView(location.pathname); const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); const isInCatalogView = isCatalogView(location.pathname, params); @@ -398,7 +399,9 @@ const PostForm = () => { <>
{!(isInAllView || isInSubscriptionsView || isInModView) && showForm && } - {isThreadClosed ? ( + {isInModQueueView ? ( +
{t('moderation_queue')}
+ ) : isThreadClosed ? (
{t('thread_closed')}
@@ -418,7 +421,9 @@ const PostForm = () => {
{!(isInAllView || isInSubscriptionsView || isInModView) && showForm && } - {isThreadClosed ? ( + {isInModQueueView ? ( +
{t('moderation_queue')}
+ ) : isThreadClosed ? (
{t('thread_closed')}
diff --git a/src/components/post-mobile/post-mobile.tsx b/src/components/post-mobile/post-mobile.tsx index c1664f1a..c67741a5 100644 --- a/src/components/post-mobile/post-mobile.tsx +++ b/src/components/post-mobile/post-mobile.tsx @@ -9,7 +9,8 @@ import { shouldShowSnow } from '../../lib/snow'; import { getHasThumbnail } from '../../lib/utils/media-utils'; import { getTextColorForBackground, hashStringToColor } from '../../lib/utils/post-utils'; import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils'; -import { isAllView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; +import { isAllView, isModQueueView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils'; +import useModQueueStore from '../../stores/use-mod-queue-store'; import { useDefaultSubplebbits } from '../../hooks/use-default-subplebbits'; import { getBoardPath } from '../../lib/utils/route-utils'; import useAvatarVisibilityStore from '../../stores/use-avatar-visibility-store'; @@ -52,10 +53,21 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos const isInAllView = isAllView(location.pathname); const isInPostPageView = isPostPageView(location.pathname, params); const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); + const isInModQueueView = isModQueueView(location.pathname); + const { getAlertThresholdSeconds } = useModQueueStore(); const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight); const hasThumbnail = getHasThumbnail(commentMediaInfo, link); + // Check if post is awaiting approval and over threshold (for mod queue view) + const approved = post?.approved; + const alreadyApproved = approved === true; + const alreadyRejected = removed === true; + const isAwaitingApproval = isInModQueueView && !alreadyApproved && !alreadyRejected; + const timeWaiting = timestamp ? Date.now() / 1000 - timestamp : 0; + const alertThresholdSeconds = getAlertThresholdSeconds(); + const isOverThreshold = isAwaitingApproval && timeWaiting > alertThresholdSeconds; + const stateString = useStateString(post); const postMenuProps = useMemo(() => selectPostMenuProps(post), [post]); @@ -176,7 +188,14 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos Board: {boardPath}
)} - {getFormattedDate(timestamp)}} content={getFormattedTimeAgo(timestamp)} />{' '} + {isInModQueueView && isOverThreshold ? ( + <> + {getFormattedDate(timestamp)}} content={getFormattedTimeAgo(timestamp)} /> ( + {getFormattedTimeAgo(timestamp)}) + + ) : ( + {getFormattedDate(timestamp)}} content={getFormattedTimeAgo(timestamp)} /> + )}{' '} {cid ? ( { ); }; -const PostMobile = ({ post, roles, showAllReplies, showReplies = true, targetReplyCid }: PostProps) => { +const PostMobile = ({ + post, + roles, + showAllReplies, + showReplies = true, + targetReplyCid, + isModQueue, + modQueueStatus, + modQueueError, + isPublishing, + onApprove, + onReject, +}: PostProps) => { const { t } = useTranslation(); const { author, cid, pinned, postCid, replyCount, state, subplebbitAddress } = post || {}; const params = useParams(); @@ -356,12 +387,12 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true, targetRep ) : (
- {showReplies && ( + {(showReplies || isModQueue) && (

)} -
+
- {!isInPostView && !isInPendingPostView && showReplies && ( + {!isInPostView && !isInPendingPostView && (showReplies || isModQueue) && (
{replyCount > 0 && `${replyCount} Replies`} {linksCount > 0 && ` / ${linksCount} Links`} - - {t('view_thread')} - + {isModQueue ? ( +
+ {modQueueStatus === 'approved' ? ( + {t('approved')} + ) : modQueueStatus === 'rejected' ? ( + {t('rejected')} + ) : modQueueStatus === 'failed' ? ( + + {t('failed')} + {modQueueError ? `: ${modQueueError}` : ''} + + ) : isPublishing ? ( + + ) : ( + <> + + + + )} +
+ ) : ( + + {t('view_thread')} + + )}
)}
diff --git a/src/stores/use-mod-queue-store.ts b/src/stores/use-mod-queue-store.ts index 25f2d7e1..5c588646 100644 --- a/src/stores/use-mod-queue-store.ts +++ b/src/stores/use-mod-queue-store.ts @@ -2,13 +2,16 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; export type AlertThresholdUnit = 'hours' | 'minutes'; +export type ModQueueViewMode = 'compact' | 'feed'; interface ModQueueState { alertThresholdValue: number; alertThresholdUnit: AlertThresholdUnit; selectedBoardFilter: string | null; + viewMode: ModQueueViewMode; setAlertThreshold: (value: number, unit: AlertThresholdUnit) => void; setSelectedBoardFilter: (boardAddress: string | null) => void; + setViewMode: (viewMode: ModQueueViewMode) => void; // Helper to get threshold in seconds for calculations getAlertThresholdSeconds: () => number; } @@ -19,10 +22,11 @@ interface OldPersistedState { alertThresholdValue?: number; alertThresholdUnit?: AlertThresholdUnit; selectedBoardFilter?: string | null; + viewMode?: ModQueueViewMode; } // Type for persisted data (without methods) -type PersistedModQueueData = Pick; +type PersistedModQueueData = Pick; const useModQueueStore = create()( persist( @@ -30,8 +34,10 @@ const useModQueueStore = create()( alertThresholdValue: 6, alertThresholdUnit: 'hours' as AlertThresholdUnit, selectedBoardFilter: null, + viewMode: 'compact', setAlertThreshold: (value, unit) => set({ alertThresholdValue: value, alertThresholdUnit: unit }), setSelectedBoardFilter: (boardAddress) => set({ selectedBoardFilter: boardAddress }), + setViewMode: (viewMode) => set({ viewMode }), getAlertThresholdSeconds: () => { const { alertThresholdValue, alertThresholdUnit } = get(); return alertThresholdUnit === 'hours' ? alertThresholdValue * 3600 : alertThresholdValue * 60; @@ -39,7 +45,7 @@ const useModQueueStore = create()( }), { name: 'mod-queue-storage', - version: 1, + version: 2, // Migrate old alertThresholdHours format to new alertThresholdValue/alertThresholdUnit format migrate: (persistedState, version): ModQueueState => { const state = persistedState as OldPersistedState; @@ -48,6 +54,7 @@ const useModQueueStore = create()( alertThresholdValue: state.alertThresholdHours, alertThresholdUnit: 'hours' as AlertThresholdUnit, selectedBoardFilter: state.selectedBoardFilter ?? null, + viewMode: state.viewMode ?? 'compact', }; // Zustand will merge this with the store definition (which includes methods) return migrated as ModQueueState; @@ -57,6 +64,7 @@ const useModQueueStore = create()( alertThresholdValue: state.alertThresholdValue ?? 6, alertThresholdUnit: state.alertThresholdUnit ?? 'hours', selectedBoardFilter: state.selectedBoardFilter ?? null, + viewMode: state.viewMode ?? 'compact', }; return current as ModQueueState; }, diff --git a/src/views/mod-queue/mod-queue.module.css b/src/views/mod-queue/mod-queue.module.css index fef3d539..2fa9bb2c 100644 --- a/src/views/mod-queue/mod-queue.module.css +++ b/src/views/mod-queue/mod-queue.module.css @@ -233,6 +233,52 @@ display: inline; } +.mobileCard { + border: var(--mod-queue-table-border); + background: var(--post-mobile-background-color, transparent); + padding: 8px; + margin: 6px 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.cardHeader { + display: flex; + justify-content: space-between; + font-size: 9pt; + font-weight: bold; +} + +.cardNumber { + color: var(--body-font-color); +} + +.cardTime { + white-space: nowrap; +} + +.cardContent { + font-size: 9pt; + color: var(--body-font-color); +} + +.cardContent a { + text-decoration: var(--post-link-text-decoration); + color: var(--post-link-text-color); +} + +.cardContent a:hover { + text-decoration: var(--post-link-text-decoration-hover); + color: var(--post-link-text-color-hover); +} + +.cardActions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + /* Desktop: buttons styled as text links (same as board buttons) */ @media (min-width: 640px) { .actions { diff --git a/src/views/mod-queue/mod-queue.tsx b/src/views/mod-queue/mod-queue.tsx index bdee9f98..bc520b1d 100644 --- a/src/views/mod-queue/mod-queue.tsx +++ b/src/views/mod-queue/mod-queue.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState, useEffect } from 'react'; +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 } from '@plebbit/plebbit-react-hooks'; @@ -21,6 +21,7 @@ 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'; const { addChallenge } = useChallengesStore.getState(); @@ -55,30 +56,22 @@ interface ModQueueRowProps { // Track which action was initiated to show appropriate completion message type ModerationAction = 'approve' | 'reject' | null; -const ModQueueRow = ({ comment, isOdd = false }: ModQueueRowProps) => { +interface ModQueueActionState { + status: 'approved' | 'rejected' | 'failed' | null; + errorMessage?: string; + isPublishing: boolean; + handleApprove: () => Promise; + handleReject: () => Promise; +} + +const useModQueueActions = (comment: Comment): ModQueueActionState => { const { t } = useTranslation(); - const { getAlertThresholdSeconds } = useModQueueStore(); + const { cid, subplebbitAddress, approved, removed } = comment || {}; const [initiatedAction, setInitiatedAction] = useState(null); - const isMobile = useIsMobile(); - const { editedComment } = useEditedComment({ comment }); - const displayComment = editedComment || comment; - - const { content, title, timestamp, subplebbitAddress, cid, shortCid, threadCid, link, thumbnailUrl, linkWidth, linkHeight, removed, approved, number } = 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 { publishCommentModeration: approve, state: approveState, @@ -117,8 +110,7 @@ const ModQueueRow = ({ comment, isOdd = false }: ModQueueRowProps) => { }, }); - const handleApprove = async () => { - // Double confirmation for approve action + const handleApprove = useCallback(async () => { const confirm = window.confirm(t('double_confirm')); if (!confirm) { return; @@ -130,10 +122,9 @@ const ModQueueRow = ({ comment, isOdd = false }: ModQueueRowProps) => { } catch (e) { console.error(e); } - }; + }, [approve, t]); - const handleReject = async () => { - // Double confirmation for reject action + const handleReject = useCallback(async () => { const confirm = window.confirm(t('double_confirm')); if (!confirm) { return; @@ -145,9 +136,8 @@ const ModQueueRow = ({ comment, isOdd = false }: ModQueueRowProps) => { } catch (e) { console.error(e); } - }; + }, [reject, t]); - // 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; @@ -158,6 +148,37 @@ const ModQueueRow = ({ comment, isOdd = false }: ModQueueRowProps) => { 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 } = 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; @@ -176,23 +197,17 @@ const ModQueueRow = ({ comment, isOdd = false }: ModQueueRowProps) => { // Render the status or action buttons const renderActions = () => { // Check existing moderation state first (from API/previous sessions) - if (alreadyApproved || approveSucceeded) { + if (status === 'approved') { return {t('approved')}; } - if (alreadyRejected || rejectSucceeded) { + if (status === 'rejected') { return {t('rejected')}; } - if (approveFailed) { + if (status === 'failed') { return ( - {t('failed')}: {approveError?.message} - - ); - } - if (rejectFailed) { - return ( - - {t('failed')}: {rejectError?.message} + {t('failed')} + {errorMessage ? `: ${errorMessage}` : ''} ); } @@ -258,6 +273,137 @@ const ModQueueRow = ({ comment, isOdd = false }: ModQueueRowProps) => { ); }; +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 } = 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 rawExcerpt = + (hasTitle ? title : null) || + (hasContent ? content : null) || + (hasLink ? link : null) || + (getHasThumbnail(getCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight), link) ? 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} + )} +
+ {renderActions()} +
+ ); +}; + +const ModQueueFeedPost = ({ comment }: { comment: Comment }) => { + const { editedComment } = useEditedComment({ comment }); + const displayComment = editedComment || comment; + const { status, errorMessage, isPublishing, handleApprove, handleReject } = useModQueueActions(displayComment); + + return ( + + ); +}; + interface ModQueueBoardFilterProps { subplebbits: MultisubSubplebbit[]; } @@ -474,7 +620,8 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp export const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => { const { t } = useTranslation(); const params = useParams(); - const { selectedBoardFilter, alertThresholdValue, alertThresholdUnit, setAlertThreshold } = useModQueueStore(); + const { selectedBoardFilter, viewMode } = useModQueueStore(); + const isMobile = useIsMobile(); const accountSubplebbitAddresses = useAccountsStore( (state) => { @@ -563,97 +710,108 @@ export const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueV [hasMore, subplebbitAddresses, subplebbitError, feed.length], ); - const alertThresholdControl = ( -
- -
- ); - return (
{!resolvedAddress && ( -
-
{t('moderation_queue')}
+
+
+ +
)} -
- {!resolvedAddress ? ( - <> -
- -
-
{alertThresholdControl}
- - ) : ( - <> -
-
{t('moderation_queue')}
-
-
{alertThresholdControl}
- - )} -
- {feed.length === 0 && !hasMore ? (
{t('queue_is_empty')}
) : ( <> -
-
No.
-
{t('excerpt')}
-
{t('submitted')}
-
{t('actions')}
-
- - {/* Use Virtuoso for infinite scroll only when there's more content to paginate */} - {hasMore ? ( - } - components={footerComponents} - /> - ) : ( + {viewMode === 'compact' && !isMobile && ( <> - {feed.map((comment, index) => ( - - ))} - {subplebbitError?.message && feed.length === 0 && ( -
- -
+
+
No.
+
{t('excerpt')}
+
{t('submitted')}
+
{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 && ( +
+ +
+ )} + + )} - )} diff --git a/src/views/post/post.module.css b/src/views/post/post.module.css index 10482541..fdd0d19d 100644 --- a/src/views/post/post.module.css +++ b/src/views/post/post.module.css @@ -189,6 +189,55 @@ color: var(--button-desktop-text-color-hover); } +.modQueueActions { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 8px; +} + +.modQueueButtonWrapper { + display: inline-flex; +} + +.modQueueActionButton { + all: unset; + cursor: pointer; + text-transform: capitalize; + color: var(--button-desktop-text-color); + text-decoration: var(--button-text-decoration); + font-family: inherit; +} + +.modQueueActionButton:hover { + color: var(--button-desktop-text-color-hover); +} + +.modQueueActionButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.modQueueStatusApproved { + color: green; + font-weight: 700; +} + +.modQueueStatusRejected { + color: red; + font-weight: 700; +} + +.approveButton { + background-color: #3fa96b; + color: #fff; +} + +.rejectButton { + background-color: #d9534f; + color: #fff; +} + .postDesktop .spacer { width: 100%; padding: 5px; @@ -668,4 +717,16 @@ color: red; font-weight: 700; text-transform: uppercase; +} + +.alert { + color: var(--mod-queue-alert-color, red); + font-weight: bold; + animation: blink 2s infinite; +} + +@keyframes blink { + 0% { opacity: 1; } + 50% { opacity: 0.5; } + 100% { opacity: 1; } } \ No newline at end of file diff --git a/src/views/post/post.tsx b/src/views/post/post.tsx index 71333b19..8f58a58a 100644 --- a/src/views/post/post.tsx +++ b/src/views/post/post.tsx @@ -25,9 +25,26 @@ export interface PostProps { showReplies?: boolean; targetReplyCid?: string; threadNumber?: number; + isModQueue?: boolean; + modQueueStatus?: 'approved' | 'rejected' | 'failed' | null; + modQueueError?: string; + isPublishing?: boolean; + onApprove?: () => void; + onReject?: () => void; } -export const Post = ({ post, showAllReplies = false, showReplies = true, targetReplyCid }: PostProps) => { +export const Post = ({ + post, + showAllReplies = false, + showReplies = true, + targetReplyCid, + isModQueue, + modQueueStatus, + modQueueError, + isPublishing, + onApprove, + onReject, +}: PostProps) => { // Only subscribe to roles field to avoid rerenders from updatingState changes const roles = useSubplebbitField(post?.subplebbitAddress, (subplebbit) => subplebbit?.roles); const isMobile = useIsMobile(); @@ -44,9 +61,33 @@ export const Post = ({ post, showAllReplies = false, showReplies = true, targetR
{isMobile ? ( - + ) : ( - + )}