mirror of
https://github.com/modrinth/code.git
synced 2026-08-31 12:05:53 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user