fix: queue store stability + persistence (#5909)

* fix: queue store stability + persistence

* fix: lint

* feat: set to draft btn

* feat: migrate to indexed db rather than local storage for moderation checklist storage (keep session + perms alone)

* fix: storage cleanup + lint

* fix: invalidation fixes
This commit is contained in:
Calum H.
2026-04-27 17:39:32 +01:00
committed by GitHub
parent a2eed001b2
commit 3f8fd9cb56
19 changed files with 1548 additions and 387 deletions

View File

@@ -273,7 +273,7 @@ async function closeReport(reply = false) {
closed: true,
},
})
updateThread(props.report.thread)
await refreshReportCaches()
didCloseReport.value = true
} catch (err: any) {
addNotification({
@@ -292,7 +292,7 @@ async function reopenReport() {
closed: false,
},
})
updateThread(props.report.thread)
await refreshReportCaches()
didCloseReport.value = false
} catch (err: any) {
addNotification({
@@ -309,6 +309,18 @@ const formatDateTime = useFormatDateTime({
dateStyle: 'long',
})
async function refreshReportCaches() {
await Promise.allSettled([refreshThread(), refreshNuxtData('new-moderation-reports')])
}
async function refreshThread() {
const threadId = props.report.thread?.id ?? props.report.thread_id
if (!threadId) return
const thread = await useBaseFetch(`thread/${threadId}`)
updateThread(thread)
}
function updateThread(newThread: any) {
if (props.report.thread) {
Object.assign(props.report.thread, newThread)

View File

@@ -477,6 +477,8 @@ async function batchMarkRemaining(verdict: 'safe' | 'unsafe') {
backToFileList()
}
}
emit('refetch')
} catch (error) {
console.error('Failed to batch update:', error)
addNotification({
@@ -549,6 +551,8 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
text: `This issue has been flagged as malicious.${otherText}`,
})
}
emit('refetch')
} catch (error) {
console.error('Failed to update detail status:', error)
addNotification({

View File

@@ -79,13 +79,13 @@
</div>
<div class="flex items-center gap-2">
<ButtonStyled
v-if="moderationStore.isQueueMode && moderationStore.queueLength > 1"
v-if="moderationQueue.isQueueMode && moderationQueue.queueLength > 1"
color="brand"
@click="skipToNextProject"
>
<button>
<RightArrowIcon aria-hidden="true" />
Next project ({{ moderationStore.queueLength }} left)
Next project ({{ moderationQueue.queueLength }} left)
</button>
</ButtonStyled>
</div>
@@ -112,13 +112,13 @@
</div>
<div class="flex items-center gap-2">
<ButtonStyled
v-if="moderationStore.isQueueMode && moderationStore.queueLength > 1"
v-if="moderationQueue.isQueueMode && moderationQueue.queueLength > 1"
color="brand"
@click="skipToNextProject"
>
<button>
<RightArrowIcon aria-hidden="true" />
Next project ({{ moderationStore.queueLength }} left)
Next project ({{ moderationQueue.queueLength }} left)
</button>
</ButtonStyled>
</div>
@@ -131,9 +131,9 @@
<div v-if="done">
<p>
You are done moderating this project!
<template v-if="moderationStore.hasItems">
<template v-if="moderationQueue.hasItems">
There are
{{ moderationStore.queueLength }} left.
{{ moderationQueue.queueLength }} left.
</template>
</p>
</div>
@@ -159,6 +159,7 @@
:disabled="false"
:heading-buttons="false"
:on-image-upload="onUploadHandler"
@input="persistGeneratedMessageState"
/>
<StyledInput
v-else
@@ -167,7 +168,7 @@
placeholder="No message generated."
autocomplete="off"
input-class="h-[400px] font-mono"
@input="persistState"
@input="persistGeneratedMessageState"
/>
</div>
</div>
@@ -331,10 +332,10 @@
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-surface-5 pt-4"
>
<div class="flex items-center gap-2">
<ButtonStyled v-if="!done && !generatedMessage && moderationStore.hasItems">
<ButtonStyled v-if="!done && !generatedMessage && moderationQueue.hasItems">
<button @click="skipCurrentProject">
<XIcon aria-hidden="true" />
Skip ({{ moderationStore.queueLength }} left)
Skip ({{ moderationQueue.queueLength }} left)
</button>
</ButtonStyled>
</div>
@@ -345,7 +346,7 @@
<button @click="endChecklist(undefined)">
<template v-if="hasNextProject">
<RightArrowIcon aria-hidden="true" />
Next project ({{ moderationStore.queueLength }} left)
Next project ({{ moderationQueue.queueLength }} left)
</template>
<template v-else>
<CheckIcon aria-hidden="true" />
@@ -441,10 +442,10 @@ import {
} from '@modrinth/assets'
import {
type Action,
type ActionState,
type ButtonAction,
checklist,
type ConditionalButtonAction,
deserializeActionStates,
type DropdownAction,
expandVariables,
finalPermissionMessages,
@@ -461,7 +462,6 @@ import {
keybinds,
type MultiSelectChipsAction,
processMessage,
serializeActionStates,
type Stage,
type ToggleAction,
} from '@modrinth/moderation'
@@ -486,13 +486,27 @@ import {
type ProjectStatus,
renderHighlightedString,
} from '@modrinth/utils'
import { computedAsync, useDebounceFn, useLocalStorage } from '@vueuse/core'
import { useQueryClient } from '@tanstack/vue-query'
import { computedAsync, useDebounceFn } from '@vueuse/core'
import type { Component } from 'vue'
import { useGeneratedState } from '~/composables/generated'
import { useImageUpload } from '~/composables/image-upload.ts'
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
import type { LockAcquireResponse } from '~/store/moderation.ts'
import { useModerationStore } from '~/store/moderation.ts'
import {
clearChecklistProgressState,
clearGeneratedMessageState as clearPersistedGeneratedMessageState,
createEmptyGeneratedMessageState,
loadChecklistActionStates,
loadChecklistStage,
loadChecklistTextInputs,
loadGeneratedMessageState,
saveChecklistActionStates,
saveChecklistStage,
saveChecklistTextInputs,
saveGeneratedMessageState,
} from '~/services/moderation-checklist-storage.ts'
import { type LockAcquireResponse, useModerationQueue } from '~/services/moderation-queue.ts'
import KeybindsModal from './ChecklistKeybindsModal.vue'
import ModpackPermissionsFlow from './ModpackPermissionsFlow.vue'
@@ -508,9 +522,10 @@ const props = defineProps<{
collapsed: boolean
}>()
const { projectV2, projectV3 } = injectProjectPageContext()
const { projectV2, projectV3, invalidate } = injectProjectPageContext()
const moderationStore = useModerationStore()
const moderationQueue = useModerationQueue()
const queryClient = useQueryClient()
const tags = useGeneratedState()
const auth = await useAuth()
@@ -548,7 +563,7 @@ async function handleVisibilityChange() {
if (document.visibilityState === 'visible' && lockStatus.value?.isOwnLock) {
// Immediately refresh the lock when returning to the tab
// This handles cases where the heartbeat was throttled while backgrounded
const refreshResult = await moderationStore.refreshLock()
const refreshResult = await moderationQueue.refreshLock()
if (!refreshResult.success) {
handleLockLost(refreshResult)
return
@@ -594,7 +609,7 @@ function clearLockCountdown() {
function startLockHeartbeat() {
lockCheckInterval.value = setInterval(
async () => {
const result = await moderationStore.refreshLock()
const result = await moderationQueue.refreshLock()
if (!result.success) {
handleLockLost(result)
}
@@ -667,7 +682,7 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
// Quick re-check if close to expiry (last 5 seconds of TTL)
if (now - next.validatedAt > PREFETCH_STALE_MS - 5000) {
const recheck = await moderationStore.checkLock(next.projectId)
const recheck = await moderationQueue.checkLock(next.projectId)
if (recheck.locked && !recheck.expired) {
// Project got locked, remove from queue and try next
prefetchQueue.value.shift()
@@ -679,7 +694,9 @@ async function navigateToNextUnlockedProject(): Promise<boolean> {
prefetchQueue.value.shift()
// Mark skipped projects as completed
next.skippedIds.forEach((id) => moderationStore.completeCurrentProject(id, 'skipped'))
await Promise.all(
next.skippedIds.map((id) => moderationQueue.completeCurrentProject(id, 'skipped')),
)
if (next.skippedIds.length > 0) {
addNotification({
@@ -734,11 +751,32 @@ async function onUploadHandler(file: File) {
}
const useSimpleEditor = ref(false)
const message = ref('')
const generatedMessage = ref(false)
const checklistPersistenceProjectSlug = projectV2.value.slug
const persistedGeneratedMessage = import.meta.client
? await loadGeneratedMessageState(checklistPersistenceProjectSlug)
: createEmptyGeneratedMessageState()
const message = ref(
typeof persistedGeneratedMessage.message === 'string' ? persistedGeneratedMessage.message : '',
)
const generatedMessage = ref(persistedGeneratedMessage.generated === true)
const loadingMessage = ref(false)
const done = ref(false)
function persistGeneratedMessageState() {
void saveGeneratedMessageState(checklistPersistenceProjectSlug, {
generated: generatedMessage.value,
message: message.value,
})
}
function clearGeneratedMessageState() {
generatedMessage.value = false
message.value = ''
void clearPersistedGeneratedMessageState(checklistPersistenceProjectSlug)
}
watch([generatedMessage, message], persistGeneratedMessageState, { flush: 'sync' })
function handleModpackPermissionsComplete() {
modpackPermissionsComplete.value = true
}
@@ -752,7 +790,7 @@ async function handleExit() {
// Release if we own the lock, or if there was an error checking (we might still own it)
const projectId = projectV2.value?.id
if (projectId && (lockStatus.value?.isOwnLock || lockError.value)) {
const released = await moderationStore.releaseLock(projectId)
const released = await moderationQueue.releaseLock(projectId)
if (!released && lockStatus.value?.isOwnLock) {
console.warn('Failed to release moderation lock for project:', projectId)
}
@@ -770,7 +808,7 @@ async function confirmTakeOverOverride() {
console.warn('[confirmTakeOverOverride] No project ID available')
return
}
const result = await moderationStore.overrideLock(projectId)
const result = await moderationQueue.overrideLock(projectId)
if (result.success) {
addNotification({
@@ -825,7 +863,7 @@ async function batchCheckLocksWithMetadata(
projectIds.map(async (id) => {
// Parallel: check lock AND fetch project metadata
const [lockResponse, projectData] = await Promise.all([
moderationStore.checkLock(id),
moderationQueue.checkLock(id),
useBaseFetch(`project/${id}`, { method: 'GET' }).catch(() => null),
])
@@ -856,7 +894,7 @@ async function batchCheckLocksWithMetadata(
// Maintain a queue of prefetched unlocked projects for instant navigation
async function maintainPrefetchQueue() {
if (isPrefetching.value) return
if (!moderationStore.isQueueMode) return
if (!moderationQueue.isQueueMode) return
const currentProjectId = projectV2.value?.id
@@ -879,7 +917,7 @@ async function maintainPrefetchQueue() {
// 4. Get remaining queue items (excluding current and already prefetched)
const prefetchedIds = new Set(prefetchQueue.value.map((p) => p.projectId))
const queueItems = [...moderationStore.currentQueue.items]
const queueItems = [...moderationQueue.currentQueue.items]
const currentIndex = currentProjectId ? queueItems.indexOf(currentProjectId) : -1
const remainingItems =
currentIndex >= 0 ? queueItems.slice(currentIndex + 1) : queueItems.slice(1)
@@ -951,12 +989,12 @@ async function skipToNextProject() {
return
}
debug('[skipToNextProject] Starting. Current project:', currentProjectId)
debug('[skipToNextProject] Queue before complete:', [...moderationStore.currentQueue.items])
debug('[skipToNextProject] Queue before complete:', [...moderationQueue.currentQueue.items])
moderationStore.completeCurrentProject(currentProjectId, 'skipped')
await moderationQueue.completeCurrentProject(currentProjectId, 'skipped')
debug('[skipToNextProject] Queue after complete:', [...moderationStore.currentQueue.items])
debug('[skipToNextProject] hasItems:', moderationStore.hasItems)
debug('[skipToNextProject] Queue after complete:', [...moderationQueue.currentQueue.items])
debug('[skipToNextProject] hasItems:', moderationQueue.hasItems)
// Use prefetched data if available
if (await navigateToNextUnlockedProject()) {
@@ -968,7 +1006,7 @@ async function skipToNextProject() {
// Fallback: batch check remaining projects with metadata (excluding current)
const remainingIds: string[] = []
const queueItems = moderationStore.currentQueue.items
const queueItems = moderationQueue.currentQueue.items
// Build list of remaining projects, excluding current
for (const id of queueItems) {
@@ -1011,7 +1049,7 @@ async function skipToNextProject() {
}
return
}
moderationStore.completeCurrentProject(id, 'skipped')
await moderationQueue.completeCurrentProject(id, 'skipped')
skippedCount++
}
@@ -1034,8 +1072,7 @@ function resetProgress() {
textInputValues.value = {}
done.value = false
generatedMessage.value = false
message.value = ''
clearGeneratedMessageState()
loadingMessage.value = false
localStorage.removeItem(`modpack-permissions-${projectV2.value.id}`)
@@ -1061,8 +1098,11 @@ function findFirstValidStage(): number {
}
const currentStageObj = computed(() => checklist[currentStage.value])
const currentStage = useLocalStorage(`moderation-stage-${projectV2.value.slug}`, () =>
findFirstValidStage(),
const persistedStage = import.meta.client
? await loadChecklistStage(checklistPersistenceProjectSlug)
: null
const currentStage = ref(
persistedStage !== null && checklist[persistedStage] ? persistedStage : findFirstValidStage(),
)
const stageTextExpanded = computedAsync(async () => {
@@ -1081,37 +1121,27 @@ const stageTextExpanded = computedAsync(async () => {
return null
}, null)
interface ActionState {
selected: boolean
value?: any
}
const persistedActionStates = useLocalStorage(
`moderation-actions-${projectV2.value.slug}`,
{},
{
serializer: {
read: (v: any) => (v ? deserializeActionStates(v) : {}),
write: (v: any) => serializeActionStates(v),
},
},
)
const persistedActionStates = import.meta.client
? await loadChecklistActionStates(checklistPersistenceProjectSlug)
: {}
const router = useRouter()
const persistedTextInputs = useLocalStorage(
`moderation-inputs-${projectV2.value.slug}`,
{} as Record<string, string>,
)
const persistedTextInputs = import.meta.client
? await loadChecklistTextInputs(checklistPersistenceProjectSlug)
: {}
const actionStates = ref<Record<string, ActionState>>(persistedActionStates.value)
const textInputValues = ref<Record<string, string>>(persistedTextInputs.value)
const actionStates = ref<Record<string, ActionState>>(persistedActionStates)
const textInputValues = ref<Record<string, string>>(persistedTextInputs)
const persistState = () => {
persistedActionStates.value = actionStates.value
persistedTextInputs.value = textInputValues.value
void saveChecklistActionStates(checklistPersistenceProjectSlug, actionStates.value)
void saveChecklistTextInputs(checklistPersistenceProjectSlug, textInputValues.value)
}
watch(currentStage, (stage) => {
void saveChecklistStage(checklistPersistenceProjectSlug, stage)
})
watch(actionStates, persistState, { deep: true })
watch(textInputValues, persistState, { deep: true })
@@ -1141,7 +1171,7 @@ function handleKeybinds(event: KeyboardEvent) {
isLoadingMessage: loadingMessage.value,
isModpackPermissionsStage: isModpackPermissionsStage.value,
futureProjectCount: moderationStore.queueLength,
futureProjectCount: moderationQueue.queueLength,
visibleActionsCount: visibleActions.value.length,
focusedActionIndex: focusedActionIndex.value,
@@ -1249,14 +1279,14 @@ onMounted(async () => {
}
// Try to acquire lock
const result = await moderationStore.acquireLock(projectV2.value.id)
const result = await moderationQueue.acquireLock(projectV2.value.id)
if (result.success) {
handleLockAcquired()
} else if (result.locked_by) {
// Actually locked by another moderator
// In queue mode with more projects - auto-skip to next project
if (moderationStore.isQueueMode && moderationStore.queueLength > 1) {
if (moderationQueue.isQueueMode && moderationQueue.queueLength > 1) {
addNotification({
title: 'Project locked',
text: `Skipped project locked by @${result.locked_by.username}.`,
@@ -1317,7 +1347,7 @@ onUnmounted(() => {
// Release lock if we own it (navigation away without explicit exit)
const projectId = projectV2.value?.id
if (projectId && lockStatus.value?.isOwnLock) {
moderationStore.releaseLock(projectId)
void moderationQueue.releaseLock(projectId)
}
// Clear prefetch state to prevent memory leaks
@@ -1809,8 +1839,7 @@ function nextStage() {
}
function goBackToStages() {
generatedMessage.value = false
message.value = ''
clearGeneratedMessageState()
let targetStage = checklist.length - 1
while (targetStage >= 0) {
@@ -1923,6 +1952,16 @@ function generateModpackMessage(allFiles: {
}
const hasNextProject = ref(false)
async function refreshModerationCaches(threadId?: string) {
const refreshes: Promise<unknown>[] = [invalidate(), refreshNuxtData('moderation-projects')]
if (threadId) {
refreshes.push(queryClient.invalidateQueries({ queryKey: ['thread', threadId] }))
}
await Promise.allSettled(refreshes)
}
async function sendMessage(status: ProjectStatus) {
// Capture project data upfront to avoid null issues during async operations
const projectId = projectV2.value?.id
@@ -1966,10 +2005,12 @@ async function sendMessage(status: ProjectStatus) {
})
}
const willHaveNext = moderationStore.completeCurrentProject(projectId, 'completed')
await refreshModerationCaches(threadId)
const willHaveNext = await moderationQueue.completeCurrentProject(projectId, 'completed')
await Promise.race([
moderationStore.releaseLock(projectId),
moderationQueue.releaseLock(projectId),
new Promise((r) => setTimeout(r, 2000)),
])
@@ -1977,6 +2018,7 @@ async function sendMessage(status: ProjectStatus) {
// to avoid the race condition where done=true renders with hasNextProject=false
hasNextProject.value = willHaveNext
done.value = true
clearGeneratedMessageState()
} catch (error) {
console.error('Error submitting moderation:', error)
addNotification({
@@ -2000,7 +2042,7 @@ async function endChecklist(status?: string) {
await nextTick()
if (moderationStore.currentQueue.total > 1) {
if (moderationQueue.currentQueue.total > 1) {
addNotification({
title: 'Moderation completed',
text: `You have completed the moderation queue.`,
@@ -2019,7 +2061,7 @@ async function endChecklist(status?: string) {
// Fallback: batch check remaining projects with metadata
const remainingIds: string[] = []
const currentProjectId = projectV2.value?.id
const queueItems = moderationStore.currentQueue.items
const queueItems = moderationQueue.currentQueue.items
// Build list of remaining projects, excluding current
for (const id of queueItems) {
@@ -2064,7 +2106,7 @@ async function endChecklist(status?: string) {
foundUnlocked = true
break
}
moderationStore.completeCurrentProject(id, 'skipped')
await moderationQueue.completeCurrentProject(id, 'skipped')
skippedCount++
}
@@ -2100,11 +2142,11 @@ async function skipCurrentProject() {
}
await Promise.race([
moderationStore.releaseLock(projectId),
moderationQueue.releaseLock(projectId),
new Promise((r) => setTimeout(r, 2000)),
])
hasNextProject.value = moderationStore.completeCurrentProject(projectId, 'skipped')
hasNextProject.value = await moderationQueue.completeCurrentProject(projectId, 'skipped')
await endChecklist('skipped')
}
@@ -2112,15 +2154,15 @@ async function skipCurrentProject() {
function clearProjectLocalStorage() {
localStorage.removeItem(`modpack-permissions-${projectV2.value.id}`)
localStorage.removeItem(`modpack-permissions-index-${projectV2.value.id}`)
localStorage.removeItem(`moderation-actions-${projectV2.value.slug}`)
localStorage.removeItem(`moderation-inputs-${projectV2.value.slug}`)
localStorage.removeItem(`moderation-stage-${projectV2.value.slug}`)
sessionStorage.removeItem(`modpack-permissions-data-${projectV2.value.id}`)
sessionStorage.removeItem(`modpack-permissions-permanent-no-${projectV2.value.id}`)
sessionStorage.removeItem(`modpack-permissions-updated-${projectV2.value.id}`)
void clearChecklistProgressState(checklistPersistenceProjectSlug)
actionStates.value = {}
textInputValues.value = {}
clearGeneratedMessageState()
}
const isLastVisibleStage = computed(() => {
@@ -2169,7 +2211,7 @@ const stageOptions = computed<OverflowMenuOption[]>(() => {
return options
})
type StageOverflowSlotOption = OverflowMenuOption & { id: string; text: string }
type StageOverflowSlotOption = OverflowMenuOption & { id: string; text: string; icon?: Component }
const stageOptionsForSlots = computed(() =>
stageOptions.value.filter((opt): opt is StageOverflowSlotOption => 'id' in opt && 'text' in opt),

View File

@@ -217,6 +217,14 @@
hoverFilled: true,
disabled: project.status === 'withheld',
},
{
id: 'set-to-draft-reply',
action: () => {
sendReply('draft')
},
hoverFilled: true,
disabled: project.status === 'draft',
},
{
id: 'send-to-review-reply',
action: () => {
@@ -236,6 +244,14 @@
hoverFilled: true,
disabled: project.status === 'withheld',
},
{
id: 'set-to-draft',
action: () => {
setStatus('draft')
},
hoverFilled: true,
disabled: project.status === 'draft',
},
{
id: 'send-to-review',
action: () => {
@@ -256,6 +272,14 @@
<EyeOffIcon aria-hidden="true" />
Withhold
</template>
<template #set-to-draft-reply>
<FileTextIcon aria-hidden="true" />
Set to draft with reply
</template>
<template #set-to-draft>
<FileTextIcon aria-hidden="true" />
Set to draft
</template>
<template #send-to-review-reply>
<ScaleIcon aria-hidden="true" />
Send to review with reply
@@ -280,6 +304,7 @@ import {
CheckIcon,
DropdownIcon,
EyeOffIcon,
FileTextIcon,
ReplyIcon,
ScaleIcon,
SendIcon,
@@ -417,7 +442,7 @@ async function sendReply(status = null, privateMessage = false) {
await updateThreadLocal()
if (status !== null) {
props.setStatus(status)
await props.setStatus(status)
}
} catch (err) {
addNotification({