feat(mod-queue): add minutes support to alert threshold

This commit is contained in:
plebeius
2026-01-11 15:20:48 +01:00
parent 49004109f8
commit ab353b59f6
38 changed files with 191 additions and 96 deletions
+44 -8
View File
@@ -1,21 +1,57 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type AlertThresholdUnit = 'hours' | 'minutes';
interface ModQueueState {
alertThresholdHours: number;
alertThresholdValue: number;
alertThresholdUnit: AlertThresholdUnit;
selectedBoardFilter: string | null;
setAlertThresholdHours: (hours: number) => void;
setAlertThreshold: (value: number, unit: AlertThresholdUnit) => void;
setSelectedBoardFilter: (boardAddress: string | null) => void;
// Helper to get threshold in seconds for calculations
getAlertThresholdSeconds: () => number;
}
const useModQueueStore = create<ModQueueState>()(
persist(
(set) => ({
alertThresholdHours: 6,
selectedBoardFilter: null,
setAlertThresholdHours: (hours) => set({ alertThresholdHours: hours }),
setSelectedBoardFilter: (boardAddress) => set({ selectedBoardFilter: boardAddress }),
}),
(set, get) => {
// Migration: Check for old format in localStorage
let initialState = { alertThresholdValue: 6, alertThresholdUnit: 'hours' as AlertThresholdUnit };
if (typeof window !== 'undefined') {
try {
const stored = localStorage.getItem('mod-queue-storage');
if (stored) {
const parsed = JSON.parse(stored);
// If old format exists, migrate it
if (parsed.state?.alertThresholdHours !== undefined) {
initialState = {
alertThresholdValue: parsed.state.alertThresholdHours,
alertThresholdUnit: 'hours',
};
} else if (parsed.state?.alertThresholdValue !== undefined) {
initialState = {
alertThresholdValue: parsed.state.alertThresholdValue,
alertThresholdUnit: parsed.state.alertThresholdUnit || 'hours',
};
}
}
} catch (e) {
// Ignore parse errors, use defaults
}
}
return {
...initialState,
selectedBoardFilter: null,
setAlertThreshold: (value, unit) => set({ alertThresholdValue: value, alertThresholdUnit: unit }),
setSelectedBoardFilter: (boardAddress) => set({ selectedBoardFilter: boardAddress }),
getAlertThresholdSeconds: () => {
const { alertThresholdValue, alertThresholdUnit } = get();
return alertThresholdUnit === 'hours' ? alertThresholdValue * 3600 : alertThresholdValue * 60;
},
};
},
{
name: 'mod-queue-storage',
},
+8 -2
View File
@@ -39,6 +39,14 @@
color: var(--body-font-color);
}
.alertThresholdUnitSelect {
padding: 4px;
border: var(--mod-queue-table-border);
background: var(--mod-queue-header-background-color);
color: var(--body-font-color);
margin-left: 4px;
}
.tableWrapper {
border: var(--mod-queue-table-border);
}
@@ -147,8 +155,6 @@
color: var(--mod-queue-alert-color);
font-weight: bold;
animation: blink 2s infinite;
/* Synchronize all animations by starting from the same phase */
animation-delay: calc(var(--mod-queue-blink-phase, 0s) * -1);
}
@keyframes blink {
+35 -52
View File
@@ -48,7 +48,7 @@ type ModerationAction = 'approve' | 'reject' | null;
const ModQueueRow = ({ comment, showBoardColumn = false }: ModQueueRowProps) => {
const { t } = useTranslation();
const { alertThresholdHours } = useModQueueStore();
const { getAlertThresholdSeconds } = useModQueueStore();
const [initiatedAction, setInitiatedAction] = useState<ModerationAction>(null);
// handle pending mod or author edit
@@ -65,7 +65,8 @@ const ModQueueRow = ({ comment, showBoardColumn = false }: ModQueueRowProps) =>
const boardPath = useBoardPath(subplebbitAddress);
const timeWaiting = Date.now() / 1000 - timestamp;
const isOverThreshold = timeWaiting > alertThresholdHours * 3600;
const alertThresholdSeconds = getAlertThresholdSeconds();
const isOverThreshold = timeWaiting > alertThresholdSeconds;
// Only show alert animation for comments awaiting approval (not approved or rejected)
const isAwaitingApproval = !alreadyApproved && !alreadyRejected;
@@ -256,18 +257,18 @@ interface ModQueueButtonProps {
// and reports its status via callback
interface ModQueueCountItemProps {
comment: Comment;
alertThresholdHours: number;
alertThresholdSeconds: number;
onStatusChange: (cid: string, status: { awaiting: boolean; urgent: boolean }) => void;
}
const ModQueueCountItem = ({ comment, alertThresholdHours, onStatusChange }: ModQueueCountItemProps) => {
const ModQueueCountItem = ({ comment, alertThresholdSeconds, onStatusChange }: ModQueueCountItemProps) => {
const { editedComment } = useEditedComment({ comment });
const displayComment = editedComment || comment;
const { cid, approved, removed, timestamp } = displayComment;
const isAwaiting = approved !== true && removed !== true;
const timeWaiting = Date.now() / 1000 - timestamp;
const isUrgent = isAwaiting && timeWaiting > alertThresholdHours * 3600;
const isUrgent = isAwaiting && timeWaiting > alertThresholdSeconds;
// Report status changes to parent - useEffect is appropriate here for syncing with parent state
useEffect(() => {
@@ -280,12 +281,12 @@ const ModQueueCountItem = ({ comment, alertThresholdHours, onStatusChange }: Mod
// Inner component that handles counting with useEditedComment for each item
interface ModQueueButtonContentProps {
feed: Comment[];
alertThresholdHours: number;
alertThresholdSeconds: number;
boardIdentifier?: string;
isMobile?: boolean;
}
const ModQueueButtonContent = ({ feed, alertThresholdHours, boardIdentifier, isMobile }: ModQueueButtonContentProps) => {
const ModQueueButtonContent = ({ feed, alertThresholdSeconds, boardIdentifier, isMobile }: ModQueueButtonContentProps) => {
const { t } = useTranslation();
const [statusMap, setStatusMap] = useState<Map<string, { awaiting: boolean; urgent: boolean }>>(new Map());
@@ -344,7 +345,7 @@ const ModQueueButtonContent = ({ feed, alertThresholdHours, boardIdentifier, isM
<>
{/* Render counter items to track real-time moderation status */}
{feed.map((item) => (
<ModQueueCountItem key={item.cid} comment={item} alertThresholdHours={alertThresholdHours} onStatusChange={handleStatusChange} />
<ModQueueCountItem key={item.cid} comment={item} alertThresholdSeconds={alertThresholdSeconds} onStatusChange={handleStatusChange} />
))}
{isMobile ? buttonContent : <>[{buttonContent}]</>}
</>
@@ -352,7 +353,7 @@ const ModQueueButtonContent = ({ feed, alertThresholdHours, boardIdentifier, isM
};
export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => {
const { alertThresholdHours } = useModQueueStore();
const { getAlertThresholdSeconds } = useModQueueStore();
const { accountSubplebbits } = useAccountSubplebbits();
const accountSubplebbitAddresses = useMemo(() => Object.keys(accountSubplebbits || {}), [accountSubplebbits]);
const defaultSubplebbits = useDefaultSubplebbits();
@@ -388,13 +389,14 @@ export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProp
return null;
}
return <ModQueueButtonContent feed={feed} alertThresholdHours={alertThresholdHours} boardIdentifier={boardIdentifier} isMobile={isMobile} />;
const alertThresholdSeconds = getAlertThresholdSeconds();
return <ModQueueButtonContent feed={feed} alertThresholdSeconds={alertThresholdSeconds} boardIdentifier={boardIdentifier} isMobile={isMobile} />;
};
export const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueViewProps) => {
const { t } = useTranslation();
const params = useParams();
const { selectedBoardFilter, alertThresholdHours, setAlertThresholdHours } = useModQueueStore();
const { selectedBoardFilter, alertThresholdValue, alertThresholdUnit, setAlertThreshold } = useModQueueStore();
const { accountSubplebbits } = useAccountSubplebbits();
const accountSubplebbitAddresses = Object.keys(accountSubplebbits);
const defaultSubplebbits = useDefaultSubplebbits();
@@ -436,43 +438,6 @@ export const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueV
setResetFunction(reset);
}, [reset, setResetFunction]);
// Check if any feed items need the blinking animation (awaiting approval + over threshold)
// This is a heuristic based on raw feed data - real-time edits might change this,
// but it's good enough to avoid running the interval when nothing needs to blink
const hasBlinkingItems = useMemo(() => {
const thresholdSeconds = alertThresholdHours * 3600;
const now = Date.now() / 1000;
return feed.some((comment) => {
const isAwaiting = comment.approved !== true && comment.removed !== true;
const timeWaiting = now - comment.timestamp;
return isAwaiting && timeWaiting > thresholdSeconds;
});
}, [feed, alertThresholdHours]);
// Synchronize blinking animation across all rows
// Set a CSS variable with the current animation phase so all elements start from the same point
// Update frequently to ensure elements rendered at different times stay synchronized
// Only run when there are items that actually need the blinking animation
useEffect(() => {
if (!hasBlinkingItems) {
return;
}
const ANIMATION_DURATION = 2000; // 2 seconds
const UPDATE_INTERVAL = 100; // Update every 100ms for smooth synchronization
const updateAnimationPhase = () => {
// Calculate current phase in the animation cycle (0 to 2 seconds)
const phase = (Date.now() % ANIMATION_DURATION) / 1000;
document.documentElement.style.setProperty('--mod-queue-blink-phase', `${phase}s`);
};
// Update immediately and then at regular intervals
updateAnimationPhase();
const interval = setInterval(updateAnimationPhase, UPDATE_INTERVAL);
return () => clearInterval(interval);
}, [hasBlinkingItems]);
const loadingStateString = useFeedStateString(subplebbitAddresses) || t('loading');
const showBoardColumn = !resolvedAddress && !selectedBoardFilter;
@@ -494,11 +459,29 @@ export const ModQueueView = ({ boardIdentifier: propBoardIdentifier }: ModQueueV
<input
type='number'
min='1'
value={alertThresholdHours}
onChange={(e) => setAlertThresholdHours(Number(e.target.value))}
step={alertThresholdUnit === 'minutes' ? '1' : '1'}
value={alertThresholdValue}
onChange={(e) => setAlertThreshold(Number(e.target.value), alertThresholdUnit)}
className={styles.alertThresholdInput}
/>{' '}
{t('hours')}
/>
<select
value={alertThresholdUnit}
onChange={(e) => {
const newUnit = e.target.value as 'hours' | 'minutes';
// Convert value when switching units
const newValue =
alertThresholdUnit === 'hours' && newUnit === 'minutes'
? alertThresholdValue * 60
: alertThresholdUnit === 'minutes' && newUnit === 'hours'
? Math.round(alertThresholdValue / 60)
: alertThresholdValue;
setAlertThreshold(Math.max(1, newValue), newUnit);
}}
className={styles.alertThresholdUnitSelect}
>
<option value='minutes'>{t('minutes')}</option>
<option value='hours'>{t('hours')}</option>
</select>
</label>
</div>
</div>