feat(mod-queue): add view mode selection and integrate with post components

This commit is contained in:
plebeius
2026-01-25 16:29:15 +08:00
parent 3cac7810ae
commit 14e8c92fa0
12 changed files with 703 additions and 145 deletions
+2 -3
View File
@@ -83,14 +83,13 @@ const BoardLayout = () => {
<DisclaimerModal />
<BoardHeader />
{isMobile
? (subplebbitAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPost?.subplebbitAddress) &&
!isOnModQueueRoute && (
? (subplebbitAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPost?.subplebbitAddress || isOnModQueueRoute) && (
<>
<PostForm key={key} />
<MobileBoardButtons />
</>
)
: (subplebbitAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPost?.subplebbitAddress) && (
: (subplebbitAddress || isInAllView || isInModView || isInSubscriptionsView || pendingPost?.subplebbitAddress || isOnModQueueRoute) && (
<>
<PostForm key={key} />
{!(isInAllView || isInSubscriptionsView || isInModView) && !isOnModQueueRoute && <SubplebbitStats />}
@@ -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;
}
@@ -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 (
<div className={styles.modQueueControls}>
<label>
{t('alert_threshold')}:
<input
type='number'
min='1'
step='1'
value={alertThresholdValue}
onChange={(e) => setAlertThreshold(Number(e.target.value), alertThresholdUnit)}
className={styles.alertThresholdInput}
/>
<select
value={alertThresholdUnit}
onChange={(e) => {
const newUnit = e.target.value as 'hours' | 'minutes';
const newValue =
alertThresholdUnit === 'hours' && newUnit === 'minutes'
? alertThresholdValue * 60
: alertThresholdUnit === 'minutes' && newUnit === 'hours'
? Math.round(alertThresholdValue / 60)
: alertThresholdValue;
setAlertThreshold(Math.max(1, newValue), newUnit);
}}
>
<option value='minutes'>{t('minutes')}</option>
<option value='hours'>{t('hours')}</option>
</select>
</label>
</div>
);
};
const ModQueueViewSelector = () => {
const { viewMode, setViewMode } = useModQueueStore();
return (
<div className={styles.modQueueControls}>
<label>
View:
<select value={viewMode} onChange={(e) => setViewMode(e.target.value as 'compact' | 'feed')}>
<option value='compact'>Compact</option>
<option value='feed'>Feed</option>
</select>
</label>
</div>
);
};
const ShowOPCommentOption = () => {
const { t } = useTranslation();
const { showOPComment, setShowOPComment } = useCatalogStyleStore();
@@ -353,6 +406,8 @@ export const MobileBoardButtons = () => {
isInModQueueView={isInModQueueView}
/>
<RefreshButton />
<ModQueueAlertThreshold />
<ModQueueViewSelector />
</>
) : (
<>
@@ -476,6 +531,10 @@ export const DesktopBoardButtons = () => {
/>
] [
<RefreshButton />]
<span className={styles.rightSideButtons}>
<ModQueueAlertThreshold />
<ModQueueViewSelector />
</span>
</>
) : (
<>
+96 -8
View File
@@ -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<ShowOmittedRepliesState>((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
){' '}
</span>
<span className={styles.dateTime}>
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />{' '}
{isInModQueueView && isOverThreshold ? (
<>
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} /> (
<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
</>
) : (
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />
)}{' '}
</span>
<span className={styles.postNum}>
{cid ? (
@@ -204,7 +235,7 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden, threadNumber }: P
<img src='assets/icons/closed.gif' alt='' className={styles.closedIcon} title={t('closed')} />
</span>
)}
{!isInPostPageView && !isReply && !isHidden && (
{!isInPostPageView && !isReply && !isHidden && !isModQueue && (
<span className={styles.replyButton}>
[
<Link to={boardPath ? `/${boardPath}/thread/${postCid}` : `/thread/${postCid}`} onClick={(e) => !cid && e.preventDefault()}>
@@ -213,8 +244,41 @@ const PostInfo = ({ post, postReplyCount = 0, roles, isHidden, threadNumber }: P
]
</span>
)}
{isModQueue && (
<span className={styles.modQueueActions}>
{modQueueStatus === 'approved' ? (
<span className={styles.modQueueStatusApproved}>{t('approved')}</span>
) : modQueueStatus === 'rejected' ? (
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
) : modQueueStatus === 'failed' ? (
<span className={styles.modQueueStatusRejected}>
{t('failed')}
{modQueueError ? `: ${modQueueError}` : ''}
</span>
) : isPublishing ? (
<LoadingEllipsis string={t('publishing')} />
) : (
<>
<span className={styles.modQueueButtonWrapper}>
[
<button className={styles.modQueueActionButton} onClick={onApprove} disabled={isPublishing}>
{t('approve')}
</button>
]
</span>
<span className={styles.modQueueButtonWrapper}>
[
<button className={styles.modQueueActionButton} onClick={onReject} disabled={isPublishing}>
{t('reject')}
</button>
]
</span>
</>
)}
</span>
)}
</span>
{!(removed || deleted) && <PostMenuDesktop postMenu={postMenuProps} />}
{!(removed || deleted) && !isModQueue && <PostMenuDesktop postMenu={postMenuProps} />}
{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 (
<div className={styles.postDesktop}>
{showReplies ? (
{showReplies || isModQueue ? (
<div className={styles.hrWrapper}>
<hr />
</div>
@@ -485,7 +561,19 @@ const PostDesktop = ({ post, roles, showAllReplies, showReplies = true, targetRe
isInSubscriptionsView={isInSubscriptionsView}
/>
)}
<PostInfo isHidden={hidden} post={post} postReplyCount={replyCount} roles={roles} threadNumber={post?.number} />
<PostInfo
isHidden={hidden}
post={post}
postReplyCount={replyCount}
roles={roles}
threadNumber={post?.number}
isModQueue={isModQueue}
modQueueStatus={modQueueStatus}
modQueueError={modQueueError}
isPublishing={isPublishing}
onApprove={onApprove}
onReject={onReject}
/>
{!isHidden && !content && !(deleted || removed) && <div className={styles.spacer} />}
{!isHidden && <CommentContent comment={post} />}
</div>
@@ -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;
+8 -3
View File
@@ -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 = () => {
<>
<div className={styles.postFormDesktop}>
{!(isInAllView || isInSubscriptionsView || isInModView) && showForm && <OfflineAlert subplebbitAddress={subplebbitAddress} />}
{isThreadClosed ? (
{isInModQueueView ? (
<div className={styles.modQueueTitle}>{t('moderation_queue')}</div>
) : isThreadClosed ? (
<div className={styles.closed}>
{t('thread_closed')}
<br />
@@ -418,7 +421,9 @@ const PostForm = () => {
</div>
<div className={styles.postFormMobile}>
{!(isInAllView || isInSubscriptionsView || isInModView) && showForm && <OfflineAlert subplebbitAddress={subplebbitAddress} />}
{isThreadClosed ? (
{isInModQueueView ? (
<div className={styles.modQueueTitle}>{t('moderation_queue')}</div>
) : isThreadClosed ? (
<div className={styles.closed}>
{t('thread_closed')}
<br />
+66 -9
View File
@@ -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
<Link to={`/${boardPath}`}>Board: {boardPath}</Link>
</div>
)}
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />{' '}
{isInModQueueView && isOverThreshold ? (
<>
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} /> (
<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
</>
) : (
<Tooltip children={<span>{getFormattedDate(timestamp)}</span>} content={getFormattedTimeAgo(timestamp)} />
)}{' '}
{cid ? (
<span className={styles.postNumLink}>
<Link
@@ -283,7 +302,19 @@ const Reply = ({ postReplyCount, reply, roles, threadNumber }: PostProps) => {
);
};
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
</>
) : (
<div className={styles.postMobile}>
{showReplies && (
{(showReplies || isModQueue) && (
<div className={styles.hrWrapper}>
<hr />
</div>
)}
<div className={showReplies ? styles.thread : styles.quotePreview}>
<div className={showReplies || isModQueue ? styles.thread : styles.quotePreview}>
<div className={styles.postContainer}>
<div
className={`${styles.postOp} ${shouldShowSnow() ? styles.xmasHatWrapper : ''}`}
@@ -373,15 +404,41 @@ const PostMobile = ({ post, roles, showAllReplies, showReplies = true, targetRep
<PostInfoAndMedia post={post} postReplyCount={replyCount} roles={roles} threadNumber={post?.number} />
<CommentContent comment={post} />
</div>
{!isInPostView && !isInPendingPostView && showReplies && (
{!isInPostView && !isInPendingPostView && (showReplies || isModQueue) && (
<div className={styles.postLink}>
<span className={styles.info}>
{replyCount > 0 && `${replyCount} Replies`}
{linksCount > 0 && ` / ${linksCount} Links`}
</span>
<Link to={boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`} className='button'>
{t('view_thread')}
</Link>
{isModQueue ? (
<div className={styles.modQueueActions}>
{modQueueStatus === 'approved' ? (
<span className={styles.modQueueStatusApproved}>{t('approved')}</span>
) : modQueueStatus === 'rejected' ? (
<span className={styles.modQueueStatusRejected}>{t('rejected')}</span>
) : modQueueStatus === 'failed' ? (
<span className={styles.modQueueStatusRejected}>
{t('failed')}
{modQueueError ? `: ${modQueueError}` : ''}
</span>
) : isPublishing ? (
<LoadingEllipsis string={t('publishing')} />
) : (
<>
<button className={`button ${styles.approveButton}`} onClick={onApprove} disabled={isPublishing}>
{t('approve')}
</button>
<button className={`button ${styles.rejectButton}`} onClick={onReject} disabled={isPublishing}>
{t('reject')}
</button>
</>
)}
</div>
) : (
<Link to={boardPath ? `/${boardPath}/thread/${cid}` : `/thread/${cid}`} className='button'>
{t('view_thread')}
</Link>
)}
</div>
)}
</div>
+10 -2
View File
@@ -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<ModQueueState, 'alertThresholdValue' | 'alertThresholdUnit' | 'selectedBoardFilter'>;
type PersistedModQueueData = Pick<ModQueueState, 'alertThresholdValue' | 'alertThresholdUnit' | 'selectedBoardFilter' | 'viewMode'>;
const useModQueueStore = create<ModQueueState>()(
persist(
@@ -30,8 +34,10 @@ const useModQueueStore = create<ModQueueState>()(
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<ModQueueState>()(
}),
{
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<ModQueueState>()(
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<ModQueueState>()(
alertThresholdValue: state.alertThresholdValue ?? 6,
alertThresholdUnit: state.alertThresholdUnit ?? 'hours',
selectedBoardFilter: state.selectedBoardFilter ?? null,
viewMode: state.viewMode ?? 'compact',
};
return current as ModQueueState;
},
+46
View File
@@ -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 {
+275 -117
View File
@@ -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<void>;
handleReject: () => Promise<void>;
}
const useModQueueActions = (comment: Comment): ModQueueActionState => {
const { t } = useTranslation();
const { getAlertThresholdSeconds } = useModQueueStore();
const { cid, subplebbitAddress, approved, removed } = comment || {};
const [initiatedAction, setInitiatedAction] = useState<ModerationAction>(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 <span className={`${styles.button} ${styles.approve}`}>{t('approved')}</span>;
}
if (alreadyRejected || rejectSucceeded) {
if (status === 'rejected') {
return <span className={`${styles.button} ${styles.reject}`}>{t('rejected')}</span>;
}
if (approveFailed) {
if (status === 'failed') {
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}
{t('failed')}
{errorMessage ? `: ${errorMessage}` : ''}
</span>
);
}
@@ -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 (
<div className={styles.cardActions}>
<span className={`${styles.button} ${styles.approve}`}>{t('approved')}</span>
</div>
);
}
if (status === 'rejected') {
return (
<div className={styles.cardActions}>
<span className={`${styles.button} ${styles.reject}`}>{t('rejected')}</span>
</div>
);
}
if (status === 'failed') {
return (
<div className={styles.cardActions}>
<span className={`${styles.button} ${styles.reject}`}>
{t('failed')}
{errorMessage ? `: ${errorMessage}` : ''}
</span>
</div>
);
}
if (isPublishing) {
return (
<div className={styles.cardActions}>
<LoadingEllipsis string={t('publishing')} />
</div>
);
}
return (
<div className={styles.cardActions}>
<button className={styles.button} onClick={handleApprove} disabled={isPublishing}>
{t('approve')}
</button>
<button className={styles.button} onClick={handleReject} disabled={isPublishing}>
{t('reject')}
</button>
</div>
);
};
return (
<div className={styles.mobileCard}>
<div className={styles.cardHeader}>
<span className={styles.cardNumber}>No. {number ?? 'N/A'}</span>
<span className={styles.cardTime}>
{isAwaitingApproval && isOverThreshold ? (
<>
{getFormattedDate(timestamp)} (<span className={styles.alert}>{getFormattedTimeAgo(timestamp)}</span>)
</>
) : (
getFormattedDate(timestamp)
)}
</span>
</div>
<div className={styles.cardContent}>
{t('excerpt')}:{' '}
{postUrl ? (
<Link to={postUrl} title={excerpt}>
{excerpt}
</Link>
) : (
<span title={excerpt}>{excerpt}</span>
)}
</div>
{renderActions()}
</div>
);
};
const ModQueueFeedPost = ({ comment }: { comment: Comment }) => {
const { editedComment } = useEditedComment({ comment });
const displayComment = editedComment || comment;
const { status, errorMessage, isPublishing, handleApprove, handleReject } = useModQueueActions(displayComment);
return (
<Post
post={displayComment}
showAllReplies={false}
showReplies={false}
isModQueue={true}
modQueueStatus={status}
modQueueError={errorMessage}
isPublishing={isPublishing}
onApprove={handleApprove}
onReject={handleReject}
/>
);
};
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 = (
<div className={styles.alertThresholdSetting}>
<label>
{t('alert_threshold')}:
<input
type='number'
min='1'
step={alertThresholdUnit === 'minutes' ? '1' : '1'}
value={alertThresholdValue}
onChange={(e) => setAlertThreshold(Number(e.target.value), alertThresholdUnit)}
className={styles.alertThresholdInput}
/>
<select
value={alertThresholdUnit}
onChange={(e) => {
const newUnit = e.target.value as 'hours' | 'minutes';
const newValue =
alertThresholdUnit === 'hours' && newUnit === 'minutes'
? alertThresholdValue * 60
: alertThresholdUnit === 'minutes' && newUnit === 'hours'
? Math.round(alertThresholdValue / 60)
: alertThresholdValue;
setAlertThreshold(Math.max(1, newValue), newUnit);
}}
>
<option value='minutes'>{t('minutes')}</option>
<option value='hours'>{t('hours')}</option>
</select>
</label>
</div>
);
return (
<div className={styles.container}>
{!resolvedAddress && (
<div className={styles.header}>
<div className={styles.title}>{t('moderation_queue')}</div>
<div className={styles.controls}>
<div className={styles.controlsLeft}>
<ModQueueBoardFilter subplebbits={subplebbitsWithMetadata} />
</div>
</div>
)}
<div className={styles.controls}>
{!resolvedAddress ? (
<>
<div className={styles.controlsLeft}>
<ModQueueBoardFilter subplebbits={subplebbitsWithMetadata} />
</div>
<div className={styles.controlsRight}>{alertThresholdControl}</div>
</>
) : (
<>
<div className={styles.controlsLeft}>
<div className={styles.title}>{t('moderation_queue')}</div>
</div>
<div className={styles.controlsRight}>{alertThresholdControl}</div>
</>
)}
</div>
{feed.length === 0 && !hasMore ? (
<div className={styles.empty}>{t('queue_is_empty')}</div>
) : (
<>
<div className={styles.tableHeader}>
<div className={styles.numberHeader}>No.</div>
<div className={styles.excerptHeader}>{t('excerpt')}</div>
<div className={styles.timeHeader}>{t('submitted')}</div>
<div className={styles.actionsHeader}>{t('actions')}</div>
</div>
{/* Use Virtuoso for infinite scroll only when there's more content to paginate */}
{hasMore ? (
<Virtuoso
useWindowScroll
data={feed}
totalCount={feed.length}
endReached={loadMore}
increaseViewportBy={{ bottom: 1200, top: 1200 }}
itemContent={(index, comment) => <ModQueueRow key={comment.cid} comment={comment} isOdd={index % 2 === 0} />}
components={footerComponents}
/>
) : (
{viewMode === 'compact' && !isMobile && (
<>
{feed.map((comment, index) => (
<ModQueueRow key={comment.cid} comment={comment} isOdd={index % 2 === 0} />
))}
{subplebbitError?.message && feed.length === 0 && (
<div className={styles.error}>
<ErrorDisplay error={subplebbitError} />
</div>
<div className={styles.tableHeader}>
<div className={styles.numberHeader}>No.</div>
<div className={styles.excerptHeader}>{t('excerpt')}</div>
<div className={styles.timeHeader}>{t('submitted')}</div>
<div className={styles.actionsHeader}>{t('actions')}</div>
</div>
{hasMore ? (
<Virtuoso
useWindowScroll
data={feed}
totalCount={feed.length}
endReached={loadMore}
increaseViewportBy={{ bottom: 1200, top: 1200 }}
itemContent={(index, comment) => <ModQueueRow key={comment.cid} comment={comment} isOdd={index % 2 === 0} />}
components={footerComponents}
/>
) : (
<>
{feed.map((comment, index) => (
<ModQueueRow key={comment.cid} comment={comment} isOdd={index % 2 === 0} />
))}
{subplebbitError?.message && feed.length === 0 && (
<div className={styles.error}>
<ErrorDisplay error={subplebbitError} />
</div>
)}
<ModQueueFooter hasMore={hasMore} subplebbitAddresses={subplebbitAddresses} />
</>
)}
</>
)}
{viewMode === 'compact' && isMobile && (
<>
{hasMore ? (
<Virtuoso
useWindowScroll
data={feed}
totalCount={feed.length}
endReached={loadMore}
increaseViewportBy={{ bottom: 1200, top: 1200 }}
itemContent={(_index, comment) => <ModQueueCard key={comment.cid} comment={comment} />}
components={footerComponents}
/>
) : (
<>
{feed.map((comment) => (
<ModQueueCard key={comment.cid} comment={comment} />
))}
{subplebbitError?.message && feed.length === 0 && (
<div className={styles.error}>
<ErrorDisplay error={subplebbitError} />
</div>
)}
<ModQueueFooter hasMore={hasMore} subplebbitAddresses={subplebbitAddresses} />
</>
)}
</>
)}
{viewMode === 'feed' && (
<>
{hasMore ? (
<Virtuoso
useWindowScroll
data={feed}
totalCount={feed.length}
endReached={loadMore}
increaseViewportBy={{ bottom: 1200, top: 1200 }}
itemContent={(_index, comment) => <ModQueueFeedPost key={comment.cid} comment={comment} />}
components={footerComponents}
/>
) : (
<>
{feed.map((comment) => (
<ModQueueFeedPost key={comment.cid} comment={comment} />
))}
{subplebbitError?.message && feed.length === 0 && (
<div className={styles.error}>
<ErrorDisplay error={subplebbitError} />
</div>
)}
<ModQueueFooter hasMore={hasMore} subplebbitAddresses={subplebbitAddresses} />
</>
)}
<ModQueueFooter hasMore={hasMore} subplebbitAddresses={subplebbitAddresses} />
</>
)}
</>
+61
View File
@@ -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; }
}
+44 -3
View File
@@ -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
<div className={styles.thread}>
<div className={styles.postContainer}>
{isMobile ? (
<PostMobile post={comment} roles={roles} showAllReplies={showAllReplies} showReplies={showReplies} targetReplyCid={targetReplyCid} />
<PostMobile
post={comment}
roles={roles}
showAllReplies={showAllReplies}
showReplies={showReplies}
targetReplyCid={targetReplyCid}
isModQueue={isModQueue}
modQueueStatus={modQueueStatus}
modQueueError={modQueueError}
isPublishing={isPublishing}
onApprove={onApprove}
onReject={onReject}
/>
) : (
<PostDesktop post={comment} roles={roles} showAllReplies={showAllReplies} showReplies={showReplies} targetReplyCid={targetReplyCid} />
<PostDesktop
post={comment}
roles={roles}
showAllReplies={showAllReplies}
showReplies={showReplies}
targetReplyCid={targetReplyCid}
isModQueue={isModQueue}
modQueueStatus={modQueueStatus}
modQueueError={modQueueError}
isPublishing={isPublishing}
onApprove={onApprove}
onReject={onReject}
/>
)}
</div>
</div>