fix moderation collapsibles & do a consistency pass (#7330)

* fix moderation collapsibles & do a consistency pass

* extract tech review sources

* improve buttons

* buttn

* improve markdown styling

* fix nested details

* save filters + fix reports reloading

* prepr
This commit is contained in:
Prospector
2026-08-27 18:46:13 +00:00
committed by GitHub
parent 263aaedc8f
commit ffd864a9e2
69 changed files with 3573 additions and 3088 deletions
@@ -1,11 +1,8 @@
<template>
<div class="flex flex-col">
<router-link
class="mb-4 flex w-fit items-center gap-2 rounded-lg px-2 py-0.5 pl-0 text-link"
:to="buildProjectHref(`/project/${route.params.id}/versions`)"
>
<ChevronLeftIcon class="shrink-0" /> {{ formatMessage(messages.allVersions) }}
</router-link>
<BackToParentLink :to="buildProjectHref(`/project/${route.params.id}/versions`)">
{{ formatMessage(messages.allVersions) }}
</BackToParentLink>
<VersionPage
v-if="version"
:version="version"
@@ -85,14 +82,13 @@
import type { Labrinth } from '@modrinth/api-client'
import {
CheckIcon,
ChevronLeftIcon,
DownloadIcon,
ExternalIcon,
MoreVerticalIcon,
ReportIcon,
VersionIcon,
} from '@modrinth/assets'
import { Button, ButtonLink, TeleportOverflowMenu } from '@modrinth/ui'
import { BackToParentLink, Button, ButtonLink, TeleportOverflowMenu } from '@modrinth/ui'
import {
commonMessages,
defineMessages,
@@ -32,50 +32,19 @@
>
<MailIcon />
</ButtonLink>
<IconButton
v-tooltip="copied ? `Copied to clipboard` : `Copy link`"
:label="copied ? `Copied to clipboard` : `Copy link`"
:disabled="copied"
class="relative grid place-items-center overflow-hidden"
@click="copyToClipboard(url)"
>
<CheckIcon
class="absolute transition-all ease-in-out"
:class="copied ? 'translate-y-0' : 'translate-y-7'"
/>
<LinkIcon
class="absolute transition-all ease-in-out"
:class="copied ? '-translate-y-7' : 'translate-y-0'"
/>
</IconButton>
<CopyLinkButton :url="url" />
</div>
</template>
<script setup lang="ts">
import {
BlueskyIcon,
CheckIcon,
LinkIcon,
MailIcon,
MastodonIcon,
TwitterIcon,
} from '@modrinth/assets'
import { ButtonLink, IconButton } from '@modrinth/ui'
import { BlueskyIcon, MailIcon, MastodonIcon, TwitterIcon } from '@modrinth/assets'
import { ButtonLink, CopyLinkButton } from '@modrinth/ui'
const props = defineProps<{
title?: string
url: string
}>()
const copied = ref(false)
const encodedUrl = computed(() => encodeURIComponent(props.url))
const encodedTitle = computed(() => (props.title ? encodeURIComponent(props.title) : undefined))
async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => {
copied.value = false
}, 3000)
}
</script>
@@ -1,6 +1,6 @@
<template>
<div>
<form class="flex flex-col gap-2 sm:flex-row" @submit.prevent="executeSearch">
<form class="flex flex-col gap-2 sm:flex-row sm:items-center" @submit.prevent="executeSearch">
<Input
v-model="query"
:icon="SearchIcon"
@@ -8,9 +8,10 @@
autocomplete="off"
placeholder="Search global trace keys..."
clearable
wrapper-class="flex-1 w-full"
size="medium"
wrapper-class="min-w-0 flex-1"
/>
<Button type="colored" color="brand" native-type="submit" :disabled="isLoading">
<Button type="colored" color="brand" size="lg" native-type="submit" :disabled="isLoading">
<SearchIcon aria-hidden="true" />
Search
</Button>
@@ -20,7 +21,7 @@
v-if="!isLoading && !loadError && total > 0"
class="mt-4 flex flex-wrap items-center justify-between gap-3"
>
<p class="m-0 text-sm text-secondary">Showing {{ pageStart }}-{{ pageEnd }} of {{ total }}</p>
<p class="m-0">Showing {{ pageStart }}-{{ pageEnd }} of {{ total }}</p>
<Pagination :page="currentPage" :count="pageCount" @switch-page="switchPage" />
</div>
@@ -0,0 +1,20 @@
<template>
<span class="flex min-w-0 items-center gap-1.5 text-contrast">
<span class="truncate">{{ label }}</span>
<SpinnerIcon v-if="loading" class="size-4 shrink-0 animate-spin" aria-hidden="true" />
<span v-else class="shrink-0">({{ formatNumber(count) }})</span>
</span>
</template>
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { useFormatNumber } from '@modrinth/ui'
defineProps<{
label: string
count: number
loading?: boolean
}>()
const formatNumber = useFormatNumber()
</script>
@@ -0,0 +1,9 @@
<template>
<div class="flex flex-col gap-3">
<div
v-for="i in 3"
:key="`loading-skeleton-${i}`"
class="flex h-[98px] w-full animate-pulse rounded-2xl bg-surface-3"
></div>
</div>
</template>
@@ -0,0 +1,56 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col justify-between gap-2 lg:flex-row">
<Input
v-model="query"
:icon="SearchIcon"
type="text"
autocomplete="off"
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
clearable
size="medium"
wrapper-class="min-w-0 flex-1"
@input="$emit('search')"
/>
<div
class="flex flex-col items-stretch justify-end gap-2 sm:flex-row sm:items-center lg:flex-shrink-0"
>
<slot name="actions" />
</div>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 flex-wrap items-center gap-3">
<slot name="meta" />
</div>
<div class="flex shrink-0 items-center justify-end gap-2 sm:ml-auto">
<slot name="pagination-extra" />
<Pagination
v-if="totalPages > 1"
:page="page"
:count="totalPages"
@switch-page="$emit('switch-page', $event)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { SearchIcon } from '@modrinth/assets'
import { commonMessages, Input, Pagination, useVIntl } from '@modrinth/ui'
const query = defineModel<string>({ required: true })
defineProps<{
page: number
totalPages: number
}>()
defineEmits<{
search: []
'switch-page': [page: number]
}>()
const { formatMessage } = useVIntl()
</script>
@@ -1,6 +1,9 @@
<template>
<div class="overflow-hidden rounded-2xl">
<div class="bg-bg-raised p-4">
<div
class="relative overflow-hidden rounded-2xl border border-solid border-surface-4 transition-[transform,opacity] duration-[400ms] ease-in"
:class="{ 'pointer-events-none translate-x-[120%] opacity-0': isSwipingAway }"
>
<div class="border-0 border-b border-solid border-surface-4 bg-bg-raised p-4">
<div
class="flex w-full flex-col items-start justify-between gap-3 sm:flex-row sm:items-center sm:gap-0"
>
@@ -180,6 +183,7 @@
v-model:collapsed="isThreadCollapsed"
:expand-text="expandText"
collapse-text="Collapse thread"
:disabled="disableCollapsing"
>
<div class="bg-surface-2 pt-2">
<ThreadView
@@ -231,7 +235,7 @@
@click="reopenReport()"
>
<CheckCircleIcon class="size-4" />
Reopen Thread
Reopen report
</Button>
</template>
<template #additionalActions="{ hasReply }">
@@ -261,6 +265,17 @@
</ThreadView>
</div>
</CollapsibleRegion>
<div
v-if="pendingDismiss"
:key="dismissAnimationId"
class="pointer-events-none absolute inset-x-0 bottom-0 h-1.5 overflow-hidden bg-highlight-red"
aria-hidden="true"
>
<div
class="report-dismiss-progress h-full w-full bg-red"
:style="{ animationDuration: `${DISMISS_DELAY_MS}ms` }"
/>
</div>
</div>
</template>
<script setup lang="ts">
@@ -288,7 +303,8 @@ import {
useVIntl,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import { computed, ref, watch } from 'vue'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, onUnmounted, ref, watch } from 'vue'
import { isStaff } from '~/helpers/users.js'
@@ -302,16 +318,22 @@ import SharedInstanceReportContext, {
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const auth = await useAuth()
const queryClient = useQueryClient()
const auth = useAuthState()
type SharedInstanceVersionDependency = Labrinth.Versions.v2.Dependency & {
project_id?: string
version_id?: string
}
const DISMISS_DELAY_MS = 3000
const SWIPE_DURATION_MS = 400
const props = defineProps<{
report: ExtendedReport
collapsed: boolean
disableCollapsing?: boolean
dismissAfterClose?: boolean
sharedInstanceDetailsLoader?: () => Promise<SharedInstanceReportDetails>
sharedInstanceVersionContentLoader?: (
instanceId: string,
@@ -319,6 +341,10 @@ const props = defineProps<{
) => Promise<ContentItem[]>
}>()
const emit = defineEmits<{
dismiss: []
}>()
const reportThread = ref<{
setReplyContent: (content: string) => void
sendReply: (privateMessage?: boolean) => Promise<void>
@@ -339,17 +365,38 @@ watch(
{ immediate: true },
)
const didCloseReport = ref(false)
const reportClosed = computed(() => {
return didCloseReport.value || props.report.closed
})
const closedOverride = ref<boolean | null>(null)
const pendingDismiss = ref(false)
const isSwipingAway = ref(false)
const dismissAnimationId = ref(0)
const thread = ref(props.report.thread)
let dismissTimeout: ReturnType<typeof setTimeout> | null = null
let swipeTimeout: ReturnType<typeof setTimeout> | null = null
const reportClosed = computed(() => closedOverride.value ?? props.report.closed)
watch(
() => props.report.thread,
(value) => {
thread.value = value
},
)
watch(
() => props.report.closed,
(closed) => {
if (closedOverride.value === closed) {
closedOverride.value = null
}
},
)
const sharedInstanceQuarantined = computed(
() =>
sharedInstanceDetails.value?.quarantine ?? props.report.shared_instance?.quarantine ?? false,
)
const threadWithReportBody = computed(() => {
if (!props.report.thread) return null
if (!thread.value) return null
const reportBodyMessage = {
id: `report-body-${props.report.id}`,
@@ -366,16 +413,15 @@ const threadWithReportBody = computed(() => {
}
return {
...props.report.thread,
messages: [reportBodyMessage, ...props.report.thread.messages],
members: [props.report.reporter_user, ...props.report.thread.members],
...thread.value,
messages: [reportBodyMessage, ...thread.value.messages],
members: [props.report.reporter_user, ...thread.value.members],
}
})
const remainingMessageCount = computed(() => {
if (!props.report.thread?.messages) return 0
// Thread messages count (report body is injected separately)
return props.report.thread.messages.length
if (!thread.value?.messages) return 0
return thread.value.messages.length
})
const expandText = computed(() => {
@@ -396,8 +442,10 @@ async function closeReport(reply = false) {
closed: true,
},
})
await refreshReportCaches()
didCloseReport.value = true
await refreshThread()
closedOverride.value = true
startDismissCountdown()
void refreshReportQuery()
} catch (err: any) {
addNotification({
title: 'Error closing report',
@@ -408,6 +456,8 @@ async function closeReport(reply = false) {
}
async function reopenReport() {
cancelDismissCountdown()
try {
await useBaseFetch(`report/${props.report.id}`, {
method: 'PATCH',
@@ -415,41 +465,84 @@ async function reopenReport() {
closed: false,
},
})
await refreshReportCaches()
didCloseReport.value = false
await refreshThread()
closedOverride.value = false
void refreshReportQuery()
} catch (err: any) {
addNotification({
title: 'Error reopening report',
text: err.data ? err.data.description : err,
type: 'error',
})
if (reportClosed.value) {
startDismissCountdown()
}
}
}
function cancelDismissCountdown() {
pendingDismiss.value = false
if (dismissTimeout !== null) {
clearTimeout(dismissTimeout)
dismissTimeout = null
}
}
function startDismissCountdown() {
if (!props.dismissAfterClose || isSwipingAway.value) return
cancelDismissCountdown()
dismissAnimationId.value += 1
pendingDismiss.value = true
dismissTimeout = setTimeout(() => {
dismissTimeout = null
swipeAway()
}, DISMISS_DELAY_MS)
}
function swipeAway() {
if (isSwipingAway.value) return
isSwipingAway.value = true
swipeTimeout = setTimeout(() => {
swipeTimeout = null
emit('dismiss')
}, SWIPE_DURATION_MS)
}
onUnmounted(() => {
cancelDismissCountdown()
if (swipeTimeout !== null) {
clearTimeout(swipeTimeout)
swipeTimeout = null
}
})
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
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
const threadId = thread.value?.id ?? props.report.thread?.id ?? props.report.thread_id
if (!threadId) return
const thread = await useBaseFetch(`thread/${threadId}`)
updateThread(thread)
const nextThread = await useBaseFetch(`thread/${threadId}`)
updateThread(nextThread)
}
function updateThread(newThread: any) {
thread.value = newThread
if (props.report.thread) {
Object.assign(props.report.thread, newThread)
}
}
function refreshReportQuery() {
return queryClient.invalidateQueries({ queryKey: ['report', props.report.id] })
}
async function getSharedInstanceVersion(
instanceId: string,
versionNumber: number,
@@ -845,3 +938,21 @@ async function banSharedInstanceOwner(owner: SharedInstanceReportUser) {
}
}
</script>
<style scoped>
.report-dismiss-progress {
transform-origin: left center;
animation-name: report-dismiss-fill;
animation-timing-function: linear;
animation-fill-mode: forwards;
}
@keyframes report-dismiss-fill {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,824 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
CheckIcon,
ChevronDownIcon,
ChevronRightIcon,
CopyIcon,
LoaderCircleIcon,
} from '@modrinth/assets'
import { Collapsible, IconButton, injectNotificationManager, Toggle } from '@modrinth/ui'
import { capitalizeString, highlightCodeLines } from '@modrinth/utils'
import { computed, nextTick, reactive, ref, watch } from 'vue'
import {
canUpdateGlobalDetail,
getFileDetailCount,
getSeverityBadgeColor,
severityOrder,
truncateMiddle,
verdictToDecision,
} from './helpers'
import TechRevVerdictButtons from './TechRevVerdictButtons.vue'
import type { ClassGroup, FlagItem, FlattenedFileReport, JarGroup } from './types'
import { injectTechReviewDecisions } from './use-tech-review-decisions'
const props = defineProps<{
file: FlattenedFileReport
focusedDetailId?: string | null
loadingIssues: Set<string>
decompiledSources: Map<string, string>
}>()
const emit = defineEmits<{
refetch: []
loadIssueSources: [issueIds: string[]]
allFlagsResolved: []
}>()
const { addNotification } = injectNotificationManager()
const {
updatingDetails,
updatingGlobalDetailKeys,
getDetailDecision,
isPreReviewed,
getFileMarkedCount,
getMarkedFlagsCount,
isDetailGloballyPassed,
isDetailGloballyResolved,
applyDecisionToRelatedDetails,
getToggledDetailVerdict,
updateIssueDetails,
updateGlobalIssueDetails,
} = injectTechReviewDecisions()
const hideGloballyPassed = ref(true)
const isBatchUpdating = ref(false)
const expandedClasses = reactive<Set<string>>(new Set())
const autoExpandedFileIds = reactive<Set<string>>(new Set())
const showCopyFeedback = reactive<Map<string, boolean>>(new Map())
const highlightedSourceCache = reactive<Map<string, { source: string; lines: string[] }>>(new Map())
const LAZY_LOAD_CLASS_SOURCE_MINIMUM = 2
const globallyPassedCount = computed(() => {
return props.file.issues.reduce(
(count, issue) => count + issue.details.filter(isDetailGloballyPassed).length,
0,
)
})
const globallyResolvedCount = computed(() => {
return props.file.issues.reduce(
(count, issue) => count + issue.details.filter(isDetailGloballyResolved).length,
0,
)
})
const remainingUnmarkedCount = computed(() => {
return getFileDetailCount(props.file) - getFileMarkedCount(props.file)
})
const selectedFileFlags = computed<FlagItem[]>(() =>
props.file.issues.flatMap((issue) =>
issue.details.map((detail) => ({
issueId: issue.id,
issueType: issue.issue_type,
detail,
})),
),
)
function getJarFlags(jarGroup: JarGroup): FlagItem[] {
return jarGroup.classes.flatMap((classItem) => classItem.flags)
}
function getJarRemainingUnmarkedCount(jarGroup: JarGroup): number {
const flags = getJarFlags(jarGroup)
return flags.length - getMarkedFlagsCount(flags)
}
function getRemainingGlobalDetailCount(flags: FlagItem[]): number {
return new Set(
flags
.filter(
(flag) =>
getDetailDecision(flag.detail.id, flag.detail.status) === 'pending' &&
canUpdateGlobalDetail(flag.detail),
)
.map((flag) => flag.detail.key),
).size
}
function maybeReturnToFileList() {
if (getFileMarkedCount(props.file) === getFileDetailCount(props.file)) {
emit('allFlagsResolved')
}
}
async function batchMarkRemainingGlobally(flags: FlagItem[], verdict: 'safe' | 'unsafe') {
if (isBatchUpdating.value) return
const detailsByKey = new Map(
flags
.filter(
(flag) =>
getDetailDecision(flag.detail.id, flag.detail.status) === 'pending' &&
canUpdateGlobalDetail(flag.detail),
)
.map((flag) => [flag.detail.key, flag.detail]),
)
const details = [...detailsByKey.values()]
if (details.length === 0) return
isBatchUpdating.value = true
try {
await updateGlobalIssueDetails(details.map((detail) => ({ detail_key: detail.key, verdict })))
applyDecisionToRelatedDetails(
details.map((detail) => detail.id),
verdictToDecision(verdict),
'global',
)
addNotification({
type: 'success',
title: `Globally marked ${details.length} trace keys as ${verdict}`,
text: `All remaining eligible traces have been globally marked as ${
verdict === 'safe' ? 'false positives' : 'malicious'
}.`,
})
maybeReturnToFileList()
emit('refetch')
} catch (error) {
console.error('Failed to batch update global traces:', error)
addNotification({
type: 'error',
title: 'Global batch update failed',
text: 'An error occurred while globally updating traces.',
})
} finally {
isBatchUpdating.value = false
}
}
async function batchMarkRemaining(flags: FlagItem[], verdict: 'safe' | 'unsafe', inJar = false) {
if (isBatchUpdating.value) return
const detailIds = flags
.filter((flag) => getDetailDecision(flag.detail.id, flag.detail.status) === 'pending')
.map((flag) => flag.detail.id)
if (detailIds.length === 0) return
isBatchUpdating.value = true
try {
await updateIssueDetails(detailIds.map((detail_id) => ({ detail_id, verdict })))
applyDecisionToRelatedDetails(detailIds, verdictToDecision(verdict), 'local')
addNotification({
type: 'success',
title: `Marked ${detailIds.length} traces as ${verdict}`,
text: `All remaining traces${inJar ? ' in this JAR' : ''} have been marked as ${
verdict === 'safe' ? 'false positives' : 'malicious'
}.`,
})
maybeReturnToFileList()
emit('refetch')
} catch (error) {
console.error('Failed to batch update:', error)
addNotification({
type: 'error',
title: 'Batch update failed',
text: 'An error occurred while updating traces.',
})
} finally {
isBatchUpdating.value = false
}
}
function updateLocalDetailAction(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: 'safe' | 'malware',
) {
return updateDetailStatus(detail.id, getToggledDetailVerdict(detail, decision, 'local'))
}
function updateGlobalDetailAction(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: 'safe' | 'malware',
) {
return updateGlobalDetailStatus(detail, getToggledDetailVerdict(detail, decision, 'global'))
}
async function updateDetailStatus(
detailId: string,
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
) {
const detail = props.file.issues.flatMap((issue) => issue.details).find((d) => d.id === detailId)
const priorDecision = detail ? getDetailDecision(detail.id, detail.status) : 'pending'
updatingDetails.add(detailId)
const previousMarkedCount = getFileMarkedCount(props.file)
try {
await updateIssueDetails([{ detail_id: detailId, verdict }])
const { otherMatchedCount } = applyDecisionToRelatedDetails(
[detailId],
verdictToDecision(verdict),
'local',
)
if (verdict !== 'pending' && priorDecision === 'pending') {
for (const classGroup of groupedByClass.value) {
const hasThisDetail = classGroup.flags.some((f) => f.detail.id === detailId)
if (hasThisDetail && getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
expandedClasses.delete(classGroup.key)
break
}
}
}
if (verdict !== 'pending') {
const markedCount = getFileMarkedCount(props.file)
const totalCount = getFileDetailCount(props.file)
if (previousMarkedCount != markedCount && markedCount === totalCount) {
emit('allFlagsResolved')
}
}
const otherText =
otherMatchedCount > 0
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked)`
: ''
if (verdict === 'pending') {
addNotification({
type: 'success',
title: 'Local trace verdict unset',
text: `The project-local verdict has been removed.${otherText}`,
})
} else if (verdict === 'safe') {
addNotification({
type: 'success',
title: 'Issue marked as pass',
text: `This issue has been marked as a false positive.${otherText}`,
})
} else {
addNotification({
type: 'success',
title: 'Issue marked as fail',
text: `This issue has been flagged as malicious.${otherText}`,
})
}
emit('refetch')
} catch (error) {
console.error('Failed to update detail status:', error)
addNotification({
type: 'error',
title: 'Failed to update issue',
text: 'An error occurred while updating the issue status.',
})
} finally {
updatingDetails.delete(detailId)
}
}
async function updateGlobalDetailStatus(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
) {
if (!canUpdateGlobalDetail(detail)) {
addNotification({
type: 'error',
title: 'Global update unavailable',
text: 'Generated trace keys cannot be marked globally.',
})
return
}
updatingGlobalDetailKeys.add(detail.key)
const previousMarkedCount = getFileMarkedCount(props.file)
try {
await updateGlobalIssueDetails([{ detail_key: detail.key, verdict }])
const { otherMatchedCount } = applyDecisionToRelatedDetails(
[detail.id],
verdictToDecision(verdict),
'global',
)
if (verdict !== 'pending') {
for (const classGroup of groupedByClass.value) {
if (getMarkedFlagsCount(classGroup.flags) === classGroup.flags.length) {
expandedClasses.delete(classGroup.key)
}
}
}
if (verdict !== 'pending') {
const markedCount = getFileMarkedCount(props.file)
const totalCount = getFileDetailCount(props.file)
if (previousMarkedCount != markedCount && markedCount === totalCount) {
emit('allFlagsResolved')
}
}
const otherText =
otherMatchedCount > 0
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked in this project)`
: ''
if (verdict === 'pending') {
addNotification({
type: 'success',
title: 'Global trace verdict unset',
text: `The global verdict for this trace key has been removed.${otherText}`,
})
} else {
addNotification({
type: 'success',
title:
verdict === 'safe' ? 'Trace globally marked as pass' : 'Trace globally marked as fail',
text:
verdict === 'safe'
? `This trace key has been marked as a global false positive.${otherText}`
: `This trace key has been globally flagged as malicious.${otherText}`,
})
}
emit('refetch')
} catch (error) {
console.error('Failed to update global detail status:', error)
addNotification({
type: 'error',
title: 'Failed to update global trace',
text: 'An error occurred while updating the global trace status.',
})
} finally {
updatingGlobalDetailKeys.delete(detail.key)
}
}
function splitJarSegments(jar: string | null, currentFileName: string | null): string[] {
if (!jar) return []
const segments = jar
.split(/[/#]/)
.map((s) => decodeURIComponent(s.trim()))
.filter((s) => s.length > 0)
if (segments.length > 0 && currentFileName && segments[0] === currentFileName) {
return segments.slice(1)
}
return segments
}
const groupedByClass = computed<ClassGroup[]>(() => {
const classMap = new Map<string, ClassGroup>()
for (const issue of props.file.issues) {
for (const detail of issue.details) {
if (hideGloballyPassed.value && isDetailGloballyPassed(detail)) {
continue
}
const classKey = `${detail.jar ?? ''}::${detail.file_path}`
if (!classMap.has(classKey)) {
classMap.set(classKey, {
key: classKey,
jar: detail.jar ?? null,
filePath: detail.file_path,
flags: [],
})
}
classMap.get(classKey)!.flags.push({
issueId: issue.id,
issueType: issue.issue_type,
detail,
})
}
}
for (const classGroup of classMap.values()) {
classGroup.flags.sort((a, b) => {
const aPreReviewed = isPreReviewed(a.detail.id, a.detail.status)
const bPreReviewed = isPreReviewed(b.detail.id, b.detail.status)
return aPreReviewed === bPreReviewed ? 0 : aPreReviewed ? 1 : -1
})
}
return Array.from(classMap.values())
})
const groupedByJar = computed<JarGroup[]>(() => {
const jarMap = new Map<string, JarGroup>()
for (const classItem of groupedByClass.value) {
const jarKey = classItem.jar ?? ''
if (!jarMap.has(jarKey)) {
jarMap.set(jarKey, {
key: jarKey,
jar: classItem.jar,
segments: splitJarSegments(classItem.jar, props.file.file_name),
classes: [],
})
}
jarMap.get(jarKey)!.classes.push(classItem)
}
return Array.from(jarMap.values()).sort((a, b) => {
const aRoot = a.segments.length === 0
const bRoot = b.segments.length === 0
return aRoot === bRoot ? 0 : aRoot ? -1 : 1
})
})
function getHighestSeverityInClass(flags: FlagItem[]): Labrinth.TechReview.Internal.DelphiSeverity {
return flags.reduce(
(highest, flag) =>
severityOrder[flag.detail.severity] > severityOrder[highest] ? flag.detail.severity : highest,
'low' as Labrinth.TechReview.Internal.DelphiSeverity,
)
}
function getClassDecompiledSource(classItem: ClassGroup): string | undefined {
for (const flag of classItem.flags) {
const source = props.decompiledSources.get(flag.detail.id)
if (source) return source
}
return undefined
}
function getHighlightedClassSource(classItem: ClassGroup): string[] {
const source = getClassDecompiledSource(classItem)
if (!source) return []
const cached = highlightedSourceCache.get(classItem.key)
if (cached?.source === source) return cached.lines
const lines = highlightCodeLines(source, 'java')
highlightedSourceCache.set(classItem.key, { source, lines })
return lines
}
function isClassLoadingSource(classItem: ClassGroup): boolean {
return classItem.flags.some((flag) => props.loadingIssues.has(flag.issueId))
}
function loadClassSources(classItem: ClassGroup) {
const issueIds = [...new Set(classItem.flags.map((flag) => flag.issueId))]
if (issueIds.length > 0) {
emit('loadIssueSources', issueIds)
}
}
function expandClass(classItem: ClassGroup) {
if (expandedClasses.has(classItem.key)) return
expandedClasses.add(classItem.key)
loadClassSources(classItem)
}
function toggleClass(classItem: ClassGroup) {
if (expandedClasses.has(classItem.key)) {
expandedClasses.delete(classItem.key)
} else {
expandClass(classItem)
}
}
async function copyToClipboard(code: string, detailId: string) {
try {
await navigator.clipboard.writeText(code)
showCopyFeedback.set(detailId, true)
setTimeout(() => {
showCopyFeedback.delete(detailId)
}, 2000)
} catch (error) {
console.error('Failed to copy code:', error)
}
}
async function focusDetail(detailId: string) {
await nextTick()
const classItem = groupedByClass.value.find((group) =>
group.flags.some((flag) => flag.detail.id === detailId),
)
if (classItem) {
expandClass(classItem)
}
await nextTick()
if (!import.meta.client) return
window.requestAnimationFrame(() => {
document.getElementById(`tech-review-detail-${detailId}`)?.scrollIntoView({
behavior: 'smooth',
block: 'center',
})
})
}
watch(
[() => props.focusedDetailId, () => props.file.id],
([detailId]) => {
if (detailId) {
focusDetail(detailId)
}
},
{ immediate: true },
)
watch(
[() => props.file.id, groupedByClass],
([fileId, classes]) => {
if (!fileId || classes.length === 0 || autoExpandedFileIds.has(fileId)) return
autoExpandedFileIds.add(fileId)
if (classes.length < LAZY_LOAD_CLASS_SOURCE_MINIMUM) {
for (const classItem of classes) {
expandClass(classItem)
}
}
},
{ immediate: true },
)
</script>
<template>
<div
v-if="getFileDetailCount(file) > 0"
class="flex flex-wrap items-center justify-between gap-3 p-4"
>
<TechRevVerdictButtons
v-if="remainingUnmarkedCount > 0"
variant="remaining"
:remaining-count="remainingUnmarkedCount"
:global-disabled="isBatchUpdating || getRemainingGlobalDetailCount(selectedFileFlags) === 0"
:local-disabled="isBatchUpdating"
@global-safe="batchMarkRemainingGlobally(selectedFileFlags, 'safe')"
@local-safe="batchMarkRemaining(selectedFileFlags, 'safe')"
@local-unsafe="batchMarkRemaining(selectedFileFlags, 'unsafe')"
@global-unsafe="batchMarkRemainingGlobally(selectedFileFlags, 'unsafe')"
/>
<label class="ml-auto flex cursor-pointer items-center gap-3 text-sm">
<span class="text-right text-secondary">
Hide globally passed
<span class="text-tertiary block text-xs">
{{ globallyResolvedCount }}/{{ getFileDetailCount(file) }} traces globally resolved
</span>
</span>
<Toggle v-model="hideGloballyPassed" :disabled="globallyPassedCount === 0" small />
</label>
</div>
<div v-for="jarGroup in groupedByJar" :key="jarGroup.key" class="flex flex-col gap-1 px-4 pb-4">
<div v-if="jarGroup.segments.length > 0" class="my-2">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-1">
<template v-for="(segment, index) in jarGroup.segments" :key="`${jarGroup.key}-${index}`">
<span
class="font-mono text-sm"
:class="
index === jarGroup.segments.length - 1
? 'font-semibold text-contrast'
: 'text-secondary'
"
>
{{ segment }}
</span>
<ChevronRightIcon
v-if="index < jarGroup.segments.length - 1"
class="size-4 text-secondary"
/>
</template>
</div>
<TechRevVerdictButtons
v-if="getJarRemainingUnmarkedCount(jarGroup) > 0"
variant="remaining"
jar
:remaining-count="getJarRemainingUnmarkedCount(jarGroup)"
:global-disabled="
isBatchUpdating || getRemainingGlobalDetailCount(getJarFlags(jarGroup)) === 0
"
:local-disabled="isBatchUpdating"
@global-safe="batchMarkRemainingGlobally(getJarFlags(jarGroup), 'safe')"
@local-safe="batchMarkRemaining(getJarFlags(jarGroup), 'safe', true)"
@local-unsafe="batchMarkRemaining(getJarFlags(jarGroup), 'unsafe', true)"
@global-unsafe="batchMarkRemainingGlobally(getJarFlags(jarGroup), 'unsafe')"
/>
</div>
</div>
<div
v-for="classItem in jarGroup.classes"
:key="classItem.key"
class="overflow-clip rounded-xl border border-solid border-surface-4"
>
<div
class="flex cursor-pointer items-center justify-between bg-surface-3 p-2 transition-colors duration-200 hover:bg-surface-4"
@click="toggleClass(classItem)"
>
<div class="my-auto flex items-center gap-2">
<IconButton
type="quiet"
label="Toggle details"
class="transition-transform"
:class="{ 'rotate-180': expandedClasses.has(classItem.key) }"
>
<ChevronDownIcon class="h-5 w-5 text-contrast" />
</IconButton>
<span v-tooltip="classItem.filePath" class="font-mono text-sm font-semibold">{{
truncateMiddle(classItem.filePath)
}}</span>
<div
class="rounded-full border-solid px-2.5 py-1"
:class="getSeverityBadgeColor(getHighestSeverityInClass(classItem.flags))"
>
<span class="text-sm font-medium">{{
capitalizeString(getHighestSeverityInClass(classItem.flags))
}}</span>
</div>
<div
class="flex items-center gap-1 rounded-full border border-solid px-2.5 py-1 text-sm"
:class="
getMarkedFlagsCount(classItem.flags) === classItem.flags.length
? 'border-green/60 bg-highlight-green text-green'
: 'border-red/60 bg-highlight-red text-red'
"
>
<CheckIcon
v-if="getMarkedFlagsCount(classItem.flags) === classItem.flags.length"
class="size-4"
/>
{{ getMarkedFlagsCount(classItem.flags) }}/{{ classItem.flags.length }} flags
</div>
<Transition name="fade">
<div
v-if="isClassLoadingSource(classItem)"
class="rounded-full border border-solid border-surface-5 bg-surface-3 px-2.5 py-1"
>
<span class="flex items-center gap-1.5 text-sm font-medium text-secondary">
<LoaderCircleIcon class="size-4 animate-spin" />
Loading source...
</span>
</div>
</Transition>
</div>
</div>
<Collapsible :collapsed="!expandedClasses.has(classItem.key)">
<div class="flex flex-col gap-2 border-0 border-t border-solid border-surface-4 p-2">
<div
v-for="flag in classItem.flags"
:id="`tech-review-detail-${flag.detail.id}`"
:key="`${flag.issueId}-${flag.detail.id}`"
class="flex flex-col gap-2 rounded-lg border border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
:class="{
'!border-brand bg-brand-highlight': focusedDetailId === flag.detail.id,
}"
>
<div class="grid grid-cols-[1fr_auto] items-center">
<div
class="flex items-center gap-2"
:class="{
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
}"
>
<span class="text-base font-semibold text-contrast">{{
flag.issueType.replace(/_/g, ' ')
}}</span>
<div
class="rounded-full border-solid px-2.5 py-1"
:class="getSeverityBadgeColor(flag.detail.severity)"
>
<span class="text-sm font-medium">{{
capitalizeString(flag.detail.severity)
}}</span>
</div>
</div>
<div class="me-2 flex items-center justify-end gap-2">
<TechRevVerdictButtons
variant="trace"
:detail="flag.detail"
@global-safe="updateGlobalDetailAction(flag.detail, 'safe')"
@local-safe="updateLocalDetailAction(flag.detail, 'safe')"
@local-unsafe="updateLocalDetailAction(flag.detail, 'malware')"
@global-unsafe="updateGlobalDetailAction(flag.detail, 'malware')"
/>
</div>
</div>
<div
v-if="flag.detail.data && Object.keys(flag.detail.data).length > 0"
class="flex flex-wrap gap-x-4 gap-y-1 pr-4 text-sm"
>
<div
v-for="[key, value] in Object.entries(flag.detail.data).sort(([a], [b]) =>
a.localeCompare(b),
)"
:key="key"
class="flex items-center gap-1.5"
>
<span class="text-secondary">{{ key }}:</span>
<a
v-if="typeof value === 'string' && value.startsWith('http')"
:href="value"
target="_blank"
rel="noopener noreferrer"
class="text-brand-blue hover:underline"
>
{{ value }}
</a>
<span v-else class="font-mono text-contrast">{{ value }}</span>
</div>
</div>
</div>
<div
v-if="getHighlightedClassSource(classItem).length > 0"
class="relative inset-0 overflow-hidden rounded-lg border border-solid border-surface-5 bg-surface-4"
>
<IconButton
v-tooltip="`Copy code`"
type="quiet"
:label="`Copy code`"
class="!absolute right-2 top-2 border-[1px]"
@click="copyToClipboard(getClassDecompiledSource(classItem)!, classItem.key)"
>
<CopyIcon v-if="!showCopyFeedback.get(classItem.key)" />
<CheckIcon v-else />
</IconButton>
<div class="overflow-x-auto bg-surface-3 py-3">
<div
v-for="(line, n) in getHighlightedClassSource(classItem)"
:key="n"
class="flex font-mono text-[13px] leading-[1.6]"
>
<div
class="select-none border-0 border-r border-solid border-surface-5 px-4 py-0 text-right text-primary"
style="min-width: 3.5rem"
>
{{ n + 1 }}
</div>
<div class="flex-1 px-4 py-0 text-primary">
<pre v-html="line || ' '"></pre>
</div>
</div>
</div>
</div>
<div
v-else-if="isClassLoadingSource(classItem)"
class="rounded-lg border border-solid border-surface-5 bg-surface-3 p-4"
>
<p class="flex items-center gap-2 text-sm text-secondary">
<LoaderCircleIcon class="size-4 animate-spin" />
Loading source...
</p>
</div>
<div v-else class="rounded-lg border border-solid border-surface-5 bg-surface-3 p-4">
<p class="text-sm text-secondary">
Source code not available or failed to decompile for this file.
</p>
</div>
</div>
</Collapsible>
</div>
</div>
</template>
<style scoped>
pre {
all: unset;
display: inline;
white-space: pre;
}
.fade-enter-active {
transition: opacity 0.3s ease-in;
transition-delay: 0.2s;
}
.fade-leave-active {
transition: opacity 0.15s ease-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { CheckIcon, DownloadIcon, ExternalIcon, VersionIcon } from '@modrinth/assets'
import { ButtonLink, useFormatBytes } from '@modrinth/ui'
import { capitalizeString } from '@modrinth/utils'
import { computed } from 'vue'
import {
getFileDetailCount,
getFileHighestSeverity,
getSeverityBadgeColor,
getVersionLabel,
getVersionPageHref,
truncateMiddle,
} from './helpers'
import type { FlattenedFileReport } from './types'
import { injectTechReviewDecisions } from './use-tech-review-decisions'
const props = defineProps<{
reports: FlattenedFileReport[]
project: {
id: string
slug?: string
project_types: string[]
}
}>()
const emit = defineEmits<{
viewFlags: [file: FlattenedFileReport]
}>()
const formatBytes = useFormatBytes()
const { getFileMarkedCount } = injectTechReviewDecisions()
const allFiles = computed(() => {
return [...props.reports].sort((a, b) => {
const aComplete = getFileMarkedCount(a) === getFileDetailCount(a)
const bComplete = getFileMarkedCount(b) === getFileDetailCount(b)
return aComplete === bComplete ? 0 : aComplete ? 1 : -1
})
})
</script>
<template>
<div
v-for="(file, idx) in allFiles"
:key="idx"
class="flex items-center justify-between border-0 border-x border-b border-solid border-surface-3 bg-surface-2 px-4 py-3"
:class="{
'rounded-bl-2xl rounded-br-2xl': idx === allFiles.length - 1,
'bg-[#E8E8E8] dark:bg-[#1A1C20]': idx % 2 === 1,
}"
>
<div class="flex items-center gap-3">
<span
v-tooltip="file.file_name"
class="py-2 font-medium text-contrast"
:aria-label="`View flags for ${file.file_name}`"
tabindex="0"
:class="{ 'cursor-pointer hover:underline': getFileDetailCount(file) > 0 }"
@click="getFileDetailCount(file) > 0 && emit('viewFlags', file)"
>
{{ truncateMiddle(file.file_name, 50) }}
</span>
<div class="rounded-full border border-solid border-surface-5 bg-surface-3 px-2.5 py-1">
<span class="text-sm font-medium text-secondary">{{ formatBytes(file.file_size) }}</span>
</div>
<div
v-if="getFileDetailCount(file) > 0"
class="rounded-full border-solid px-2.5 py-1"
:class="getSeverityBadgeColor(getFileHighestSeverity(file))"
>
<span class="text-sm font-medium">{{
capitalizeString(getFileHighestSeverity(file))
}}</span>
</div>
<div
v-if="getFileDetailCount(file) > 0"
class="flex items-center gap-1 rounded-full border border-solid px-2.5 py-1 text-sm"
:class="
getFileMarkedCount(file) === getFileDetailCount(file)
? 'border-green/60 bg-highlight-green text-green'
: 'border-red/60 bg-highlight-red text-red'
"
>
<CheckIcon v-if="getFileMarkedCount(file) === getFileDetailCount(file)" class="size-4" />
{{ getFileMarkedCount(file) }}/{{ getFileDetailCount(file) }} flags
</div>
<!-- TODO: remove toString when backend supports it properly -->
<div
v-else-if="file.flag_reason.toString() === 'manual'"
class="border-blue/60 flex items-center gap-1 rounded-full border border-solid bg-highlight-blue px-2.5 py-1 text-sm text-blue"
>
Manual review
</div>
<div
v-else
class="border-green/60 flex items-center gap-1 rounded-full border border-solid bg-highlight-green px-2.5 py-1 text-sm text-green"
>
No flags
</div>
</div>
<div class="flex items-center gap-2">
<ButtonLink
type="outlined"
target="_blank"
:href="getVersionPageHref(project, file.version_id)"
:aria-label="`Open version ${getVersionLabel(file)}`"
>
<VersionIcon aria-hidden="true" /> {{ getVersionLabel(file) }}
</ButtonLink>
<ButtonLink
type="outlined"
target="_blank"
:href="`https://slicer.run/?url=${encodeURIComponent(file.download_url)}`"
aria-label="Open in Slicer"
>
<ExternalIcon aria-hidden="true" /> Slicer
</ButtonLink>
<ButtonLink
v-tooltip="`Download ${file.file_name} (${formatBytes(file.file_size)})`"
type="outlined"
:href="file.download_url"
:download="file.file_name"
tabindex="0"
icon-only
circular
>
<DownloadIcon />
</ButtonLink>
</div>
</div>
</template>
@@ -0,0 +1,416 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
BugIcon,
CheckIcon,
DropdownIcon,
EyeOffIcon,
ScaleIcon,
ShieldCheckIcon,
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import { type TechReviewContext, techReviewQuickReplies } from '@modrinth/moderation'
import {
Button,
type ButtonMenuOption,
CollapsibleRegion,
commonMessages,
injectModrinthClient,
injectNotificationManager,
TeleportOverflowMenu,
useFormatBytes,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { capitalizeString, type ThreadMessage, type User } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed, ref } from 'vue'
import type { UnsafeFile } from '~/components/ui/moderation/MaliciousSummaryModal.vue'
import ThreadView from '~/components/ui/thread/ThreadView.vue'
import { severityOrder } from './helpers'
import type { FlattenedFileReport } from './types'
import { injectTechReviewDecisions } from './use-tech-review-decisions'
const props = defineProps<{
project: Labrinth.Projects.v3.Project
projectOwner: Labrinth.TechReview.Internal.Ownership
thread: Labrinth.TechReview.Internal.Thread
reports: FlattenedFileReport[]
disableCollapsing?: boolean
}>()
const isThreadCollapsed = defineModel<boolean>('collapsed', { required: true })
const emit = defineEmits<{
refetch: []
markComplete: [projectId: string]
showMaliciousSummary: [unsafeFiles: UnsafeFile[]]
statusChanged: [status: Labrinth.Projects.v2.ProjectStatus]
}>()
const auth = useAuthState()
const featureFlags = useFeatureFlags()
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { getDetailDecision } = injectTechReviewDecisions()
const formatBytes = useFormatBytes()
const formatDateTimeUtc = useFormatDateTime({
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short',
timeZone: 'UTC',
})
const remainingMessageCount = computed(() => {
if (!props.thread?.messages) return 0
return Math.max(0, props.thread.messages.length - 1)
})
const threadExpandText = computed(() => {
if (remainingMessageCount.value === 0) return 'Expand'
if (remainingMessageCount.value === 1) return 'Show 1 more message'
return `Show ${remainingMessageCount.value} more messages`
})
const projectStatus = ref<Labrinth.Projects.v2.ProjectStatus>(props.project.status)
const isLoadingStatusAction = ref(false)
function isStatusActionDisabled(status: Labrinth.Projects.v2.ProjectStatus): boolean {
return projectStatus.value === status || isLoadingStatusAction.value
}
async function setStatus(status: Labrinth.Projects.v2.ProjectStatus) {
isLoadingStatusAction.value = true
try {
await client.labrinth.projects_v2.edit(props.project.id, { status })
emit('refetch')
projectStatus.value = status
emit('statusChanged', status)
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: (err as any)?.data?.description ? (err as any).data.description : String(err),
type: 'error',
})
}
isLoadingStatusAction.value = false
}
const projectStatusActions = computed<ButtonMenuOption[]>(() => [
{
id: 'approve',
label: 'Approve',
icon: CheckIcon,
tone: 'green',
hoverFilled: true,
action: () => setStatus('approved'),
disabled: isStatusActionDisabled('approved'),
},
{
id: 'withhold',
label: 'Withhold',
icon: EyeOffIcon,
tone: 'orange',
hoverFilled: true,
action: () => setStatus('withheld'),
disabled: isStatusActionDisabled('withheld'),
},
{
id: 'send-to-review',
label: 'Send to review',
icon: ScaleIcon,
action: () => setStatus('processing'),
disabled: isStatusActionDisabled('processing'),
},
{
id: 'reject',
label: 'Reject',
icon: XIcon,
tone: 'red',
hoverFilled: true,
action: () => setStatus('rejected'),
disabled: isStatusActionDisabled('rejected'),
},
])
const techReviewContext = computed<TechReviewContext>(() => ({
project: props.project,
project_owner: props.projectOwner,
reports: props.reports,
}))
const threadViewRef = ref<{
setReplyContent: (content: string) => void
getReplyContent: () => string
} | null>(null)
const unsafeFiles = computed<UnsafeFile[]>(() => {
return props.reports
.filter((report) =>
report.issues.some((issue) =>
issue.details.some((detail) => getDetailDecision(detail.id, detail.status) === 'malware'),
),
)
.map((report) => ({
file: report,
projectName: props.project.name,
projectId: props.project.id,
userId: props.projectOwner.id,
username: props.projectOwner.name,
}))
})
const reviewSummaryPreview = computed(() => {
const fileDecisions = new Map<
string,
{
fileName: string
fileSize: number
decisions: {
filePath: string
issueType: string
severity: string
decision: 'safe' | 'malware'
}[]
maxSeverity: Labrinth.TechReview.Internal.DelphiSeverity
}
>()
let totalSafe = 0
let totalUnsafe = 0
for (const report of props.reports) {
if (!fileDecisions.has(report.id)) {
fileDecisions.set(report.id, {
fileName: report.file_name,
fileSize: report.file_size,
decisions: [],
maxSeverity: 'low',
})
}
const fileData = fileDecisions.get(report.id)!
for (const issue of report.issues) {
for (const detail of issue.details) {
const decision = getDetailDecision(detail.id, detail.status)
if (decision === 'pending') continue
fileData.decisions.push({
filePath: detail.file_path,
issueType: issue.issue_type.replace(/_/g, ' '),
severity: detail.severity,
decision,
})
if (severityOrder[detail.severity] > severityOrder[fileData.maxSeverity]) {
fileData.maxSeverity = detail.severity
}
if (decision === 'safe') totalSafe++
else totalUnsafe++
}
}
}
const totalDecisions = totalSafe + totalUnsafe
if (totalDecisions === 0) return ''
const timestamp = formatDateTimeUtc(dayjs().toDate())
let markdown = `## Tech Review Summary\n*${timestamp}*\n\n`
markdown += `<details>\n<summary>File Details (${totalSafe} safe, ${totalUnsafe} unsafe)</summary>\n\n`
for (const [, fileData] of fileDecisions) {
if (fileData.decisions.length === 0) continue
const fileSafe = fileData.decisions.filter((d) => d.decision === 'safe').length
const fileUnsafe = fileData.decisions.filter((d) => d.decision === 'malware').length
const fileVerdict = fileUnsafe > 0 ? 'Unsafe' : 'Safe'
markdown += `### ${fileData.fileName}\n`
markdown += `> ${formatBytes(fileData.fileSize)}${fileData.decisions.length} issues • Max severity: ${fileData.maxSeverity} • **Verdict:** ${fileVerdict}\n\n`
markdown += `<details>\n<summary>Issues (${fileSafe} safe, ${fileUnsafe} unsafe)</summary>\n\n`
markdown += `| Class | Issue Type | Severity | Decision |\n`
markdown += `|-------|------------|----------|----------|\n`
for (const d of fileData.decisions) {
const decisionText = d.decision === 'safe' ? '✅ Safe' : '❌ Unsafe'
markdown += `| \`${d.filePath}\` | ${d.issueType} | ${capitalizeString(d.severity)} | ${decisionText} |\n`
}
markdown += `\n</details>\n\n`
}
markdown += `</details>\n\n`
markdown += `---\n\n**Total:** ${totalDecisions} issues reviewed (${totalSafe} safe, ${totalUnsafe} unsafe)\n\n`
return markdown
})
const threadWithPreview = computed(() => {
if (!reviewSummaryPreview.value) return props.thread
const user = auth.value?.user as User | null
if (!user) return props.thread
const previewMessage: ThreadMessage & { preview: true } = {
id: 'preview-message',
author_id: user.id,
body: {
type: 'text',
body: reviewSummaryPreview.value,
private: true,
replying_to: null,
associated_images: [],
},
created: new Date().toISOString(),
hide_identity: false,
preview: true,
}
return {
...props.thread,
messages: [...props.thread.messages, previewMessage],
members: props.thread.members.some((m) => m.id === user.id)
? props.thread.members
: [...props.thread.members, user],
}
})
const allIssuesResolved = computed(() => {
for (const report of props.reports) {
for (const issue of report.issues) {
for (const detail of issue.details) {
if (getDetailDecision(detail.id, detail.status) === 'pending') return false
}
}
}
return true
})
const canSubmitReview = computed(() => {
const totalIssues = props.reports.reduce((sum, r) => sum + r.issues.length, 0)
if (totalIssues === 0) return true
return allIssuesResolved.value
})
const hasSubmittedPassReview = ref(false)
async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
hasSubmittedPassReview.value = verdict === 'safe'
const editorContent = threadViewRef.value?.getReplyContent() || ''
let message: string | undefined
if (reviewSummaryPreview.value && editorContent) {
message = `${reviewSummaryPreview.value}${editorContent}`
} else if (reviewSummaryPreview.value) {
message = reviewSummaryPreview.value
} else if (editorContent) {
message = editorContent
}
try {
await client.labrinth.tech_review_internal.submitProject(props.project.id, {
verdict,
message,
})
emit('markComplete', props.project.id)
addNotification({
type: 'success',
title: 'Review submitted',
text: 'Technical review completed successfully.',
})
if (verdict === 'unsafe') {
emit('showMaliciousSummary', unsafeFiles.value)
}
} catch (error: unknown) {
const err = error as { response?: { data?: { issues?: string[] } } }
if (err.response?.data?.issues) {
const missedCount = err.response.data.issues.length
addNotification({
type: 'error',
title: 'Pending issues remain',
text: `${missedCount} issue(s) still need a verdict before submitting.`,
})
} else {
addNotification({
type: 'error',
title: 'Submit failed',
text: 'Failed to submit review. Please try again.',
})
}
}
}
</script>
<template>
<CollapsibleRegion
v-model:collapsed="isThreadCollapsed"
:expand-text="threadExpandText"
:disabled="disableCollapsing"
collapse-text="Collapse thread"
>
<div class="bg-surface-2 pt-0">
<!-- DEV-531 -->
<!-- @vue-expect-error TODO: will convert ThreadView to use api-client types at a later date -->
<ThreadView
ref="threadViewRef"
:thread="threadWithPreview"
:quick-replies="techReviewQuickReplies"
:quick-reply-context="techReviewContext"
primary-action="note"
@update-thread="emit('refetch')"
>
<template #additionalActions>
<Button
v-tooltip="
!canSubmitReview
? 'There are still pending flags!'
: hasSubmittedPassReview
? 'Project already passed!'
: undefined
"
type="colored"
color="brand"
:disabled="!canSubmitReview || hasSubmittedPassReview"
@click="handleSubmitReview('safe')"
>
<ShieldCheckIcon /> Pass
</Button>
<Button
v-tooltip="!canSubmitReview ? 'There are still pending flags!' : undefined"
type="colored"
color="red"
:disabled="!canSubmitReview"
@click="handleSubmitReview('unsafe')"
>
<BugIcon /> Fail
</Button>
<TeleportOverflowMenu
label="More options"
class="btn-dropdown-animation !w-auto !rounded-xl !px-2.5"
:disabled="isLoadingStatusAction"
:options="projectStatusActions"
>
<SpinnerIcon v-if="isLoadingStatusAction" class="animate-spin" aria-hidden="true" />
<ScaleIcon v-else aria-hidden="true" />
Set status
<DropdownIcon aria-hidden="true" />
</TeleportOverflowMenu>
<Button
v-if="featureFlags.developerMode"
type="outlined"
@click="emit('showMaliciousSummary', unsafeFiles)"
>Debug</Button
>
</template>
</ThreadView>
</div>
</CollapsibleRegion>
</template>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { BanIcon, CheckCheckIcon, CheckIcon, ShieldAlertIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { canUpdateGlobalDetail } from './helpers'
import { injectTechReviewDecisions } from './use-tech-review-decisions'
const REMAINING_LABELS = {
globalSafe: 'All remaining globally safe',
localSafe: 'All remaining safe',
localUnsafe: 'All remaining malware',
globalUnsafe: 'All remaining globally unsafe',
} as const
const TRACE_ARIA_LABELS = {
globalSafe: 'Global pass',
localSafe: 'Local pass',
localUnsafe: 'Local fail',
globalUnsafe: 'Global fail',
} as const
const props = defineProps<{
variant: 'remaining' | 'trace'
remainingCount?: number
jar?: boolean
detail?: Labrinth.TechReview.Internal.ReportIssueDetail
globalDisabled?: boolean
localDisabled?: boolean
}>()
const emit = defineEmits<{
globalSafe: []
localSafe: []
localUnsafe: []
globalUnsafe: []
}>()
const {
isDetailActionSelected,
getDetailActionTooltip,
updatingDetails,
updatingGlobalDetailKeys,
} = injectTechReviewDecisions()
const groupAriaLabel = computed(() => {
if (props.variant === 'trace') return 'Trace verdict actions'
return props.jar ? 'Remaining JAR issue actions' : 'Remaining issue actions'
})
const remainingLabel = computed(() =>
props.variant === 'remaining' && props.remainingCount != null
? `${props.remainingCount} issue${props.remainingCount === 1 ? '' : 's'} remaining`
: undefined,
)
const ariaLabels = computed(() =>
props.variant === 'remaining' ? REMAINING_LABELS : TRACE_ARIA_LABELS,
)
function tooltip(decision: 'safe' | 'malware', scope: 'local' | 'global'): string {
if (props.variant === 'remaining') {
if (decision === 'safe' && scope === 'global') return REMAINING_LABELS.globalSafe
if (decision === 'safe') return REMAINING_LABELS.localSafe
if (scope === 'local') return REMAINING_LABELS.localUnsafe
return REMAINING_LABELS.globalUnsafe
}
return getDetailActionTooltip(props.detail!, decision, scope)
}
function selected(decision: 'safe' | 'malware', scope: 'local' | 'global'): boolean {
if (props.variant !== 'trace' || !props.detail) return false
return isDetailActionSelected(props.detail, decision, scope)
}
const isGlobalDisabled = computed(() => {
if (props.variant === 'remaining') return props.globalDisabled
if (!props.detail) return true
return (
!canUpdateGlobalDetail(props.detail) ||
updatingGlobalDetailKeys.has(props.detail.key) ||
updatingDetails.has(props.detail.id)
)
})
const isLocalDisabled = computed(() => {
if (props.variant === 'remaining') return props.localDisabled
if (!props.detail) return true
return updatingDetails.has(props.detail.id) || updatingGlobalDetailKeys.has(props.detail.key)
})
const BUTTON_BASE_CLASS =
'custom-focus-indicator flex size-8 cursor-pointer items-center justify-center border-0 border-l border-solid border-l-surface-5 bg-transparent p-0 transition-[background-color,filter] duration-150 ease-in-out first:rounded-s-[calc(var(--radius-md)-1px)] first:border-l-0 last:rounded-e-[calc(var(--radius-md)-1px)] disabled:cursor-not-allowed disabled:opacity-50 [&>svg]:size-4'
function buttonClass(decision: 'safe' | 'malware', scope: 'local' | 'global') {
return [
BUTTON_BASE_CLASS,
decision === 'safe' ? 'text-green' : 'text-red',
selected(decision, scope)
? 'bg-bg-green shadow-[inset_0_0_0_1px_var(--color-green)] hover:bg-bg-green focus-visible:bg-bg-green focus-visible:shadow-[inset_0_0_0_2px_var(--color-green)]'
: 'hover:bg-surface-4 focus-visible:bg-surface-4 focus-visible:shadow-[inset_0_0_0_2px_var(--color-brand)]',
]
}
</script>
<template>
<div
class="flex items-center overflow-hidden rounded-xl border border-solid border-surface-5 bg-surface-3"
role="group"
:aria-label="groupAriaLabel"
>
<span
v-if="remainingLabel"
class="whitespace-nowrap px-3 text-sm font-semibold text-secondary"
>{{ remainingLabel }}</span
>
<button
v-tooltip="tooltip('safe', 'global')"
:class="buttonClass('safe', 'global')"
:aria-label="ariaLabels.globalSafe"
:disabled="isGlobalDisabled"
@click="emit('globalSafe')"
>
<CheckCheckIcon aria-hidden="true" />
</button>
<button
v-tooltip="tooltip('safe', 'local')"
:class="buttonClass('safe', 'local')"
:aria-label="ariaLabels.localSafe"
:disabled="isLocalDisabled"
@click="emit('localSafe')"
>
<CheckIcon aria-hidden="true" />
</button>
<button
v-tooltip="tooltip('malware', 'local')"
:class="buttonClass('malware', 'local')"
:aria-label="ariaLabels.localUnsafe"
:disabled="isLocalDisabled"
@click="emit('localUnsafe')"
>
<BanIcon aria-hidden="true" />
</button>
<button
v-tooltip="tooltip('malware', 'global')"
:class="buttonClass('malware', 'global')"
:aria-label="ariaLabels.globalUnsafe"
:disabled="isGlobalDisabled"
@click="emit('globalUnsafe')"
>
<ShieldAlertIcon aria-hidden="true" />
</button>
</div>
</template>
@@ -0,0 +1,104 @@
import type { Labrinth } from '@modrinth/api-client'
import type { DetailDecision, FlattenedFileReport } from './types'
export const severityOrder: Record<Labrinth.TechReview.Internal.DelphiSeverity, number> = {
severe: 3,
high: 2,
medium: 1,
low: 0,
}
export function getSeverityBadgeColor(
severity: Labrinth.TechReview.Internal.DelphiSeverity,
): string {
switch (severity) {
case 'severe':
return 'border-red/60 border bg-highlight-red text-red'
case 'high':
return 'border-orange/60 border bg-highlight-orange text-orange'
case 'medium':
return 'border-green/60 border bg-highlight-green text-green'
case 'low':
default:
return 'border-blue/60 border bg-highlight-blue text-blue'
}
}
export function truncateMiddle(str: string, maxLength = 120): string {
if (str.length <= maxLength) return str
const keep = maxLength - 3
const front = Math.ceil(keep / 3)
return str.slice(0, front) + '...' + str.slice(front - keep)
}
export function getFileHighestSeverity(
file: FlattenedFileReport,
): Labrinth.TechReview.Internal.DelphiSeverity {
let highest: Labrinth.TechReview.Internal.DelphiSeverity = 'low'
for (const issue of file.issues) {
for (const detail of issue.details) {
if (severityOrder[detail.severity] > severityOrder[highest]) {
highest = detail.severity
}
}
}
return highest
}
export function getFileDetailCount(file: FlattenedFileReport): number {
return file.issues.reduce((sum, issue) => sum + issue.details.length, 0)
}
export function flattenFileReports(
versions: Labrinth.TechReview.Internal.VersionReport[],
): FlattenedFileReport[] {
return versions.flatMap((version) =>
version.files.map((file) => ({
...file,
id: file.report_id,
version_id: version.version_id,
version_number: version.version_number,
})),
)
}
export function getVersionLabel(file: FlattenedFileReport): string {
return file.version_number || file.version_id
}
export function getVersionPageHref(
project: { id: string; slug?: string; project_types: string[] },
versionId: string,
): string {
return `/${project.project_types[0] ?? 'project'}/${project.slug ?? project.id}/version/${versionId}`
}
export function verdictToDecision(
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
): DetailDecision {
if (verdict === 'safe') return 'safe'
if (verdict === 'unsafe') return 'malware'
return 'pending'
}
export function decisionToVerdict(
decision: Exclude<DetailDecision, 'pending'>,
): Labrinth.TechReview.Internal.DelphiReportIssueStatus {
return decision === 'safe' ? 'safe' : 'unsafe'
}
export function statusMatchesDecision(
status: Labrinth.TechReview.Internal.DelphiReportIssueStatus | null,
decision: DetailDecision,
): boolean {
if (status === 'safe') return decision === 'safe'
if (status === 'unsafe') return decision === 'malware'
return false
}
export function canUpdateGlobalDetail(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
): boolean {
return detail.key.length > 0 && !detail.key.startsWith('<no-key-')
}
@@ -0,0 +1,30 @@
import type { Labrinth } from '@modrinth/api-client'
export type FlattenedFileReport = Labrinth.TechReview.Internal.FileReport & {
id: string
version_id: string
version_number?: string
}
export type DetailDecision = 'safe' | 'malware' | 'pending'
export type DetailDecisionScope = 'local' | 'global'
export type FlagItem = {
issueId: string
issueType: string
detail: Labrinth.TechReview.Internal.ReportIssueDetail
}
export type ClassGroup = {
key: string
jar: string | null
filePath: string
flags: FlagItem[]
}
export type JarGroup = {
key: string
jar: string | null
segments: string[]
classes: ClassGroup[]
}
@@ -0,0 +1,215 @@
import type { Labrinth } from '@modrinth/api-client'
import { injectModrinthClient } from '@modrinth/ui'
import { inject, type InjectionKey, type MaybeRefOrGetter, reactive, toValue } from 'vue'
import { canUpdateGlobalDetail, decisionToVerdict, statusMatchesDecision } from './helpers'
import type { DetailDecision, DetailDecisionScope, FlagItem, FlattenedFileReport } from './types'
export function useTechReviewDecisions(reports: MaybeRefOrGetter<FlattenedFileReport[]>) {
const client = injectModrinthClient()
const detailDecisions = reactive<Map<string, DetailDecision>>(new Map())
const detailDecisionScopes = reactive<Map<string, DetailDecisionScope>>(new Map())
const updatingDetails = reactive<Set<string>>(new Set())
const updatingGlobalDetailKeys = reactive<Set<string>>(new Set())
function getDetailDecision(
detailId: string,
backendStatus: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
): DetailDecision {
const localDecision = detailDecisions.get(detailId)
if (localDecision) return localDecision
if (backendStatus === 'safe') return 'safe'
if (backendStatus === 'unsafe') return 'malware'
return 'pending'
}
function isPreReviewed(
detailId: string,
backendStatus: Labrinth.TechReview.Internal.DelphiReportIssueStatus,
): boolean {
return (
(backendStatus === 'safe' || backendStatus === 'unsafe') && !detailDecisions.has(detailId)
)
}
function getFileMarkedCount(file: FlattenedFileReport): number {
let count = 0
for (const issue of file.issues) {
for (const detail of issue.details) {
if (getDetailDecision(detail.id, detail.status) !== 'pending') count++
}
}
return count
}
function getMarkedFlagsCount(flags: FlagItem[]): number {
return flags.filter((f) => getDetailDecision(f.detail.id, f.detail.status) !== 'pending').length
}
function isDetailGloballyPassed(detail: Labrinth.TechReview.Internal.ReportIssueDetail): boolean {
if (detailDecisionScopes.get(detail.id) === 'global') {
return detailDecisions.get(detail.id) === 'safe'
}
return detail.global_status === 'safe'
}
function isDetailGloballyResolved(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
): boolean {
if (detailDecisionScopes.get(detail.id) === 'global') {
return detailDecisions.get(detail.id) !== 'pending'
}
return detail.global_status === 'safe' || detail.global_status === 'unsafe'
}
function applyDecisionToRelatedDetails(
detailIds: string[],
decision: DetailDecision,
scope: DetailDecisionScope,
): { otherMatchedCount: number } {
const allDetails = toValue(reports).flatMap((report) =>
report.issues.flatMap((issue) => issue.details),
)
const selectedDetailIds = new Set(detailIds)
const updatedDetailIds = new Set<string>()
for (const detailId of detailIds) {
const detail = allDetails.find((candidate) => candidate.id === detailId)
const matchingDetails = detail?.key
? allDetails.filter((candidate) => candidate.key === detail.key)
: detail
? [detail]
: []
if (matchingDetails.length === 0) {
detailDecisions.set(detailId, decision)
detailDecisionScopes.set(detailId, scope)
updatedDetailIds.add(detailId)
continue
}
for (const matchingDetail of matchingDetails) {
detailDecisions.set(matchingDetail.id, decision)
detailDecisionScopes.set(matchingDetail.id, scope)
updatedDetailIds.add(matchingDetail.id)
}
}
return {
otherMatchedCount: [...updatedDetailIds].filter((id) => !selectedDetailIds.has(id)).length,
}
}
function isDetailActionSelected(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: DetailDecision,
scope: DetailDecisionScope,
): boolean {
const localDecision = detailDecisions.get(detail.id)
const localScope = detailDecisionScopes.get(detail.id)
if (localDecision && localScope) {
if (localDecision === 'pending') {
if (localScope === 'local') {
if (scope === 'local') return false
return statusMatchesDecision(detail.global_status, decision)
}
if (scope === 'global') return false
return statusMatchesDecision(detail.local_status, decision)
}
return localDecision === decision && localScope === scope
}
if (scope === 'global') {
return statusMatchesDecision(detail.global_status, decision)
}
if (detail.global_status) return false
return statusMatchesDecision(detail.local_status, decision)
}
function getToggledDetailVerdict(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: Exclude<DetailDecision, 'pending'>,
scope: DetailDecisionScope,
): Labrinth.TechReview.Internal.DelphiReportIssueStatus {
return isDetailActionSelected(detail, decision, scope) ? 'pending' : decisionToVerdict(decision)
}
function getDetailActionTooltip(
detail: Labrinth.TechReview.Internal.ReportIssueDetail,
decision: Exclude<DetailDecision, 'pending'>,
scope: DetailDecisionScope,
): string {
const action = decision === 'safe' ? 'pass' : 'fail'
const scopeLabel = scope === 'global' ? 'Global' : 'Local'
if (scope === 'global' && !canUpdateGlobalDetail(detail)) {
return 'Global verdict unavailable for generated trace keys'
}
if (isDetailActionSelected(detail, decision, scope)) {
return `Unset ${scopeLabel.toLowerCase()} ${action}`
}
return `${scopeLabel} ${action}`
}
async function updateIssueDetails(
data: {
detail_id: string
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus
}[],
) {
await client.request('/moderation/tech-review/issue-detail', {
api: 'labrinth',
version: 'internal',
method: 'PATCH',
body: data,
})
}
async function updateGlobalIssueDetails(
data: {
detail_key: string
verdict: Labrinth.TechReview.Internal.DelphiReportIssueStatus
}[],
) {
await client.labrinth.tech_review_internal.updateGlobalIssueDetails(data)
}
return {
updatingDetails,
updatingGlobalDetailKeys,
getDetailDecision,
isPreReviewed,
getFileMarkedCount,
getMarkedFlagsCount,
isDetailGloballyPassed,
isDetailGloballyResolved,
applyDecisionToRelatedDetails,
isDetailActionSelected,
getToggledDetailVerdict,
getDetailActionTooltip,
updateIssueDetails,
updateGlobalIssueDetails,
}
}
export type TechReviewDecisions = ReturnType<typeof useTechReviewDecisions>
export const TECH_REVIEW_DECISIONS_KEY: InjectionKey<TechReviewDecisions> =
Symbol('techReviewDecisions')
export function injectTechReviewDecisions(): TechReviewDecisions {
const decisions = inject(TECH_REVIEW_DECISIONS_KEY)
if (!decisions) {
throw new Error('Tech review decisions must be provided by ModerationTechRevCard')
}
return decisions
}
@@ -0,0 +1,126 @@
import type { Labrinth } from '@modrinth/api-client'
import { injectModrinthClient } from '@modrinth/ui'
import { type MaybeRefOrGetter, reactive, toValue } from 'vue'
const CACHE_TTL = 24 * 60 * 60 * 1000
const CACHE_KEY_PREFIX = 'tech_review_source_'
type CachedSource = {
source: string
timestamp: number
}
function getCachedSource(detailId: string): string | null {
try {
const cached = localStorage.getItem(`${CACHE_KEY_PREFIX}${detailId}`)
if (!cached) return null
const data: CachedSource = JSON.parse(cached)
const now = Date.now()
if (now - data.timestamp > CACHE_TTL) {
localStorage.removeItem(`${CACHE_KEY_PREFIX}${detailId}`)
return null
}
return data.source
} catch {
return null
}
}
function setCachedSource(detailId: string, source: string): void {
try {
const data: CachedSource = {
source,
timestamp: Date.now(),
}
localStorage.setItem(`${CACHE_KEY_PREFIX}${detailId}`, JSON.stringify(data))
} catch (error) {
console.error('Failed to cache source:', error)
}
}
function clearExpiredCache(): void {
try {
const now = Date.now()
const keys = Object.keys(localStorage)
for (const key of keys) {
if (key.startsWith(CACHE_KEY_PREFIX)) {
const cached = localStorage.getItem(key)
if (cached) {
const data: CachedSource = JSON.parse(cached)
if (now - data.timestamp > CACHE_TTL) {
localStorage.removeItem(key)
}
}
}
}
} catch (error) {
console.error('Failed to clear expired cache:', error)
}
}
export function useTechReviewSources(
issues: MaybeRefOrGetter<Labrinth.TechReview.Internal.FileIssue[]>,
) {
const client = injectModrinthClient()
if (import.meta.client) {
clearExpiredCache()
}
const loadingIssues = reactive<Set<string>>(new Set())
const decompiledSources = reactive<Map<string, string>>(new Map())
const loadedIssues = reactive<Set<string>>(new Set())
async function loadIssueSource(issueId: string): Promise<void> {
if (loadingIssues.has(issueId) || loadedIssues.has(issueId)) return
loadingIssues.add(issueId)
try {
const issueData = await client.labrinth.tech_review_internal.getIssue(issueId)
for (const detail of issueData.details) {
if (detail.decompiled_source) {
decompiledSources.set(detail.id, detail.decompiled_source)
setCachedSource(detail.id, detail.decompiled_source)
}
}
loadedIssues.add(issueId)
} catch (error) {
console.error('Failed to load issue source:', error)
} finally {
loadingIssues.delete(issueId)
}
}
function handleLoadIssueSources(issueIds: string[]): void {
const uniqueIssueIds = new Set(issueIds)
const matchedIssues = toValue(issues).filter((issue) => uniqueIssueIds.has(issue.id))
for (const issue of matchedIssues) {
for (const detail of issue.details) {
if (!decompiledSources.has(detail.id)) {
const cached = getCachedSource(detail.id)
if (cached) {
decompiledSources.set(detail.id, cached)
}
}
}
const hasUncached = issue.details.some((detail) => !decompiledSources.has(detail.id))
if (hasUncached) {
loadIssueSource(issue.id)
}
}
}
return {
loadingIssues,
decompiledSources,
handleLoadIssueSources,
}
}
@@ -95,7 +95,7 @@
</div>
</div>
</NewModal>
<div v-if="flags.developerMode" class="mx-4 mb-3 font-semibold">
<div v-if="flags.showThreadIds" class="mx-4 mb-3 font-semibold">
Thread ID:
<CopyCode :text="thread.id" />
</div>
@@ -113,18 +113,19 @@
@update-thread="() => updateThreadLocal()"
/>
</div>
<template v-if="report && report.closed">
<p>{{ formatMessage(messages.closedThreadDescription) }}</p>
<div v-if="report && report.closed" class="m-4 mt-2 flex flex-col gap-4">
<p class="m-0">{{ formatMessage(messages.closedThreadDescription) }}</p>
<Button
v-if="isStaff(auth.user)"
:disabled="isLoading"
class="w-fit"
@click="runBlockingAction('reopen', () => reopenReport())"
>
<SpinnerIcon v-if="loadingAction === 'reopen'" class="animate-spin" aria-hidden="true" />
<CheckCircleIcon v-else aria-hidden="true" />
{{ formatMessage(messages.actionReopenThread) }}
</Button>
</template>
</div>
<template v-else-if="!report || !report.closed">
<div class="mx-4 mb-2 mt-2">
<MarkdownEditor
@@ -211,36 +212,34 @@
</div>
<div class="flex flex-wrap items-center gap-2">
<template v-if="report">
<template v-if="isStaff(auth.user)">
<Button
v-if="replyBody"
type="colored"
color="red"
:disabled="isLoading"
@click="runBlockingAction('close-with-reply', () => closeReport(true))"
>
<SpinnerIcon
v-if="loadingAction === 'close-with-reply'"
class="animate-spin"
aria-hidden="true"
/>
<CheckCircleIcon v-else aria-hidden="true" />
{{ formatMessage(messages.actionCloseWithReply) }}
</Button>
<Button
v-else
:disabled="isLoading"
@click="runBlockingAction('close', () => closeReport())"
>
<SpinnerIcon
v-if="loadingAction === 'close'"
class="animate-spin"
aria-hidden="true"
/>
<CheckCircleIcon v-else aria-hidden="true" />
{{ formatMessage(messages.actionCloseThread) }}
</Button>
</template>
<Button
v-if="isStaff(auth.user) && replyBody"
type="colored"
color="red"
:disabled="isLoading"
@click="runBlockingAction('close-with-reply', () => closeReport(true))"
>
<SpinnerIcon
v-if="loadingAction === 'close-with-reply'"
class="animate-spin"
aria-hidden="true"
/>
<CheckCircleIcon v-else aria-hidden="true" />
{{ formatMessage(messages.actionCloseWithReply) }}
</Button>
<Button
v-else
:disabled="isLoading"
@click="runBlockingAction('close', () => closeReport())"
>
<SpinnerIcon
v-if="loadingAction === 'close'"
class="animate-spin"
aria-hidden="true"
/>
<CheckCircleIcon v-else aria-hidden="true" />
{{ formatMessage(messages.actionCloseThread) }}
</Button>
</template>
<template v-if="project">
<template v-if="isStaff(auth.user)">
@@ -526,8 +525,8 @@ const messages = defineMessages({
defaultMessage: 'Close with reply',
},
actionCloseThread: {
id: 'conversation-thread.action.close-thread',
defaultMessage: 'Close thread',
id: 'conversation-thread.action.close-report',
defaultMessage: 'Close report',
},
actionApproveWithReply: {
id: 'conversation-thread.action.approve-with-reply',
@@ -1,6 +1,6 @@
<template>
<div>
<div v-if="flags.developerMode" class="m-4 font-bold text-heading">
<div v-if="flags.showThreadIds" class="m-4 font-bold text-heading">
Thread ID:
<CopyCode :text="thread.id" />
</div>
@@ -23,10 +23,12 @@
<p class="text-lg text-secondary">No messages yet</p>
</div>
<template v-if="closed">
<p class="text-secondary">This thread is closed and new messages cannot be sent to it.</p>
<slot name="closedActions" />
</template>
<div v-if="closed" class="flex flex-col gap-4 p-4 pt-2">
<p class="m-0 text-secondary">This thread is closed and new messages cannot be sent to it.</p>
<div>
<slot name="closedActions" />
</div>
</div>
<template v-else>
<div class="px-4 py-2">
@@ -41,43 +43,58 @@
class="mt-4 flex flex-col items-stretch justify-between gap-3 px-4 pb-4 sm:flex-row sm:items-center sm:gap-2"
>
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<Button
v-if="sortedMessages.length > 0"
<SplitButton
v-if="primaryAction === 'note' && isStaff(auth.user)"
type="colored"
color="brand"
menu-label="More send options"
:disabled="!replyBody"
class="w-full gap-2 sm:w-auto"
@click="sendReply()"
>
<ReplyIcon class="size-4" />
Reply
</Button>
<Button
v-else
type="colored"
color="brand"
:disabled="!replyBody"
class="w-full gap-2 sm:w-auto"
@click="sendReply()"
>
<SendIcon class="size-4" />
Send
</Button>
<Button
v-if="isStaff(auth.user)"
:disabled="!replyBody"
:options="publicMessageOptions"
class="w-full sm:w-auto"
@click="sendReply(true)"
>
Add note
</Button>
</SplitButton>
<template v-else>
<Button
v-if="sortedMessages.length > 0"
type="colored"
color="brand"
:disabled="!replyBody"
class="w-full gap-2 sm:w-auto"
@click="sendReply()"
>
<ReplyIcon class="size-4" />
Reply
</Button>
<Button
v-else
type="colored"
color="brand"
:disabled="!replyBody"
class="w-full gap-2 sm:w-auto"
@click="sendReply()"
>
<SendIcon class="size-4" />
Send
</Button>
<Button
v-if="isStaff(auth.user)"
:disabled="!replyBody"
class="w-full sm:w-auto"
@click="sendReply(true)"
>
Add note
</Button>
</template>
<TeleportOverflowMenu
v-if="visibleQuickReplies.length > 0"
label="More options"
:options="visibleQuickReplies"
class="!w-auto !rounded-xl !px-2.5"
>
Quick reply
<ArrowUpFromLineIcon />
Load preset
<ChevronDownIcon />
</TeleportOverflowMenu>
</div>
@@ -91,9 +108,15 @@
</template>
<script setup lang="ts" generic="T">
import { ChevronDownIcon, MessageIcon, ReplyIcon, SendIcon } from '@modrinth/assets'
import {
ArrowUpFromLineIcon,
ChevronDownIcon,
MessageIcon,
ReplyIcon,
SendIcon,
} from '@modrinth/assets'
import type { QuickReply } from '@modrinth/moderation'
import { Button, TeleportOverflowMenu } from '@modrinth/ui'
import { Button, SplitButton, TeleportOverflowMenu } from '@modrinth/ui'
import {
type ButtonMenuOption,
CopyCode,
@@ -110,6 +133,19 @@ import ThreadMessage from './ThreadMessage.vue'
const { addNotification } = injectNotificationManager()
const props = withDefaults(
defineProps<{
thread: Thread
quickReplies?: ReadonlyArray<QuickReply<T>>
quickReplyContext?: T
closed?: boolean
primaryAction?: 'reply' | 'note'
}>(),
{
primaryAction: 'reply',
},
)
const visibleQuickReplies = computed<ButtonMenuOption[]>(() => {
const replies = props.quickReplies
const context = props.quickReplyContext
@@ -131,13 +167,6 @@ const visibleQuickReplies = computed<ButtonMenuOption[]>(() => {
)
})
const props = defineProps<{
thread: Thread
quickReplies?: ReadonlyArray<QuickReply<T>>
quickReplyContext?: T
closed?: boolean
}>()
async function handleQuickReply(reply: QuickReply<T>, context: T) {
const message = typeof reply.message === 'function' ? await reply.message(context) : reply.message
@@ -151,7 +180,7 @@ defineExpose({
sendReply,
})
const auth = await useAuth()
const auth = useAuthState()
const emit = defineEmits<{
updateThread: [thread: Thread]
@@ -169,6 +198,16 @@ const members = computed(() => {
const replyBody = ref('')
const publicMessageOptions = computed<ButtonMenuOption[]>(() => [
{
id: 'send-public',
label: 'Send publicly',
icon: SendIcon,
action: () => sendReply(false),
disabled: !replyBody.value,
},
])
function setReplyContent(content: string) {
replyBody.value = content
}
+7 -4
View File
@@ -52,14 +52,17 @@ const getQueryString = (value: QueryValue) => {
return value ?? null
}
export const useAuthState = () =>
useState<AuthState>('auth', () => ({
user: null,
token: '',
}))
export const useAuth = async (
oldToken: string | null | undefined = null,
route?: AuthInitRoute,
) => {
const auth = useState<AuthState>('auth', () => ({
user: null,
token: '',
}))
const auth = useAuthState()
if (!auth.value.user || oldToken) {
auth.value = await initAuth(oldToken, route)
@@ -19,6 +19,7 @@ const validateValues = <K extends PropertyKey>(flags: Record<K, FlagValue>) => f
export const DEFAULT_FEATURE_FLAGS = validateValues({
// Developer flags
developerMode: false,
showThreadIds: false,
demoMode: false,
showVersionFilesInTable: false,
showVersionEnvironmentColumn: false,
@@ -1025,9 +1025,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "الموافقة مع الرد"
},
"conversation-thread.action.close-thread": {
"message": "إغلاق سلسلة المحادثة"
},
"conversation-thread.action.close-with-reply": {
"message": "الإغلاق مع الرد"
},
@@ -917,9 +917,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Schválit s odpovědí"
},
"conversation-thread.action.close-thread": {
"message": "Uzavřít vlákno"
},
"conversation-thread.action.close-with-reply": {
"message": "Uzavřít s odpovědí"
},
@@ -677,9 +677,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Godkend med svar"
},
"conversation-thread.action.close-thread": {
"message": "Luk tråd"
},
"conversation-thread.action.close-with-reply": {
"message": "Luk med svar"
},
@@ -1025,9 +1025,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Genehmigen mit Antwort"
},
"conversation-thread.action.close-thread": {
"message": "Thread schliessen"
},
"conversation-thread.action.close-with-reply": {
"message": "Schliessen mit Antwort"
},
@@ -1025,9 +1025,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Mit Antwort annehmen"
},
"conversation-thread.action.close-thread": {
"message": "Thread schließen"
},
"conversation-thread.action.close-with-reply": {
"message": "Mit Antwort schließen"
},
+2 -2
View File
@@ -1070,8 +1070,8 @@
"conversation-thread.action.approve-with-reply": {
"message": "Approve with reply"
},
"conversation-thread.action.close-thread": {
"message": "Close thread"
"conversation-thread.action.close-report": {
"message": "Close report"
},
"conversation-thread.action.close-with-reply": {
"message": "Close with reply"
@@ -1025,9 +1025,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Aprovar con respuesta"
},
"conversation-thread.action.close-thread": {
"message": "Cerrar hilo"
},
"conversation-thread.action.close-with-reply": {
"message": "Cerrar con respuesta"
},
@@ -1025,9 +1025,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Aprobar con respuesta"
},
"conversation-thread.action.close-thread": {
"message": "Cerrar hilo"
},
"conversation-thread.action.close-with-reply": {
"message": "Cerrar con respuesta"
},
@@ -383,9 +383,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Hyväksy vastauksella"
},
"conversation-thread.action.close-thread": {
"message": "Sulje keskustelu"
},
"conversation-thread.action.close-with-reply": {
"message": "Sulje vastauksella"
},
@@ -1025,9 +1025,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Approuver avec réponse"
},
"conversation-thread.action.close-thread": {
"message": "Fermer le fil"
},
"conversation-thread.action.close-with-reply": {
"message": "Fermer avec réponse"
},
@@ -1007,9 +1007,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Jóváhagyás és válasz"
},
"conversation-thread.action.close-thread": {
"message": "Gondolatmenet lezárása"
},
"conversation-thread.action.close-with-reply": {
"message": "Bezárás és válasz"
},
@@ -1019,9 +1019,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Approva con risposta"
},
"conversation-thread.action.close-thread": {
"message": "Chiudi thread"
},
"conversation-thread.action.close-with-reply": {
"message": "Chiudi con risposta"
},
@@ -1022,9 +1022,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "送信して承認"
},
"conversation-thread.action.close-thread": {
"message": "スレッドを閉じる"
},
"conversation-thread.action.close-with-reply": {
"message": "返信して閉じる"
},
@@ -1025,9 +1025,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "답글과 함께 승인"
},
"conversation-thread.action.close-thread": {
"message": "스레드 닫기"
},
"conversation-thread.action.close-with-reply": {
"message": "답글과 함께 닫기"
},
@@ -794,9 +794,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Luluskan dengan balasan"
},
"conversation-thread.action.close-thread": {
"message": "Tutup bebenang"
},
"conversation-thread.action.close-with-reply": {
"message": "Tutup dengan balasan"
},
@@ -1022,9 +1022,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Accepteren met antwoord"
},
"conversation-thread.action.close-thread": {
"message": "Sluit thread"
},
"conversation-thread.action.close-with-reply": {
"message": "Sluit met antwoord"
},
@@ -794,9 +794,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Godkjenn med svar"
},
"conversation-thread.action.close-thread": {
"message": "Lukk tråden"
},
"conversation-thread.action.close-with-reply": {
"message": "Lukk med svar"
},
@@ -1022,9 +1022,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Zatwierdź z odpowiedzią "
},
"conversation-thread.action.close-thread": {
"message": "Zamknij wątek"
},
"conversation-thread.action.close-with-reply": {
"message": "Zamknij z odpowiedzią"
},
@@ -1025,9 +1025,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Aprovar com resposta"
},
"conversation-thread.action.close-thread": {
"message": "Encerrar tópico"
},
"conversation-thread.action.close-with-reply": {
"message": "Encerrar com resposta"
},
@@ -1025,9 +1025,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Aprovar com comentário"
},
"conversation-thread.action.close-thread": {
"message": "Fechar tópico"
},
"conversation-thread.action.close-with-reply": {
"message": "Fechar com comentário"
},
@@ -1019,9 +1019,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Одобрить с ответом"
},
"conversation-thread.action.close-thread": {
"message": "Закрыть ветку"
},
"conversation-thread.action.close-with-reply": {
"message": "Закрыть с ответом"
},
@@ -959,9 +959,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Godkänn med svar"
},
"conversation-thread.action.close-thread": {
"message": "Stäng tråd"
},
"conversation-thread.action.close-with-reply": {
"message": "Stäng med svar"
},
@@ -1025,9 +1025,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Onaylandı, işlem tamamlandı"
},
"conversation-thread.action.close-thread": {
"message": "Konuyu kapat"
},
"conversation-thread.action.close-with-reply": {
"message": "Cevapla ve kapat"
},
@@ -1022,9 +1022,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Підтвердити з відповіддю"
},
"conversation-thread.action.close-thread": {
"message": "Закрити тему"
},
"conversation-thread.action.close-with-reply": {
"message": "Закрити з відповіддю"
},
@@ -908,9 +908,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "Chấp thuận với phản hồi"
},
"conversation-thread.action.close-thread": {
"message": "Đóng luồng"
},
"conversation-thread.action.close-with-reply": {
"message": "Đóng với phản hồi"
},
@@ -1025,9 +1025,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "批准并回复"
},
"conversation-thread.action.close-thread": {
"message": "关闭对话消息"
},
"conversation-thread.action.close-with-reply": {
"message": "关闭并回复"
},
@@ -1025,9 +1025,6 @@
"conversation-thread.action.approve-with-reply": {
"message": "回覆並核准"
},
"conversation-thread.action.close-thread": {
"message": "關閉討論串"
},
"conversation-thread.action.close-with-reply": {
"message": "回覆並關閉"
},
@@ -89,12 +89,11 @@
@download="emit('onDownload')"
/>
<div class="flex flex-col">
<nuxt-link
class="mb-4 flex w-fit items-center gap-2 rounded-lg px-2 py-0.5 pl-0 text-link"
<BackToParentLink
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/versions`"
>
<ChevronLeftIcon class="shrink-0" /> {{ formatMessage(messages.allVersions) }}
</nuxt-link>
{{ formatMessage(messages.allVersions) }}
</BackToParentLink>
<template v-if="version">
<Admonition
v-if="version.files_missing_attribution?.length"
@@ -521,6 +520,7 @@ import {
import { moderationSettings } from '@modrinth/moderation'
import {
Admonition,
BackToParentLink,
Button,
ButtonLink,
Collapsible,
@@ -36,7 +36,7 @@
</form>
</NewModal>
<div>
<form class="flex gap-2" @submit.prevent="executeSearch">
<form class="flex items-center gap-2" @submit.prevent="executeSearch">
<Input
v-model="query"
:icon="SearchIcon"
@@ -44,17 +44,18 @@
autocomplete="off"
placeholder="Search external projects..."
clearable
wrapper-class="flex-1 w-full"
size="medium"
wrapper-class="min-w-0 flex-1"
/>
<Button type="colored" color="brand" native-type="submit">
<Button type="colored" color="brand" size="lg" native-type="submit">
<SearchIcon aria-hidden="true" />
Search by title
</Button>
<Button native-type="button" @click="executeFlameIdLookup">
<Button size="lg" native-type="button" @click="executeFlameIdLookup">
<BinaryIcon aria-hidden="true" />
Lookup CurseForge ID
</Button>
<Button native-type="button" @click="executeSha1Lookup">
<Button size="lg" native-type="button" @click="executeSha1Lookup">
<HashIcon aria-hidden="true" />
Lookup SHA-1
</Button>
+94 -109
View File
@@ -1,95 +1,88 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<Input
v-model="query"
:icon="SearchIcon"
type="text"
autocomplete="off"
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
clearable
wrapper-class="flex-1"
input-class="h-[40px] w-full"
@input="goToPage(1)"
/>
<ModerationQueueToolbar
v-model="query"
:page="currentPage"
:total-pages="totalPages"
@search="goToPage(1)"
@switch-page="goToPage"
>
<template #actions>
<Combobox
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
trigger-type="base"
trigger-size="lg"
:options="filterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
@select="goToPage(1)"
>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
<ModerationFilterCount
:label="currentFilterType"
:count="totalProjects"
:loading="pending"
/>
</span>
</template>
</Combobox>
<div class="flex flex-col flex-wrap justify-end gap-2 sm:flex-row lg:flex-shrink-0">
<div class="flex flex-col gap-2 sm:flex-row">
<Combobox
v-model="currentFilterType"
class="!w-full flex-grow sm:!w-[280px] sm:flex-grow-0 lg:!w-[280px]"
trigger-type="base"
trigger-size="lg"
:options="filterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
@select="goToPage(1)"
>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast"
>{{ currentFilterType }} ({{ totalProjects }})</span
>
</span>
</template>
</Combobox>
<Combobox
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[240px] sm:flex-grow-0"
trigger-type="base"
trigger-size="lg"
:options="sortTypes"
:placeholder="formatMessage(commonMessages.sortByLabel)"
@select="goToPage(1)"
>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<SortAscIcon
v-if="currentSortType === 'Oldest' || currentSortType === 'Least external deps'"
class="size-5 flex-shrink-0 text-secondary"
/>
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast">{{ currentSortType }}</span>
</span>
</template>
</Combobox>
<Combobox
v-model="currentSortType"
class="!w-full flex-grow sm:!w-[240px] sm:flex-grow-0"
trigger-type="base"
trigger-size="lg"
:options="sortTypes"
:placeholder="formatMessage(commonMessages.sortByLabel)"
@select="goToPage(1)"
>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<SortAscIcon
v-if="currentSortType === 'Oldest' || currentSortType === 'Least external deps'"
class="size-5 flex-shrink-0 text-secondary"
/>
<SortDescIcon v-else class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast">{{ currentSortType }}</span>
</span>
</template>
</Combobox>
<Combobox
v-model="itemsPerPage"
class="!w-full flex-grow sm:!w-[160px] sm:flex-grow-0 lg:!w-[140px]"
trigger-type="base"
trigger-size="lg"
:options="itemsPerPageOptions"
placeholder="Items per page"
@select="goToPage(1)"
>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<span class="truncate text-contrast">{{ itemsPerPage }} items</span>
</span>
</template>
</Combobox>
</div>
<Combobox
v-model="itemsPerPage"
class="!w-full flex-grow sm:!w-[160px] sm:flex-grow-0 lg:!w-[140px]"
trigger-type="base"
trigger-size="lg"
:options="itemsPerPageOptions"
placeholder="Items per page"
@select="goToPage(1)"
>
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<span class="truncate text-contrast">{{ itemsPerPage }} items</span>
</span>
</template>
</Combobox>
<Button
type="colored"
color="orange"
class="flex !h-[40px] w-full items-center justify-center gap-2 sm:w-auto"
size="lg"
class="w-full sm:w-auto"
:disabled="pending || paginatedProjects?.length === 0"
@click="moderateAllInFilter()"
>
<ScaleIcon class="flex-shrink-0" />
<ScaleIcon />
<span class="hidden sm:inline">{{ formatMessage(messages.moderate) }}</span>
<span class="sm:hidden">Moderate</span>
</Button>
</div>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap items-center gap-3">
</template>
<template #meta>
<div v-if="totalProjects > 0">
Showing {{ pageStart }}{{ pageEnd }} of {{ totalProjects }}
Showing {{ formatNumber(pageStart) }}{{ formatNumber(pageEnd) }} of
{{ formatNumber(totalProjects) }}
{{
currentFilterType === DEFAULT_FILTER_TYPE ? 'projects' : currentFilterType.toLowerCase()
}}
@@ -100,39 +93,27 @@
{{ formatMessage(messages.excludeTechnicalReview) }}
</label>
</div>
</div>
<Pagination
v-if="totalPages > 1"
:page="currentPage"
:count="totalPages"
@switch-page="goToPage"
/>
<ConfettiExplosion v-if="visible" />
<QueueSummaryModal
ref="queueSummaryModal"
:completed-ids="moderationQueue.currentQueue.completed"
:skipped-ids="moderationQueue.currentQueue.skipped"
@review-skipped="reviewSkippedQueue"
/>
</div>
<div class="flex flex-col gap-3">
<template v-if="pending">
<div
v-for="i in 3"
:key="`loading-skeleton-${i}`"
class="flex h-[98px] w-full animate-pulse rounded-2xl bg-surface-3"
></div>
</template>
<EmptyState
v-else-if="paginatedProjects.length === 0"
:type="!!query ? 'no-search-result' : 'no-tasks'"
:heading="emptyStateHeading"
:description="emptyStateDescription"
/>
</ModerationQueueToolbar>
<ConfettiExplosion v-if="visible" />
<QueueSummaryModal
ref="queueSummaryModal"
:completed-ids="moderationQueue.currentQueue.completed"
:skipped-ids="moderationQueue.currentQueue.skipped"
@review-skipped="reviewSkippedQueue"
/>
<ModerationQueueSkeleton v-if="pending" />
<EmptyState
v-else-if="paginatedProjects.length === 0"
:type="!!query ? 'no-search-result' : 'no-tasks'"
:heading="emptyStateHeading"
:description="emptyStateDescription"
/>
<div v-else class="flex flex-col gap-3">
<ModerationQueueCard
v-for="item in paginatedProjects"
v-else
:key="item.project.id"
:queue-entry="item"
:show-external-dependencies="currentFilterType === MODPACK_FILTER_TYPE"
@@ -147,7 +128,7 @@
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ListFilterIcon, ScaleIcon, SearchIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
import { ListFilterIcon, ScaleIcon, SortAscIcon, SortDescIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import {
Combobox,
@@ -157,15 +138,18 @@ import {
EmptyState,
injectModrinthClient,
injectNotificationManager,
Input,
Pagination,
Toggle,
useFormatNumber,
useVIntl,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import ConfettiExplosion from 'vue-confetti-explosion'
import ModerationFilterCount from '~/components/ui/moderation/ModerationFilterCount.vue'
import ModerationQueueCard from '~/components/ui/moderation/ModerationQueueCard.vue'
import ModerationQueueSkeleton from '~/components/ui/moderation/ModerationQueueSkeleton.vue'
import ModerationQueueToolbar from '~/components/ui/moderation/ModerationQueueToolbar.vue'
import QueueSummaryModal from '~/components/ui/moderation/QueueSummaryModal.vue'
import { type ModerationProject, toModerationProjects } from '~/helpers/moderation.ts'
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
@@ -175,6 +159,7 @@ import { findNextEligibleQueueProject } from '~/services/moderation/queue-eligib
useHead({ title: 'Projects queue - Modrinth' })
const { formatMessage } = useVIntl()
const formatNumber = useFormatNumber()
const { addNotification } = injectNotificationManager()
const moderationQueue = useModerationQueue()
const route = useRoute()
@@ -28,6 +28,11 @@ const { data: report } = useQuery({
<template>
<div class="flex flex-col gap-3">
<ModerationReportCard v-if="report" :report="report" :collapsed="false" />
<ModerationReportCard
v-if="report"
:report="report"
:collapsed="false"
:disable-collapsing="true"
/>
</div>
</template>
@@ -1,21 +1,13 @@
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-col justify-between gap-3 lg:flex-row">
<Input
v-model="query"
:icon="SearchIcon"
type="text"
autocomplete="off"
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
clearable
wrapper-class="flex-1"
input-class="h-[40px] w-full"
@input="goToPage(1)"
/>
<div
class="flex flex-col items-stretch justify-end gap-2 sm:flex-row sm:items-center lg:flex-shrink-0"
>
<ModerationQueueToolbar
v-model="query"
:page="currentPage"
:total-pages="totalPages"
@search="goToPage(1)"
@switch-page="goToPage"
>
<template #actions>
<Combobox
v-model="currentMessageFilter"
class="!w-full flex-grow sm:!w-[200px] sm:flex-grow-0"
@@ -25,12 +17,14 @@
trigger-size="lg"
@select="goToPage(1)"
>
<template #selected="{ label: messageLabel }">
<template #selected>
<span class="flex flex-row gap-2 align-middle font-semibold">
<ListFilterIcon class="size-5 flex-shrink-0 text-secondary" />
<span class="truncate text-contrast"
>{{ messageLabel }} ({{ sortedReports.length }})</span
>
<ModerationFilterCount
:label="currentMessageFilterName"
:count="sortedReports.length"
:loading="isLoading"
/>
</span>
</template>
</Combobox>
@@ -78,7 +72,7 @@
<span class="min-w-0 flex-1 truncate px-0.5 font-semibold text-inherit">
{{
currentReporterOrProject.length === 0
? 'All Reports'
? 'All reports'
: `${currentReporterOrProject.length} selected`
}}
</span>
@@ -111,7 +105,7 @@
class="h-5 w-5 shrink-0 text-primary"
:class="currentReporterOrProject.length === 0 ? 'text-contrast' : 'text-primary'"
/>
<span class="min-w-0 flex-1 font-semibold leading-tight">All Reports</span>
<span class="min-w-0 flex-1 font-semibold leading-tight">All reports</span>
<span class="flex shrink-0 items-center justify-center text-brand">
<CheckIcon
v-if="currentReporterOrProject.length === 0"
@@ -169,35 +163,34 @@
</div>
</template>
</TeleportPopoutMenu>
</div>
</div>
</template>
<template #meta>
<div v-if="sortedReports.length > 0">
Showing {{ formatNumber(pageStart) }}{{ formatNumber(pageEnd) }} of
{{ formatNumber(sortedReports.length) }} reports
</div>
</template>
</ModerationQueueToolbar>
<div v-if="totalPages > 1" class="flex items-center justify-between">
<div>
Showing
{{ itemsPerPage * (currentPage - 1) + 1 }}
{{ itemsPerPage * (currentPage - 1) + Math.min(itemsPerPage, paginatedReports.length) }}
of {{ sortedReports.length }} reports
</div>
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<ModerationQueueSkeleton v-if="isLoading" />
<div
v-else-if="paginatedReports.length === 0"
class="universal-card flex h-24 items-center justify-center text-secondary"
>
No reports in queue.
</div>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div class="flex flex-col gap-4">
<div v-if="paginatedReports.length === 0" class="universal-card h-24 animate-pulse"></div>
<div v-else class="flex flex-col gap-4 overflow-x-clip">
<ReportCard
v-for="report in paginatedReports"
:key="report.id"
:report="report"
:collapsed="true"
dismiss-after-close
@dismiss="dismissReport(report.id)"
/>
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<div v-if="totalPages > 1" class="flex justify-end">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
</div>
@@ -211,7 +204,6 @@ import {
ChevronLeftIcon,
LayersIcon,
ListFilterIcon,
SearchIcon,
SortAscIcon,
SortDescIcon,
} from '@modrinth/assets'
@@ -222,163 +214,259 @@ import {
commonMessages,
formatReportType,
injectModrinthClient,
Input,
MultiSelect,
type MultiSelectItem,
Pagination,
TeleportPopoutMenu,
useDebugLogger,
useFormatNumber,
useVIntl,
} from '@modrinth/ui'
import Fuse from 'fuse.js'
import ModerationFilterCount from '~/components/ui/moderation/ModerationFilterCount.vue'
import ModerationQueueSkeleton from '~/components/ui/moderation/ModerationQueueSkeleton.vue'
import ModerationQueueToolbar from '~/components/ui/moderation/ModerationQueueToolbar.vue'
import ReportCard from '~/components/ui/moderation/ModerationReportCard.vue'
import { enrichReportBatch } from '~/helpers/moderation.ts'
useHead({ title: 'Reports queue - Modrinth' })
const { formatMessage } = useVIntl()
const formatNumber = useFormatNumber()
const route = useRoute()
const router = useRouter()
const auth = await useAuth()
const client = injectModrinthClient()
const debug = useDebugLogger('ModerationReports')
const { data: allReports } = await useLazyAsyncData('new-moderation-reports', async () => {
const startTime = performance.now()
let currentOffset = 0
const REPORT_ENDPOINT_COUNT = 350
const allReports: ExtendedReport[] = []
const { data: allReports, pending: reportsPending } = await useLazyAsyncData(
'new-moderation-reports',
async () => {
const startTime = performance.now()
let currentOffset = 0
const REPORT_ENDPOINT_COUNT = 350
const allReports: ExtendedReport[] = []
const enrichmentPromises: Promise<ExtendedReport[]>[] = []
const enrichmentPromises: Promise<ExtendedReport[]>[] = []
let reports: Labrinth.Reports.v3.Report[]
let hasMoreReports = true
while (hasMoreReports) {
reports = (await useBaseFetch(
`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}&all=true`,
{
apiVersion: 3,
},
)) as Labrinth.Reports.v3.Report[]
let reports: Labrinth.Reports.v3.Report[]
let hasMoreReports = true
while (hasMoreReports) {
reports = (await useBaseFetch(
`report?count=${REPORT_ENDPOINT_COUNT}&offset=${currentOffset}&all=true`,
{
apiVersion: 3,
},
)) as Labrinth.Reports.v3.Report[]
hasMoreReports = reports.length > 0
if (!hasMoreReports) {
break
hasMoreReports = reports.length > 0
if (!hasMoreReports) {
break
}
const enrichmentPromise = enrichReportBatch(reports, client)
enrichmentPromises.push(enrichmentPromise)
// this is explicitly not the length of the reports array, because the API may return fewer reports due to a report in the middle not being
// serializable if the offset is set to the reports array you can get the same report from the end multiple times.
currentOffset += REPORT_ENDPOINT_COUNT
if (enrichmentPromises.length >= 3) {
const completed = await Promise.all(enrichmentPromises.splice(0, 2))
allReports.push(...completed.flat())
}
}
const enrichmentPromise = enrichReportBatch(reports, client)
enrichmentPromises.push(enrichmentPromise)
const remainingBatches = await Promise.all(enrichmentPromises)
allReports.push(...remainingBatches.flat())
// this is explicitly not the length of the reports array, because the API may return fewer reports due to a report in the middle not being
// serializable if the offset is set to the reports array you can get the same report from the end multiple times.
currentOffset += REPORT_ENDPOINT_COUNT
const endTime = performance.now()
const duration = endTime - startTime
if (enrichmentPromises.length >= 3) {
const completed = await Promise.all(enrichmentPromises.splice(0, 2))
allReports.push(...completed.flat())
}
}
debug(
`Reports fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
)
const remainingBatches = await Promise.all(enrichmentPromises)
allReports.push(...remainingBatches.flat())
const endTime = performance.now()
const duration = endTime - startTime
debug(
`Reports fetched and processed in ${duration.toFixed(2)}ms (${(duration / 1000).toFixed(2)}s)`,
)
return allReports
})
const query = ref(route.query.q?.toString() || '')
watch(
query,
(newQuery) => {
const currentQuery = { ...route.query }
if (newQuery) {
currentQuery.q = newQuery
} else {
delete currentQuery.q
}
router.replace({
path: route.path,
query: currentQuery,
})
},
{ immediate: false },
)
watch(
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || ''
if (query.value !== newValue) {
query.value = newValue
}
return allReports
},
)
const currentSortTypeSorting = ref('oldest')
const isLoading = computed(() => reportsPending.value || allReports.value == null)
const SORT_VALUES = ['oldest', 'newest'] as const
const sortTypes: ComboboxOption<string>[] = [
{ value: 'oldest', label: 'Oldest' },
{ value: 'newest', label: 'Newest' },
]
const currentMessageFilter = ref('all')
const messageFilterTypes: ComboboxOption<string>[] = [
{ value: 'all', label: 'All' },
{ value: 'unread', label: 'Unread' },
{ value: 'read', label: 'Read' },
{ value: 'involved', label: 'Involved' },
]
const MESSAGE_FILTERS = [
{ value: 'all', name: 'All' },
{ value: 'unread', name: 'Unread' },
{ value: 'read', name: 'Read' },
{ value: 'involved', name: 'Involved' },
] as const
const MESSAGE_FILTER_VALUES = MESSAGE_FILTERS.map((filter) => filter.value)
const currentProjectTypeFilter = ref('all')
const projectTypeFilterTypes: ComboboxOption<string>[] = [
{ value: 'all', label: 'All project types' },
{ value: 'modpack', label: 'Modpacks' },
{ value: 'mod', label: 'Mods' },
{ value: 'resourcepack', label: 'Resource Packs' },
{ value: 'datapack', label: 'Data Packs' },
{ value: 'plugin', label: 'Plugins' },
{ value: 'shader', label: 'Shaders' },
{ value: 'minecraft_java_server', label: 'Servers' },
{ value: 'shared-instance', label: 'Shared instance' },
]
const PROJECT_TYPE_FILTERS = [
{ value: 'all', name: 'All project types' },
{ value: 'modpack', name: 'Modpacks' },
{ value: 'mod', name: 'Mods' },
{ value: 'resourcepack', name: 'Resource Packs' },
{ value: 'datapack', name: 'Data Packs' },
{ value: 'plugin', name: 'Plugins' },
{ value: 'shader', name: 'Shaders' },
{ value: 'minecraft_java_server', name: 'Servers' },
{ value: 'shared-instance', name: 'Shared instance' },
] as const
const PROJECT_TYPE_VALUES = PROJECT_TYPE_FILTERS.map((filter) => filter.value)
const currentReportTargetFilter = ref('all')
const reportTargetFilterTypes: ComboboxOption<string>[] = [
{ value: 'all', label: 'All' },
{ value: 'project', label: 'Projects' },
{ value: 'user', label: 'Users' },
{ value: 'version', label: 'Versions' },
{ value: 'shared-instance', label: 'Shared instances' },
]
const REPORT_TARGET_FILTERS = [
{ value: 'all', name: 'All' },
{ value: 'project', name: 'Projects' },
{ value: 'user', name: 'Users' },
{ value: 'version', name: 'Versions' },
{ value: 'shared-instance', name: 'Shared instances' },
] as const
const REPORT_TARGET_VALUES = REPORT_TARGET_FILTERS.map((filter) => filter.value)
const currentReportIssueFilter = ref('all')
const reportIssueFilterTypes = computed<ComboboxOption<string>[]>(() => {
const base: ComboboxOption<string>[] = [{ value: 'all', label: 'All' }]
if (!allReports.value) return base
function parseAllowed<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
const parsed = queryAsStringOrEmpty((value as string | string[] | null | undefined) ?? '')
return (allowed as readonly string[]).includes(parsed) ? (parsed as T) : fallback
}
const issueTypes = new Set(allReports.value.map((report) => report.report_type))
function parsePage(value: unknown): number {
const page = Number.parseInt(
queryAsStringOrEmpty((value as string | string[] | null | undefined) ?? ''),
10,
)
return Number.isInteger(page) && page > 0 ? page : 1
}
const sortedTypes = Array.from(issueTypes).sort()
return [
...base,
...sortedTypes.map((type) => ({
value: type,
label: formatReportType(formatMessage, type),
})),
]
})
function selectedValuesEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) return false
return left.every((value, index) => value === right[index])
}
function serializeRouteQuery(query: typeof route.query): string {
const keys = Object.keys(query).sort()
return JSON.stringify(
Object.fromEntries(
keys.flatMap((key) => {
const value = query[key]
if (value == null || value === '') return []
return [[key, Array.isArray(value) ? value.map(String) : String(value)]]
}),
),
)
}
const query = ref(queryAsStringOrEmpty(route.query.q ?? ''))
const currentSortTypeSorting = ref(parseAllowed(route.query.sort, SORT_VALUES, 'oldest'))
const currentMessageFilter = ref(parseAllowed(route.query.messages, MESSAGE_FILTER_VALUES, 'all'))
const currentMessageFilterName = computed(
() =>
MESSAGE_FILTERS.find((filter) => filter.value === currentMessageFilter.value)?.name ?? 'All',
)
const currentProjectTypeFilter = ref(
parseAllowed(route.query.projectType, PROJECT_TYPE_VALUES, 'all'),
)
const currentReportTargetFilter = ref(parseAllowed(route.query.target, REPORT_TARGET_VALUES, 'all'))
const currentReportIssueFilter = ref(queryAsStringOrEmpty(route.query.issue ?? '') || 'all')
const currentReporterOrProject = ref(queryAsStringArray(route.query.selected))
const currentPage = ref(parsePage(route.query.page))
function writeFiltersToRoute() {
const nextQuery = { ...route.query }
if (query.value) nextQuery.q = query.value
else delete nextQuery.q
if (currentSortTypeSorting.value !== 'oldest') nextQuery.sort = currentSortTypeSorting.value
else delete nextQuery.sort
if (currentMessageFilter.value !== 'all') nextQuery.messages = currentMessageFilter.value
else delete nextQuery.messages
if (currentReportTargetFilter.value !== 'all') nextQuery.target = currentReportTargetFilter.value
else delete nextQuery.target
if (currentReportIssueFilter.value !== 'all') nextQuery.issue = currentReportIssueFilter.value
else delete nextQuery.issue
if (currentProjectTypeFilter.value !== 'all') {
nextQuery.projectType = currentProjectTypeFilter.value
} else {
delete nextQuery.projectType
}
if (currentReporterOrProject.value.length === 1) {
nextQuery.selected = currentReporterOrProject.value[0]
} else if (currentReporterOrProject.value.length > 1) {
nextQuery.selected = currentReporterOrProject.value
} else {
delete nextQuery.selected
}
if (currentPage.value > 1) nextQuery.page = String(currentPage.value)
else delete nextQuery.page
if (serializeRouteQuery(route.query) === serializeRouteQuery(nextQuery)) return
router.replace({
path: route.path,
query: nextQuery,
})
}
function readFiltersFromRoute() {
const nextQuery = queryAsStringOrEmpty(route.query.q ?? '')
if (query.value !== nextQuery) query.value = nextQuery
const nextSort = parseAllowed(route.query.sort, SORT_VALUES, 'oldest')
if (currentSortTypeSorting.value !== nextSort) currentSortTypeSorting.value = nextSort
const nextMessages = parseAllowed(route.query.messages, MESSAGE_FILTER_VALUES, 'all')
if (currentMessageFilter.value !== nextMessages) currentMessageFilter.value = nextMessages
const nextProjectType = parseAllowed(route.query.projectType, PROJECT_TYPE_VALUES, 'all')
if (currentProjectTypeFilter.value !== nextProjectType) {
currentProjectTypeFilter.value = nextProjectType
}
const nextTarget = parseAllowed(route.query.target, REPORT_TARGET_VALUES, 'all')
if (currentReportTargetFilter.value !== nextTarget) currentReportTargetFilter.value = nextTarget
const nextIssue = queryAsStringOrEmpty(route.query.issue ?? '') || 'all'
if (currentReportIssueFilter.value !== nextIssue) currentReportIssueFilter.value = nextIssue
const nextSelected = queryAsStringArray(route.query.selected)
if (!selectedValuesEqual(currentReporterOrProject.value, nextSelected)) {
currentReporterOrProject.value = nextSelected
}
const nextPage = parsePage(route.query.page)
if (currentPage.value !== nextPage) currentPage.value = nextPage
}
watch(
[
query,
currentSortTypeSorting,
currentMessageFilter,
currentProjectTypeFilter,
currentReportTargetFilter,
currentReportIssueFilter,
currentReporterOrProject,
currentPage,
],
writeFiltersToRoute,
{ deep: true },
)
watch(() => route.query, readFiltersFromRoute, { deep: true })
type ReportedType<T> = T & { report_item_count: number }
const currentReporterOrProject = ref<string[]>([])
const reporterOrProjectOptions = computed<MultiSelectItem<string>[]>(() => {
if (!allReports.value) return []
const options: MultiSelectItem<string>[] = []
@@ -410,7 +498,7 @@ const reporterOrProjectOptions = computed<MultiSelectItem<string>[]>(() => {
.forEach((project) => {
options.push({
value: `project/${project.id}`,
label: `${project.title} (${project.report_item_count})`,
label: `${project.title} (${formatNumber(project.report_item_count)})`,
icon: project.icon_url ? h('img', { src: project.icon_url }) : undefined,
})
})
@@ -426,7 +514,7 @@ const reporterOrProjectOptions = computed<MultiSelectItem<string>[]>(() => {
.forEach((reporter) => {
options.push({
value: `reporter/${reporter.id}`,
label: `${reporter.username} (${reporter.report_item_count})`,
label: `${reporter.username} (${formatNumber(reporter.report_item_count)})`,
icon: reporter.avatar_url ? h('img', { src: reporter.avatar_url }) : undefined,
})
})
@@ -434,7 +522,6 @@ const reporterOrProjectOptions = computed<MultiSelectItem<string>[]>(() => {
return options
})
const currentPage = ref(1)
const itemsPerPage = 15
const totalPages = computed(() => Math.ceil((sortedReports.value?.length || 0) / itemsPerPage))
@@ -501,65 +588,164 @@ const baseFiltered = computed(() => {
})
const filteredReports = computed(() => {
const messageFilter = currentMessageFilter.value
const projectTypeFilter = currentProjectTypeFilter.value
const reportTargetFilter = currentReportTargetFilter.value
const reportIssueFilter = currentReportIssueFilter.value
if (
messageFilter === 'all' &&
projectTypeFilter === 'all' &&
reportTargetFilter === 'all' &&
reportIssueFilter === 'all'
) {
return baseFiltered.value
}
const messageFilterPredicate = (report: ExtendedReport) => {
const messages = report.thread?.messages || []
if (messageFilter === 'all') return true
if (messages.length === 0) return messageFilter === 'Unread'
if (!messages[messages.length - 1].author_id) return false
if (messageFilter === 'involved') {
const userId = (auth.value.user as any)?.id
return userId && messages.some((message) => message.author_id === userId)
}
const roleMap = memberRoleMap.value.get(report.id)
if (!roleMap) return false
const authorRole = roleMap.get(messages[messages.length - 1].author_id)
const isModeratorMessage = authorRole === 'moderator' || authorRole === 'admin'
return messageFilter === 'Read' ? isModeratorMessage : !isModeratorMessage
}
const projectTypeFilterPredicate = (report: ExtendedReport) => {
if (projectTypeFilter === 'all') return true
if (projectTypeFilter === 'shared-instance') return report.item_type === 'shared-instance'
return report.project?.project_type === projectTypeFilter
}
const reportTargetFilterPredicate = (report: ExtendedReport) => {
return reportTargetFilter === 'all' || report.item_type === reportTargetFilter
}
const reportIssueFilterPredicate = (report: ExtendedReport) => {
return reportIssueFilter === 'all' || report.report_type === reportIssueFilter
}
return baseFiltered.value.filter((report) => {
return (
messageFilterPredicate(report) &&
projectTypeFilterPredicate(report) &&
reportTargetFilterPredicate(report) &&
reportIssueFilterPredicate(report)
matchesMessageFilter(report, currentMessageFilter.value) &&
matchesProjectTypeFilter(report, currentProjectTypeFilter.value) &&
matchesReportTargetFilter(report, currentReportTargetFilter.value) &&
matchesReportIssueFilter(report, currentReportIssueFilter.value)
)
})
})
function matchesMessageFilter(
report: ExtendedReport,
messageFilter: (typeof MESSAGE_FILTERS)[number]['value'] | string,
): boolean {
if (messageFilter === 'all') return true
const messages = report.thread?.messages || []
if (messages.length === 0) return messageFilter === 'unread'
if (!messages[messages.length - 1].author_id) return false
if (messageFilter === 'involved') {
const userId = (auth.value.user as any)?.id
return !!userId && messages.some((message) => message.author_id === userId)
}
const roleMap = memberRoleMap.value.get(report.id)
if (!roleMap) return false
const authorRole = roleMap.get(messages[messages.length - 1].author_id)
const isModeratorMessage = authorRole === 'moderator' || authorRole === 'admin'
return messageFilter === 'read' ? isModeratorMessage : !isModeratorMessage
}
function matchesProjectTypeFilter(
report: ExtendedReport,
projectTypeFilter: (typeof PROJECT_TYPE_FILTERS)[number]['value'] | string,
): boolean {
if (projectTypeFilter === 'all') return true
if (projectTypeFilter === 'shared-instance') return report.item_type === 'shared-instance'
return report.project?.project_type === projectTypeFilter
}
function matchesReportTargetFilter(
report: ExtendedReport,
reportTargetFilter: (typeof REPORT_TARGET_FILTERS)[number]['value'] | string,
): boolean {
return reportTargetFilter === 'all' || report.item_type === reportTargetFilter
}
function matchesReportIssueFilter(report: ExtendedReport, reportIssueFilter: string): boolean {
return reportIssueFilter === 'all' || report.report_type === reportIssueFilter
}
function labelWithCount(name: string, count: number): string {
return `${name} (${formatNumber(count)})`
}
const reportsForMessageCounts = computed(() =>
baseFiltered.value.filter(
(report) =>
matchesProjectTypeFilter(report, currentProjectTypeFilter.value) &&
matchesReportTargetFilter(report, currentReportTargetFilter.value) &&
matchesReportIssueFilter(report, currentReportIssueFilter.value),
),
)
const reportsForProjectTypeCounts = computed(() =>
baseFiltered.value.filter(
(report) =>
matchesMessageFilter(report, currentMessageFilter.value) &&
matchesReportTargetFilter(report, currentReportTargetFilter.value) &&
matchesReportIssueFilter(report, currentReportIssueFilter.value),
),
)
const reportsForTargetCounts = computed(() =>
baseFiltered.value.filter(
(report) =>
matchesMessageFilter(report, currentMessageFilter.value) &&
matchesProjectTypeFilter(report, currentProjectTypeFilter.value) &&
matchesReportIssueFilter(report, currentReportIssueFilter.value),
),
)
const reportsForIssueCounts = computed(() =>
baseFiltered.value.filter(
(report) =>
matchesMessageFilter(report, currentMessageFilter.value) &&
matchesProjectTypeFilter(report, currentProjectTypeFilter.value) &&
matchesReportTargetFilter(report, currentReportTargetFilter.value),
),
)
const messageFilterTypes = computed<ComboboxOption<string>[]>(() =>
MESSAGE_FILTERS.map((filter) => ({
value: filter.value,
label: isLoading.value
? filter.name
: labelWithCount(
filter.name,
reportsForMessageCounts.value.filter((report) =>
matchesMessageFilter(report, filter.value),
).length,
),
})),
)
const projectTypeFilterTypes = computed<ComboboxOption<string>[]>(() =>
PROJECT_TYPE_FILTERS.map((filter) => ({
value: filter.value,
label: isLoading.value
? filter.name
: labelWithCount(
filter.name,
reportsForProjectTypeCounts.value.filter((report) =>
matchesProjectTypeFilter(report, filter.value),
).length,
),
})),
)
const reportTargetFilterTypes = computed<ComboboxOption<string>[]>(() =>
REPORT_TARGET_FILTERS.map((filter) => ({
value: filter.value,
label: isLoading.value
? filter.name
: labelWithCount(
filter.name,
reportsForTargetCounts.value.filter((report) =>
matchesReportTargetFilter(report, filter.value),
).length,
),
})),
)
const reportIssueFilterTypes = computed<ComboboxOption<string>[]>(() => {
const issueTypes = new Set((allReports.value ?? []).map((report) => report.report_type))
const options = [
{ value: 'all', name: 'All' },
...Array.from(issueTypes)
.sort()
.map((type) => ({
value: type,
name: formatReportType(formatMessage, type),
})),
]
return options.map((filter) => ({
value: filter.value,
label: isLoading.value
? filter.name
: labelWithCount(
filter.name,
reportsForIssueCounts.value.filter((report) =>
matchesReportIssueFilter(report, filter.value),
).length,
),
}))
})
const sortedReports = computed(() => {
const reporterOrProjectFilter = currentReporterOrProject.value
const filtered =
@@ -591,7 +777,37 @@ const paginatedReports = computed(() => {
return sortedReports.value.slice(start, end)
})
const pageStart = computed(() =>
sortedReports.value.length === 0 ? 0 : itemsPerPage * (currentPage.value - 1) + 1,
)
const pageEnd = computed(
() =>
itemsPerPage * (currentPage.value - 1) + Math.min(itemsPerPage, paginatedReports.value.length),
)
function goToPage(page: number) {
currentPage.value = page
}
watch(totalPages, (pages) => {
if (isLoading.value) return
if (pages === 0) {
if (currentPage.value !== 1) goToPage(1)
return
}
if (currentPage.value > pages) {
goToPage(pages)
}
})
function dismissReport(reportId: string) {
if (!allReports.value) return
allReports.value = allReports.value.filter((report) => report.id !== reportId)
if (currentPage.value > totalPages.value) {
currentPage.value = Math.max(1, totalPages.value)
}
}
</script>
@@ -1,13 +1,15 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ArrowLeftIcon, LoaderCircleIcon } from '@modrinth/assets'
import { ButtonLink, injectModrinthClient } from '@modrinth/ui'
import { LoaderCircleIcon } from '@modrinth/assets'
import { BackToParentLink, injectModrinthClient } from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import MaliciousSummaryModal, {
type UnsafeFile,
} from '~/components/ui/moderation/MaliciousSummaryModal.vue'
import ModerationTechRevCard from '~/components/ui/moderation/ModerationTechRevCard.vue'
import { flattenFileReports } from '~/components/ui/moderation/tech-review/helpers'
import { useTechReviewSources } from '~/components/ui/moderation/tech-review/use-tech-review-sources'
const client = injectModrinthClient()
const queryClient = useQueryClient()
@@ -18,133 +20,6 @@ const projectId = String(useRouteId('project'))
useHead({ title: () => `Tech review - ${projectId} - Modrinth` })
const CACHE_TTL = 24 * 60 * 60 * 1000
const CACHE_KEY_PREFIX = 'tech_review_source_'
type CachedSource = {
source: string
timestamp: number
}
function getCachedSource(detailId: string): string | null {
try {
const cached = localStorage.getItem(`${CACHE_KEY_PREFIX}${detailId}`)
if (!cached) return null
const data: CachedSource = JSON.parse(cached)
const now = Date.now()
if (now - data.timestamp > CACHE_TTL) {
localStorage.removeItem(`${CACHE_KEY_PREFIX}${detailId}`)
return null
}
return data.source
} catch {
return null
}
}
function setCachedSource(detailId: string, source: string): void {
try {
const data: CachedSource = {
source,
timestamp: Date.now(),
}
localStorage.setItem(`${CACHE_KEY_PREFIX}${detailId}`, JSON.stringify(data))
} catch (error) {
console.error('Failed to cache source:', error)
}
}
function clearExpiredCache(): void {
try {
const now = Date.now()
const keys = Object.keys(localStorage)
for (const key of keys) {
if (key.startsWith(CACHE_KEY_PREFIX)) {
const cached = localStorage.getItem(key)
if (cached) {
const data: CachedSource = JSON.parse(cached)
if (now - data.timestamp > CACHE_TTL) {
localStorage.removeItem(key)
}
}
}
}
} catch (error) {
console.error('Failed to clear expired cache:', error)
}
}
if (import.meta.client) {
clearExpiredCache()
}
const loadingIssues = reactive<Set<string>>(new Set())
const decompiledSources = reactive<Map<string, string>>(new Map())
const loadedIssues = reactive<Set<string>>(new Set())
async function loadIssueSource(issueId: string): Promise<void> {
if (loadingIssues.has(issueId) || loadedIssues.has(issueId)) return
loadingIssues.add(issueId)
try {
const issueData = await client.labrinth.tech_review_internal.getIssue(issueId)
for (const detail of issueData.details) {
if (detail.decompiled_source) {
decompiledSources.set(detail.id, detail.decompiled_source)
setCachedSource(detail.id, detail.decompiled_source)
}
}
loadedIssues.add(issueId)
} catch (error) {
console.error('Failed to load issue source:', error)
} finally {
loadingIssues.delete(issueId)
}
}
function findIssuesByIds(issueIds: Set<string>): Labrinth.TechReview.Internal.FileIssue[] {
const issues: Labrinth.TechReview.Internal.FileIssue[] = []
if (!reviewItem.value) return []
for (const report of reviewItem.value.reports) {
for (const issue of report.issues) {
if (issueIds.has(issue.id)) {
issues.push(issue)
}
}
}
return issues
}
function handleLoadIssueSources(issueIds: string[]): void {
const uniqueIssueIds = new Set(issueIds)
const issues = findIssuesByIds(uniqueIssueIds)
for (const issue of issues) {
for (const detail of issue.details) {
if (!decompiledSources.has(detail.id)) {
const cached = getCachedSource(detail.id)
if (cached) {
decompiledSources.set(detail.id, cached)
}
}
}
const hasUncached = issue.details.some((detail) => !decompiledSources.has(detail.id))
if (hasUncached) {
loadIssueSource(issue.id)
}
}
}
const {
data: projectReportData,
isLoading: isLoadingReport,
@@ -194,11 +69,6 @@ const isLoading = computed(
const hasError = computed(() => isReportError.value || isProjectError.value)
type FlattenedFileReport = Labrinth.TechReview.Internal.FileReport & {
id: string
version_id: string
}
const ownership = computed<Labrinth.TechReview.Internal.Ownership | null>(() => {
if (organizationData.value) {
return {
@@ -229,15 +99,7 @@ const reviewItem = computed(() => {
const { project_report, thread } = projectReportData.value
const reports: FlattenedFileReport[] = project_report
? project_report.versions.flatMap((version) =>
version.files.map((file) => ({
...file,
id: file.report_id,
version_id: version.version_id,
})),
)
: []
const reports = project_report ? flattenFileReports(project_report.versions) : []
return {
project: projectData.value,
@@ -247,6 +109,10 @@ const reviewItem = computed(() => {
}
})
const { loadingIssues, decompiledSources, handleLoadIssueSources } = useTechReviewSources(
() => reviewItem.value?.reports.flatMap((report) => report.issues) ?? [],
)
const focusedDetailId = computed(() => route.query.detail?.toString() ?? null)
async function handleMarkComplete(projectId: string) {
@@ -301,13 +167,8 @@ onUnmounted(() => {
</script>
<template>
<div class="flex flex-col gap-4">
<div>
<ButtonLink :to="'/moderation/technical-review'">
<ArrowLeftIcon class="size-5" />
Back to queue
</ButtonLink>
</div>
<div class="flex flex-col">
<BackToParentLink :to="'/moderation/technical-review'"> Back to queue </BackToParentLink>
<div v-if="isLoading" class="flex flex-col gap-4">
<div class="universal-card flex h-48 items-center justify-center">
@@ -333,12 +194,12 @@ onUnmounted(() => {
:loading-issues="loadingIssues"
:decompiled-sources="decompiledSources"
:collapsed="false"
disable-collapsing
@refetch="refetch"
@load-issue-sources="handleLoadIssueSources"
@mark-complete="handleMarkComplete"
@show-malicious-summary="handleShowMaliciousSummary"
/>
<MaliciousSummaryModal ref="maliciousSummaryModalRef" :unsafe-files="currentUnsafeFiles" />
</div>
</template>
@@ -1,22 +1,15 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
BlendIcon,
ListFilterIcon,
LoaderCircleIcon,
SearchIcon,
SortAscIcon,
SortDescIcon,
} from '@modrinth/assets'
import { BlendIcon, ListFilterIcon, SortAscIcon, SortDescIcon, SpinnerIcon } from '@modrinth/assets'
import {
Combobox,
type ComboboxOption,
commonMessages,
injectModrinthClient,
Input,
Pagination,
TeleportPopoutMenu,
Toggle,
useFormatNumber,
useVIntl,
} from '@modrinth/ui'
import { useInfiniteQuery, useQueryClient } from '@tanstack/vue-query'
@@ -26,7 +19,11 @@ import { nextTick, reactive } from 'vue'
import MaliciousSummaryModal, {
type UnsafeFile,
} from '~/components/ui/moderation/MaliciousSummaryModal.vue'
import ModerationQueueSkeleton from '~/components/ui/moderation/ModerationQueueSkeleton.vue'
import ModerationQueueToolbar from '~/components/ui/moderation/ModerationQueueToolbar.vue'
import ModerationTechRevCard from '~/components/ui/moderation/ModerationTechRevCard.vue'
import { flattenFileReports } from '~/components/ui/moderation/tech-review/helpers'
import { useTechReviewSources } from '~/components/ui/moderation/tech-review/use-tech-review-sources'
useHead({ title: 'Tech review queue - Modrinth' })
@@ -34,187 +31,14 @@ const client = injectModrinthClient()
const queryClient = useQueryClient()
const keybinds = useModerationKeybinds()
const currentPage = ref(1)
const API_PAGE_SIZE = 50
const UI_PAGE_SIZE = 4
const { formatMessage } = useVIntl()
const formatNumber = useFormatNumber()
const route = useRoute()
const router = useRouter()
const CACHE_TTL = 24 * 60 * 60 * 1000
const CACHE_KEY_PREFIX = 'tech_review_source_'
type CachedSource = {
source: string
timestamp: number
}
function getCachedSource(detailId: string): string | null {
try {
const cached = localStorage.getItem(`${CACHE_KEY_PREFIX}${detailId}`)
if (!cached) return null
const data: CachedSource = JSON.parse(cached)
const now = Date.now()
if (now - data.timestamp > CACHE_TTL) {
localStorage.removeItem(`${CACHE_KEY_PREFIX}${detailId}`)
return null
}
return data.source
} catch {
return null
}
}
function setCachedSource(detailId: string, source: string): void {
try {
const data: CachedSource = {
source,
timestamp: Date.now(),
}
localStorage.setItem(`${CACHE_KEY_PREFIX}${detailId}`, JSON.stringify(data))
} catch (error) {
console.error('Failed to cache source:', error)
}
}
function clearExpiredCache(): void {
try {
const now = Date.now()
const keys = Object.keys(localStorage)
for (const key of keys) {
if (key.startsWith(CACHE_KEY_PREFIX)) {
const cached = localStorage.getItem(key)
if (cached) {
const data: CachedSource = JSON.parse(cached)
if (now - data.timestamp > CACHE_TTL) {
localStorage.removeItem(key)
}
}
}
}
} catch (error) {
console.error('Failed to clear expired cache:', error)
}
}
clearExpiredCache()
const loadingIssues = reactive<Set<string>>(new Set())
const decompiledSources = reactive<Map<string, string>>(new Map())
const loadedIssues = reactive<Set<string>>(new Set())
async function loadIssueSource(issueId: string): Promise<void> {
if (loadingIssues.has(issueId) || loadedIssues.has(issueId)) return
loadingIssues.add(issueId)
try {
const issueData = await client.labrinth.tech_review_internal.getIssue(issueId)
for (const detail of issueData.details) {
if (detail.decompiled_source) {
decompiledSources.set(detail.id, detail.decompiled_source)
setCachedSource(detail.id, detail.decompiled_source)
}
}
loadedIssues.add(issueId)
} catch (error) {
console.error('Failed to load issue source:', error)
} finally {
loadingIssues.delete(issueId)
}
}
function findIssuesByIds(issueIds: Set<string>): Labrinth.TechReview.Internal.FileIssue[] {
const issues: Labrinth.TechReview.Internal.FileIssue[] = []
for (const review of reviewItems.value) {
for (const report of review.reports) {
for (const issue of report.issues) {
if (issueIds.has(issue.id)) {
issues.push(issue)
}
}
}
}
return issues
}
function handleLoadIssueSources(issueIds: string[]): void {
const uniqueIssueIds = new Set(issueIds)
const issues = findIssuesByIds(uniqueIssueIds)
for (const issue of issues) {
for (const detail of issue.details) {
if (!decompiledSources.has(detail.id)) {
const cached = getCachedSource(detail.id)
if (cached) {
decompiledSources.set(detail.id, cached)
}
}
}
const hasUncached = issue.details.some((detail) => !decompiledSources.has(detail.id))
if (hasUncached) {
loadIssueSource(issue.id)
}
}
}
const query = ref(route.query.q?.toString() || '')
watch(
query,
(newQuery) => {
const currentQuery = { ...route.query }
if (newQuery) {
currentQuery.q = newQuery
} else {
delete currentQuery.q
}
router.replace({
path: route.path,
query: currentQuery,
})
goToPage(1)
},
{ immediate: false },
)
watch(
() => route.query.q,
(newQueryParam) => {
const newValue = newQueryParam?.toString() || ''
if (query.value !== newValue) {
query.value = newValue
}
},
)
const currentFilterType = ref('All flags')
const filterTypes = computed<ComboboxOption<string>[]>(() => {
const base: ComboboxOption<string>[] = [{ value: 'All flags', label: 'All flags' }]
if (!reviewItems.value) return base
const issueTypes = new Set(
reviewItems.value
.flatMap((review) => review.reports)
.flatMap((report) => report.issues)
.map((issue) => issue.issue_type),
)
const sortedTypes = Array.from(issueTypes).sort()
return [...base, ...sortedTypes.map((type) => ({ value: type, label: type }))]
})
const currentSortType = ref('Severity highest')
const SORT_VALUES = ['Severity highest', 'Severity lowest', 'Oldest', 'Newest'] as const
const sortTypes: ComboboxOption<string>[] = [
{ value: 'Severity highest', label: 'Severity highest' },
{ value: 'Severity lowest', label: 'Severity lowest' },
@@ -222,26 +46,200 @@ const sortTypes: ComboboxOption<string>[] = [
{ value: 'Newest', label: 'Newest' },
]
const currentResponseFilter = ref('All')
const RESPONSE_FILTER_VALUES = ['All', 'Unread', 'Read'] as const
const responseFilterTypes: ComboboxOption<string>[] = [
{ value: 'All', label: 'All' },
{ value: 'Unread', label: 'Unread' },
{ value: 'Read', label: 'Read' },
]
const currentProjectTypeFilter = ref('All project types')
const projectTypeFilterTypes: ComboboxOption<string>[] = [
{ value: 'All project types', label: 'All project types' },
{ value: 'Modpacks', label: 'Modpacks' },
{ value: 'Mods', label: 'Mods' },
{ value: 'Resource Packs', label: 'Resource Packs' },
{ value: 'Data Packs', label: 'Data Packs' },
{ value: 'Plugins', label: 'Plugins' },
{ value: 'Shaders', label: 'Shaders' },
{ value: 'Servers', label: 'Servers' },
]
const PROJECT_TYPE_FILTERS = [
{ value: 'All project types', name: 'All project types' },
{ value: 'Modpacks', name: 'Modpacks' },
{ value: 'Mods', name: 'Mods' },
{ value: 'Resource Packs', name: 'Resource Packs' },
{ value: 'Data Packs', name: 'Data Packs' },
{ value: 'Plugins', name: 'Plugins' },
{ value: 'Shaders', name: 'Shaders' },
{ value: 'Servers', name: 'Servers' },
] as const
const PROJECT_TYPE_VALUES = PROJECT_TYPE_FILTERS.map((filter) => filter.value)
const inOtherQueueFilter = ref(true)
function parseAllowed<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
const parsed = queryAsStringOrEmpty((value as string | string[] | null | undefined) ?? '')
return (allowed as readonly string[]).includes(parsed) ? (parsed as T) : fallback
}
function parsePage(value: unknown): number {
const page = Number.parseInt(
queryAsStringOrEmpty((value as string | string[] | null | undefined) ?? ''),
10,
)
return Number.isInteger(page) && page > 0 ? page : 1
}
function parseBoolean(value: unknown, fallback: boolean): boolean {
const parsed = queryAsStringOrEmpty(
(value as string | string[] | null | undefined) ?? '',
).toLowerCase()
if (parsed === 'true' || parsed === '1') return true
if (parsed === 'false' || parsed === '0') return false
return fallback
}
function serializeRouteQuery(query: typeof route.query): string {
const keys = Object.keys(query).sort()
return JSON.stringify(
Object.fromEntries(
keys.flatMap((key) => {
const value = query[key]
if (value == null || value === '') return []
return [[key, Array.isArray(value) ? value.map(String) : String(value)]]
}),
),
)
}
const query = ref(queryAsStringOrEmpty(route.query.q ?? ''))
const currentFilterType = ref(queryAsStringOrEmpty(route.query.flags ?? '') || 'All flags')
const currentSortType = ref(parseAllowed(route.query.sort, SORT_VALUES, 'Severity highest'))
const currentResponseFilter = ref(parseAllowed(route.query.response, RESPONSE_FILTER_VALUES, 'All'))
const currentProjectTypeFilter = ref(
parseAllowed(route.query.projectType, PROJECT_TYPE_VALUES, 'All project types'),
)
const inOtherQueueFilter = ref(parseBoolean(route.query.underReview, true))
const currentPage = ref(parsePage(route.query.page))
let syncingFromRoute = false
function writeFiltersToRoute() {
if (syncingFromRoute) return
const nextQuery = { ...route.query }
if (query.value) nextQuery.q = query.value
else delete nextQuery.q
if (currentSortType.value !== 'Severity highest') nextQuery.sort = currentSortType.value
else delete nextQuery.sort
if (currentResponseFilter.value !== 'All') nextQuery.response = currentResponseFilter.value
else delete nextQuery.response
if (currentFilterType.value !== 'All flags') nextQuery.flags = currentFilterType.value
else delete nextQuery.flags
if (currentProjectTypeFilter.value !== 'All project types') {
nextQuery.projectType = currentProjectTypeFilter.value
} else {
delete nextQuery.projectType
}
if (!inOtherQueueFilter.value) nextQuery.underReview = 'false'
else delete nextQuery.underReview
if (currentPage.value > 1) nextQuery.page = String(currentPage.value)
else delete nextQuery.page
if (serializeRouteQuery(route.query) === serializeRouteQuery(nextQuery)) return
router.replace({
path: route.path,
query: nextQuery,
})
}
function readFiltersFromRoute() {
syncingFromRoute = true
const nextQuery = queryAsStringOrEmpty(route.query.q ?? '')
if (query.value !== nextQuery) query.value = nextQuery
const nextFlags = queryAsStringOrEmpty(route.query.flags ?? '') || 'All flags'
if (currentFilterType.value !== nextFlags) currentFilterType.value = nextFlags
const nextSort = parseAllowed(route.query.sort, SORT_VALUES, 'Severity highest')
if (currentSortType.value !== nextSort) currentSortType.value = nextSort
const nextResponse = parseAllowed(route.query.response, RESPONSE_FILTER_VALUES, 'All')
if (currentResponseFilter.value !== nextResponse) currentResponseFilter.value = nextResponse
const nextProjectType = parseAllowed(
route.query.projectType,
PROJECT_TYPE_VALUES,
'All project types',
)
if (currentProjectTypeFilter.value !== nextProjectType) {
currentProjectTypeFilter.value = nextProjectType
}
const nextUnderReview = parseBoolean(route.query.underReview, true)
if (inOtherQueueFilter.value !== nextUnderReview) inOtherQueueFilter.value = nextUnderReview
const nextPage = parsePage(route.query.page)
if (currentPage.value !== nextPage) currentPage.value = nextPage
nextTick(() => {
syncingFromRoute = false
})
}
watch(
[
query,
currentFilterType,
currentSortType,
currentResponseFilter,
currentProjectTypeFilter,
inOtherQueueFilter,
currentPage,
],
writeFiltersToRoute,
)
watch(() => route.query, readFiltersFromRoute, { deep: true })
const filterTypes = computed<ComboboxOption<string>[]>(() => {
const issues =
reviewItems.value?.flatMap((review) => review.reports.flatMap((report) => report.issues)) ?? []
const counts = new Map<string, number>()
for (const issue of issues) {
counts.set(issue.issue_type, (counts.get(issue.issue_type) ?? 0) + 1)
}
const options: ComboboxOption<string>[] = [
{
value: 'All flags',
label: isLoading.value ? 'All flags' : `All flags (${formatNumber(issues.length)})`,
},
]
for (const type of Array.from(counts.keys()).sort()) {
options.push({
value: type,
label: isLoading.value ? type : `${type} (${formatNumber(counts.get(type) ?? 0)})`,
})
}
return options
})
const projectTypeFilterTypes = computed<ComboboxOption<string>[]>(() => {
const items = reviewItems.value ?? []
const showCounts = !isLoading.value && currentProjectTypeFilter.value === 'All project types'
return PROJECT_TYPE_FILTERS.map((filter) => {
if (!showCounts) {
return { value: filter.value, label: filter.name }
}
const apiType = toApiProjectType(filter.value)
const count =
filter.value === 'All project types'
? items.length
: items.filter((item) => apiType && item.project.project_types.includes(apiType)).length
return { value: filter.value, label: `${filter.name} (${formatNumber(count)})` }
})
})
const techReviewQueryKey = computed(
() =>
@@ -275,13 +273,11 @@ const searchResults = computed(() => {
return fuse.value.search(query.value).map((result) => result.item)
})
const baseFiltered = computed(() => {
const filteredItems = computed(() => {
if (!reviewItems.value) return []
return query.value && searchResults.value ? searchResults.value : [...reviewItems.value]
})
const filteredItems = computed(() => baseFiltered.value)
const filteredIssuesCount = computed(() => {
return filteredItems.value.reduce((total, review) => {
return total + review.reports.reduce((sum, report) => sum + report.issues.length, 0)
@@ -295,6 +291,15 @@ const paginatedItems = computed(() => {
const end = start + UI_PAGE_SIZE
return filteredItems.value.slice(start, end)
})
const pageStart = computed(() =>
filteredItems.value.length === 0 ? 0 : (currentPage.value - 1) * UI_PAGE_SIZE + 1,
)
const pageEnd = computed(() =>
Math.min(
(currentPage.value - 1) * UI_PAGE_SIZE + paginatedItems.value.length,
filteredItems.value.length,
),
)
function goToPage(page: number, top = false) {
currentPage.value = page
@@ -388,7 +393,7 @@ const {
})
},
getNextPageParam: (lastPage, allPages) => {
// If we got a full page, there's probably more
// full page = maybe more
return lastPage.project_reports.length >= API_PAGE_SIZE ? allPages.length : undefined
},
initialPageParam: 0,
@@ -423,11 +428,6 @@ const mergedSearchResponse = computed(() => {
)
})
type FlattenedFileReport = Labrinth.TechReview.Internal.FileReport & {
id: string
version_id: string
}
const reviewItems = computed(() => {
if (!mergedSearchResponse.value?.project_reports?.length) {
return []
@@ -435,47 +435,29 @@ const reviewItems = computed(() => {
const response = mergedSearchResponse.value
return response.project_reports
.map((projectReport) => {
const project = response.projects[projectReport.project_id]
const thread = project?.thread_id ? response.threads[project.thread_id] : undefined
return response.project_reports.flatMap((projectReport) => {
const project = response.projects[projectReport.project_id]
const thread = project?.thread_id ? response.threads[project.thread_id] : undefined
if (!thread) return []
if (!thread) return null
const reports: FlattenedFileReport[] = projectReport.versions.flatMap((version) =>
version.files.map((file) => ({
...file,
id: file.report_id,
version_id: version.version_id,
})),
)
return {
return [
{
project,
project_owner: response.ownership[projectReport.project_id],
thread,
reports,
}
})
.filter(
(
item,
): item is {
project: Labrinth.TechReview.Internal.ProjectModerationInfo
project_owner: Labrinth.TechReview.Internal.Ownership
thread: Labrinth.TechReview.Internal.Thread
reports: FlattenedFileReport[]
} => item !== null,
)
reports: flattenFileReports(projectReport.versions),
},
]
})
})
function handleMarkComplete(projectId: string) {
// Find the index of the current card before removing it
const currentIndex = paginatedItems.value.findIndex((item) => item.project.id === projectId)
const { loadingIssues, decompiledSources, handleLoadIssueSources } = useTechReviewSources(() =>
reviewItems.value.flatMap((review) => review.reports.flatMap((report) => report.issues)),
)
// Find the thread ID for this project so we can remove it from the threads cache
const projectData = reviewItems.value.find((item) => item.project.id === projectId)
const threadId = projectData?.thread?.id
function handleMarkComplete(projectId: string) {
const currentIndex = paginatedItems.value.findIndex((item) => item.project.id === projectId)
const threadId = reviewItems.value.find((item) => item.project.id === projectId)?.thread?.id
queryClient.setQueryData(
techReviewQueryKey.value,
@@ -493,7 +475,7 @@ function handleMarkComplete(projectId: string) {
...oldData,
pages: oldData.pages.map((page) => ({
...page,
// Keep the raw page length stable; getNextPageParam uses it to know if more API pages exist.
// leave this as-is so getNextPageParam still sees a full page
project_reports: page.project_reports,
projects: Object.fromEntries(
Object.entries(page.projects).filter(([id]) => id !== projectId),
@@ -509,25 +491,18 @@ function handleMarkComplete(projectId: string) {
},
)
// Also invalidate the query to ensure consistency with server state
// This triggers a background refetch after the optimistic update
queryClient.invalidateQueries({
queryKey: ['tech-reviews'],
refetchType: 'none', // Don't refetch immediately, just mark as stale
refetchType: 'none',
})
// Scroll to the next card after Vue updates the DOM
nextTick(() => {
// Get the project ID at the same position (next project after removal)
const nextItem = paginatedItems.value[currentIndex]
if (nextItem) {
const nextCard = cardRefs.get(nextItem.project.id)
if (nextCard) {
nextCard.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
}
cardRefs.get(nextItem.project.id)?.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
}
})
}
@@ -550,13 +525,16 @@ watch(
currentProjectTypeFilter,
],
() => {
if (syncingFromRoute) return
goToPage(1)
},
)
watch(totalPages, (pages) => {
if (isLoading.value) return
if (pages === 0) {
goToPage(1)
if (currentPage.value !== 1) goToPage(1)
return
}
@@ -626,32 +604,14 @@ onUnmounted(() => {
:progress="batchScanProgressInformation"
/> -->
<div class="flex flex-col justify-between gap-2 lg:flex-row">
<Input
v-model="query"
:icon="SearchIcon"
type="text"
autocomplete="off"
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
clearable
wrapper-class="flex-1 lg:max-w-52"
input-class="!h-10"
@input="goToPage(1)"
/>
<div v-if="totalPages > 1" class="hidden flex-1 justify-center lg:flex">
<LoaderCircleIcon
v-if="isFetchingNextPage"
v-tooltip="`Pages are still being fetched...`"
aria-hidden="true"
class="my-auto mr-2 size-6 animate-spin text-green"
/>
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
</div>
<div
class="flex flex-col items-stretch justify-end gap-2 sm:flex-row sm:items-center lg:flex-shrink-0"
>
<ModerationQueueToolbar
v-model="query"
:page="currentPage"
:total-pages="totalPages"
@search="goToPage(1)"
@switch-page="goToPage"
>
<template #actions>
<Combobox
v-model="currentResponseFilter"
class="!w-full flex-grow sm:!w-[120px] sm:flex-grow-0"
@@ -695,16 +655,19 @@ onUnmounted(() => {
<template #panel>
<div class="flex min-w-64 flex-col gap-3">
<label class="flex cursor-pointer items-center justify-between gap-2 text-sm">
<span class="whitespace-nowrap font-semibold">In project queue</span>
<span class="whitespace-nowrap font-semibold">Only under review</span>
<Toggle v-model="inOtherQueueFilter" />
</label>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-secondary"
>Flag type ({{ filteredIssuesCount }})</span
>
<span class="flex items-center gap-1.5 text-sm font-semibold text-secondary">
Flag type
<SpinnerIcon v-if="isLoading" class="size-3.5 animate-spin" aria-hidden="true" />
<template v-else>({{ formatNumber(filteredIssuesCount) }})</template>
</span>
<Combobox
v-model="currentFilterType"
class="!w-full"
dropdown-class="!z-[10000]"
:options="filterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
searchable
@@ -722,6 +685,7 @@ onUnmounted(() => {
<Combobox
v-model="currentProjectTypeFilter"
class="!w-full"
dropdown-class="!z-[10000]"
:options="projectTypeFilterTypes"
:placeholder="formatMessage(commonMessages.filterByLabel)"
searchable
@@ -737,23 +701,29 @@ onUnmounted(() => {
</div>
</template>
</TeleportPopoutMenu>
</div>
</div>
</template>
<template #meta>
<div v-if="filteredItems.length > 0" class="flex items-center gap-2">
<SpinnerIcon
v-if="isFetchingNextPage"
v-tooltip="`Pages are still being fetched...`"
aria-hidden="true"
class="size-4 animate-spin"
/>
Showing {{ formatNumber(pageStart) }}{{ formatNumber(pageEnd) }} of
{{ formatNumber(filteredItems.length) }} projects
</div>
</template>
</ModerationQueueToolbar>
<div v-if="totalPages > 1" class="flex justify-center lg:hidden">
<Pagination :page="currentPage" :count="totalPages" @switch-page="goToPage" />
<ModerationQueueSkeleton v-if="isLoading" />
<div
v-else-if="paginatedItems.length === 0"
class="universal-card flex h-24 items-center justify-center text-secondary"
>
No projects in queue.
</div>
<div class="flex flex-col gap-4">
<div v-if="isLoading" class="flex flex-col gap-4">
<div v-for="i in UI_PAGE_SIZE" :key="i" class="universal-card h-48 animate-pulse"></div>
</div>
<div
v-else-if="paginatedItems.length === 0"
class="universal-card flex h-24 items-center justify-center text-secondary"
>
No projects in queue.
</div>
<div v-else class="flex flex-col gap-4">
<div
v-for="item in paginatedItems"
:key="item.project.id"
@@ -780,7 +750,7 @@ onUnmounted(() => {
</div>
</div>
<div v-if="totalPages > 1" class="mt-4 flex justify-center">
<div v-if="totalPages > 1" class="flex justify-end">
<Pagination
:page="currentPage"
:count="totalPages"
@@ -396,6 +396,8 @@ pub struct ProjectReport {
pub struct VersionReport {
/// ID of the project version this report is for.
pub version_id: VersionId,
/// Version number of the project version this report is for.
pub version_number: String,
/// Reports for this version's files.
#[serde(default)]
pub files: Vec<FileReport>,
@@ -663,6 +665,7 @@ async fn fetch_project_reports(
version_reports.push(VersionReport {
version_id: VersionId::from(version_query.inner.id),
version_number: version_query.inner.version_number.clone(),
files: file_reports,
});
}