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
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "نشر",
"approved": "موافق عليه",
"rejected": "مرفوض",
"submitted": "تم الإرسال"
"submitted": "تم الإرسال",
"hours": "ساعات",
"minutes": "دقائق"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "প্রকাশনা",
"approved": "অনুমোদিত",
"rejected": "প্রত্যাখ্যান",
"submitted": "জমা দেওয়া হয়েছে"
"submitted": "জমা দেওয়া হয়েছে",
"hours": "ঘণ্টা",
"minutes": "মিনিট"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Publikování",
"approved": "Schváleno",
"rejected": "Zamítnuto",
"submitted": "Odesláno"
"submitted": "Odesláno",
"hours": "hodin",
"minutes": "minut"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Udgivelse",
"approved": "Godkendt",
"rejected": "Afvist",
"submitted": "Indsendt"
"submitted": "Indsendt",
"hours": "timer",
"minutes": "minutter"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Veröffentlichung",
"approved": "Genehmigt",
"rejected": "Abgelehnt",
"submitted": "Eingereicht"
"submitted": "Eingereicht",
"hours": "Stunden",
"minutes": "Minuten"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Δημοσίευση",
"approved": "Εγκεκριμένο",
"rejected": "Απορρίφθηκε",
"submitted": "Υποβλήθηκε"
"submitted": "Υποβλήθηκε",
"hours": "ώρες",
"minutes": "λεπτά"
}
+2
View File
@@ -261,6 +261,8 @@
"reject": "Reject",
"filter_by_board": "Filter by board",
"mod_queue_alert_threshold": "Mod queue alert threshold",
"hours": "hours",
"minutes": "minutes",
"no_content": "No content",
"excerpt": "Excerpt",
"publishing": "Publishing",
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Publicando",
"approved": "Aprobado",
"rejected": "Rechazado",
"submitted": "Enviado"
"submitted": "Enviado",
"hours": "horas",
"minutes": "minutos"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "در حال انتشار",
"approved": "تایید شده",
"rejected": "رد شده",
"submitted": "ارسال شده"
"submitted": "ارسال شده",
"hours": "ساعت",
"minutes": "دقیقه"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Julkaiseminen",
"approved": "Hyväksytty",
"rejected": "Hylätty",
"submitted": "Lähetetty"
"submitted": "Lähetetty",
"hours": "tuntia",
"minutes": "minuuttia"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Pag-publish",
"approved": "Naaprubado",
"rejected": "Tinanggihan",
"submitted": "Isinumite"
"submitted": "Isinumite",
"hours": "oras",
"minutes": "minuto"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Publication",
"approved": "Approuvé",
"rejected": "Rejeté",
"submitted": "Soumis"
"submitted": "Soumis",
"hours": "heures",
"minutes": "minutes"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "מפרסם",
"approved": "אושר",
"rejected": "נדחה",
"submitted": "נשלח"
"submitted": "נשלח",
"hours": "שעות",
"minutes": "דקות"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "प्रकाशन",
"approved": "अनुमोदित",
"rejected": "अस्वीकृत",
"submitted": "सबमिट किया गया"
"submitted": "सबमिट किया गया",
"hours": "घंटे",
"minutes": "मिनट"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Közzététel",
"approved": "Jóváhagyva",
"rejected": "Elutasítva",
"submitted": "Beküldve"
"submitted": "Beküldve",
"hours": "óra",
"minutes": "perc"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Menerbitkan",
"approved": "Disetujui",
"rejected": "Ditolak",
"submitted": "Dikirim"
"submitted": "Dikirim",
"hours": "jam",
"minutes": "menit"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Pubblicazione",
"approved": "Approvato",
"rejected": "Rifiutato",
"submitted": "Inviato"
"submitted": "Inviato",
"hours": "ore",
"minutes": "minuti"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "公開中",
"approved": "承認済み",
"rejected": "却下",
"submitted": "送信済み"
"submitted": "送信済み",
"hours": "時間",
"minutes": "分"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "게시 중",
"approved": "승인됨",
"rejected": "거부됨",
"submitted": "제출됨"
"submitted": "제출됨",
"hours": "시간",
"minutes": "분"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "प्रकाशन करत आहे",
"approved": "मंजूर",
"rejected": "नाकारले",
"submitted": "सबमिट केले"
"submitted": "सबमिट केले",
"hours": "तास",
"minutes": "मिनिटे"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Publiceren",
"approved": "Goedgekeurd",
"rejected": "Afgewezen",
"submitted": "Ingediend"
"submitted": "Ingediend",
"hours": "uur",
"minutes": "minuten"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Publisering",
"approved": "Godkjent",
"rejected": "Avslått",
"submitted": "Sendt inn"
"submitted": "Sendt inn",
"hours": "timer",
"minutes": "minutter"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Publikowanie",
"approved": "Zatwierdzone",
"rejected": "Odrzucone",
"submitted": "Przesłane"
"submitted": "Przesłane",
"hours": "godzin",
"minutes": "minut"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Publicando",
"approved": "Aprovado",
"rejected": "Rejeitado",
"submitted": "Enviado"
"submitted": "Enviado",
"hours": "horas",
"minutes": "minutos"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Publicare",
"approved": "Aprobat",
"rejected": "Respins",
"submitted": "Trimis"
"submitted": "Trimis",
"hours": "ore",
"minutes": "minute"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Публикация",
"approved": "Одобрено",
"rejected": "Отклонено",
"submitted": "Отправлено"
"submitted": "Отправлено",
"hours": "часов",
"minutes": "минут"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Publikimi",
"approved": "Aprovuar",
"rejected": "Refuzuar",
"submitted": "Dërguar"
"submitted": "Dërguar",
"hours": "orë",
"minutes": "minuta"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Publicerar",
"approved": "Godkänd",
"rejected": "Avvisad",
"submitted": "Inskickat"
"submitted": "Inskickat",
"hours": "timmar",
"minutes": "minuter"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "ప్రచురించడం",
"approved": "అనుమోదించబడింది",
"rejected": "తిరస్కరించబడింది",
"submitted": "సమర్పించబడింది"
"submitted": "సమర్పించబడింది",
"hours": "గంటలు",
"minutes": "నిమిషాలు"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "กำลังเผยแพร่",
"approved": "อนุมัติแล้ว",
"rejected": "ปฏิเสธ",
"submitted": "ส่งแล้ว"
"submitted": "ส่งแล้ว",
"hours": "ชั่วโมง",
"minutes": "นาที"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Yayınlama",
"approved": "Onaylandı",
"rejected": "Reddedildi",
"submitted": "Gönderildi"
"submitted": "Gönderildi",
"hours": "saat",
"minutes": "dakika"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Публікація",
"approved": "Схвалено",
"rejected": "Відхилено",
"submitted": "Відправлено"
"submitted": "Відправлено",
"hours": "годин",
"minutes": "хвилин"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "اشاعت",
"approved": "منظور شدہ",
"rejected": "مسترد",
"submitted": "جمع کرایا گیا"
"submitted": "جمع کرایا گیا",
"hours": "گھنٹے",
"minutes": "منٹ"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "Đang xuất bản",
"approved": "Đã phê duyệt",
"rejected": "Đã từ chối",
"submitted": "Đã gửi"
"submitted": "Đã gửi",
"hours": "giờ",
"minutes": "phút"
}
+3 -1
View File
@@ -267,5 +267,7 @@
"publishing": "发布中",
"approved": "已批准",
"rejected": "已拒绝",
"submitted": "已提交"
"submitted": "已提交",
"hours": "小时",
"minutes": "分钟"
}
+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>