Files
AstralRinth/apps/frontend/src/components/ui/thread/ConversationThread.vue
T
aecsocket 39f2b0ecb6 Technical review queue (#4775)
* chore: fix typo in status message

* feat(labrinth): overhaul malware scanner report storage and routes

* chore: address some review comments

* feat: add Delphi to Docker Compose `with-delphi` profile

* chore: fix unused import Clippy lint

* feat(labrinth/delphi): use PAT token authorization with project read scopes

* chore: expose file IDs in version queries

* fix: accept null decompiled source payloads from Delphi

* tweak(labrinth): expose base62 file IDs more consistently for Delphi

* feat(labrinth/delphi): support new Delphi report severity field

* chore(labrinth): run `cargo sqlx prepare` to fix Docker build errors

* tweak: add route for fetching Delphi issue type schema, abstract Labrinth away from issue types

* chore: run `cargo sqlx prepare`

* chore: fix typo on frontend generated state file message

* feat: update to use new Delphi issue schema

* wip: tech review endpoints

* wip: add ToSchema for dependent types

* wip: report issues return

* wip

* wip: returning more data

* wip

* Fix up db query

* Delphi configuration to talk to Labrinth

* Get Delphi working with Labrinth

* Add Delphi dummy fixture

* Better Delphi logging

* Improve utoipa for tech review routes

* Add more sorting options for tech review queue

* Oops join

* New routes for fetching issues and reports

* Fix which kind of ID is returned in tech review endpoints

* Deduplicate tech review report rows

* Reduce info sent for projects

* Fetch more thread info

* Address PR comments

* fix ci

* fix postgres version mismatch

* fix version creation

* Implement routes

* fix up tech review

* Allow adding a moderation comment to Delphi rejections

* fix up rebase

* exclude rejected projects from tech review

* add status change msg to tech review thread

* cargo sqlx prepare

* also ignore withheld projects

* More filtering on issue search

* wip: report routes

* Fix up for build

* cargo sqlx prepare

* fix thread message privacy

* New tech review search route

* submit route

* details have statuses now

* add default to drid status

* dedup issue details

* fix sqlx query on empty files

* fixes

* Dedupe issue detail statuses and message on entering tech rev

* Fix qa issues

* Fix qa issues

* fix review comments

* typos

* fix ci

* feat: tech review frontend (#4781)

* chore: fix typo in status message

* feat(labrinth): overhaul malware scanner report storage and routes

* chore: address some review comments

* feat: add Delphi to Docker Compose `with-delphi` profile

* chore: fix unused import Clippy lint

* feat(labrinth/delphi): use PAT token authorization with project read scopes

* chore: expose file IDs in version queries

* fix: accept null decompiled source payloads from Delphi

* tweak(labrinth): expose base62 file IDs more consistently for Delphi

* feat(labrinth/delphi): support new Delphi report severity field

* chore(labrinth): run `cargo sqlx prepare` to fix Docker build errors

* tweak: add route for fetching Delphi issue type schema, abstract Labrinth away from issue types

* chore: run `cargo sqlx prepare`

* chore: fix typo on frontend generated state file message

* feat: update to use new Delphi issue schema

* wip: tech review endpoints

* wip: add ToSchema for dependent types

* wip: report issues return

* wip

* wip: returning more data

* wip

* Fix up db query

* Delphi configuration to talk to Labrinth

* Get Delphi working with Labrinth

* Add Delphi dummy fixture

* Better Delphi logging

* Improve utoipa for tech review routes

* Add more sorting options for tech review queue

* Oops join

* New routes for fetching issues and reports

* Fix which kind of ID is returned in tech review endpoints

* Deduplicate tech review report rows

* Reduce info sent for projects

* Fetch more thread info

* Address PR comments

* fix ci

* fix ci

* fix postgres version mismatch

* fix version creation

* Implement routes

* feat: batch scan alert

* feat: layout

* feat: introduce surface variables

* fix: theme selector

* feat: rough draft of tech review card

* feat: tab switcher

* feat: batch scan btn

* feat: api-client module for tech review

* draft: impl

* feat: auto icons

* fix: layout issues

* feat: fixes to code blocks + flag labels

* feat: temp remove mock data

* fix: search sort types

* fix: intl & lint

* chore: re-enable mock data

* fix: flag badges + auto open first issue in file tab

* feat: update for new routes

* fix: more qa issues

* feat: lazy load sources

* fix: re-enable auth middleware

* feat: impl threads

* fix: lint & severity

* feat: download btn + switch to using NavTabs with new local mode option

* feat: re-add toplevel btns

* feat: reports page consistency

* fix: consistency on project queue

* fix: icons + sizing

* fix: colors and gaps

* fix: impl endpoints

* feat: load all flags on file tab

* feat: thread generics changes

* feat: more qa

* feat: fix collapse

* fix: qa

* feat: msg modal

* fix: ISO import

* feat: qa fixes

* fix: empty state basic

* fix: collapsible region

* fix: collapse thread by default

* feat: rough draft of new process/flow

* fix labrinth build

* fix thread message privacy

* New tech review search route

* feat: qa fixes

* feat: QA changes

* fix: verdict on detail not whole issue

* fix: lint + intl

* fix: lint

* fix: thread message for tech rev verdict

* feat: use anim frames

* fix: exports + typecheck

* polish: qa changes

* feat: qa

* feat: qa polish

* feat: fix malic modal

* fix: lint

* fix: qa + lint

* fix: pagination

* fix: lint

* fix: qa

* intl extract

* fix ci

---------

Signed-off-by: Calum H. <contact@cal.engineer>
Co-authored-by: Alejandro González <me@alegon.dev>
Co-authored-by: aecsocket <aecsocket@tutanota.com>

---------

Signed-off-by: Calum H. <contact@cal.engineer>
Co-authored-by: Alejandro González <me@alegon.dev>
Co-authored-by: Calum H. <contact@cal.engineer>
2025-12-20 11:43:04 +00:00

554 lines
13 KiB
Vue

<template>
<div>
<Modal
ref="modalSubmit"
:header="isRejected(project) ? 'Resubmit for review' : 'Submit for review'"
>
<div class="modal-submit universal-body">
<span>
You're submitting <span class="project-title">{{ project.title }}</span> to be reviewed
again by the moderators.
</span>
<span>
Make sure you have addressed the comments from the moderation team.
<span class="known-errors">
Repeated submissions without addressing the moderators' comments may result in an
account suspension.
</span>
</span>
<Checkbox
v-model="submissionConfirmation"
description="Confirm I have addressed the messages from the moderators"
>
I confirm that I have properly addressed the moderators' comments.
</Checkbox>
<div class="input-group push-right">
<button
class="iconified-button moderation-button"
:disabled="!submissionConfirmation"
@click="resubmit()"
>
<ScaleIcon aria-hidden="true" />
Resubmit for review
</button>
</div>
</div>
</Modal>
<Modal ref="modalReply" header="Reply to thread">
<div class="modal-submit universal-body">
<span>
Your project is already approved. As such, the moderation team does not actively monitor
this thread. However, they may still see your message if there is a problem with your
project.
</span>
<span>
If you need to get in contact with the moderation team, please use the
<a class="text-link" href="https://support.modrinth.com" target="_blank">
Modrinth Help Center
</a>
and click the green bubble to contact support.
</span>
<Checkbox
v-model="replyConfirmation"
description="Confirm moderators do not actively monitor this"
>
I acknowledge that the moderators do not actively monitor the thread.
</Checkbox>
<div class="input-group push-right">
<button
class="btn btn-primary"
:disabled="!replyConfirmation"
@click="sendReplyFromModal()"
>
<ReplyIcon aria-hidden="true" />
Reply to thread
</button>
</div>
</div>
</Modal>
<div v-if="flags.developerMode" class="thread-id">
Thread ID:
<CopyCode :text="thread.id" />
</div>
<div v-if="sortedMessages.length > 0" class="messages universal-card recessed">
<ThreadMessage
v-for="message in sortedMessages"
:key="'message-' + message.id"
:thread="thread"
:message="message"
:members="members"
:report="report"
:auth="auth"
raised
@update-thread="() => updateThreadLocal()"
/>
</div>
<template v-if="report && report.closed">
<p>This thread is closed and new messages cannot be sent to it.</p>
<button v-if="isStaff(auth.user)" class="iconified-button" @click="reopenReport()">
<CheckCircleIcon aria-hidden="true" />
Reopen thread
</button>
</template>
<template v-else-if="!report || !report.closed">
<div class="markdown-editor-spacing">
<MarkdownEditor
v-model="replyBody"
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
:on-image-upload="onUploadImage"
/>
</div>
<div class="input-group">
<button
v-if="sortedMessages.length > 0"
class="btn btn-primary"
:disabled="!replyBody"
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
>
<ReplyIcon aria-hidden="true" />
Reply
</button>
<button
v-else
class="btn btn-primary"
:disabled="!replyBody"
@click="isApproved(project) && !isStaff(auth.user) ? openReplyModal() : sendReply()"
>
<SendIcon aria-hidden="true" />
Send
</button>
<button
v-if="isStaff(auth.user)"
class="btn"
:disabled="!replyBody"
@click="sendReply(null, true)"
>
<ScaleIcon aria-hidden="true" />
Add private note
</button>
<template v-if="currentMember && !isStaff(auth.user)">
<template v-if="isRejected(project)">
<button
v-if="replyBody"
class="iconified-button moderation-button"
@click="openResubmitModal(true)"
>
<ScaleIcon aria-hidden="true" />
Resubmit for review with reply
</button>
<button
v-else
class="iconified-button moderation-button"
@click="openResubmitModal(false)"
>
<ScaleIcon aria-hidden="true" />
Resubmit for review
</button>
</template>
</template>
<div class="spacer"></div>
<div class="input-group extra-options">
<template v-if="report">
<template v-if="isStaff(auth.user)">
<button
v-if="replyBody"
class="iconified-button danger-button"
@click="closeReport(true)"
>
<CheckCircleIcon aria-hidden="true" />
Close with reply
</button>
<button v-else class="iconified-button danger-button" @click="closeReport()">
<CheckCircleIcon aria-hidden="true" />
Close thread
</button>
</template>
</template>
<template v-if="project">
<template v-if="isStaff(auth.user)">
<button
v-if="replyBody"
class="btn btn-green"
:disabled="isApproved(project)"
@click="sendReply(requestedStatus)"
>
<CheckIcon aria-hidden="true" />
Approve with reply
</button>
<button
v-else
class="btn btn-green"
:disabled="isApproved(project)"
@click="setStatus(requestedStatus)"
>
<CheckIcon aria-hidden="true" />
Approve
</button>
<div class="joined-buttons">
<button
v-if="replyBody"
class="btn btn-danger"
:disabled="project.status === 'rejected'"
@click="sendReply('rejected')"
>
<XIcon aria-hidden="true" />
Reject with reply
</button>
<button
v-else
class="btn btn-danger"
:disabled="project.status === 'rejected'"
@click="setStatus('rejected')"
>
<XIcon aria-hidden="true" />
Reject
</button>
<OverflowMenu
class="btn btn-danger btn-dropdown-animation icon-only"
:options="
replyBody
? [
{
id: 'withhold-reply',
color: 'danger',
action: () => {
sendReply('withheld')
},
hoverFilled: true,
disabled: project.status === 'withheld',
},
{
id: 'send-to-review-reply',
action: () => {
sendReply('processing', true)
},
hoverFilled: true,
disabled: project.status === 'processing',
},
]
: [
{
id: 'withhold',
color: 'danger',
action: () => {
setStatus('withheld')
},
hoverFilled: true,
disabled: project.status === 'withheld',
},
{
id: 'send-to-review',
action: () => {
setStatus('processing')
},
hoverFilled: true,
disabled: project.status === 'processing',
},
]
"
>
<DropdownIcon style="rotate: 180deg" aria-hidden="true" />
<template #withhold-reply>
<EyeOffIcon aria-hidden="true" />
Withhold with reply
</template>
<template #withhold>
<EyeOffIcon aria-hidden="true" />
Withhold
</template>
<template #send-to-review-reply>
<ScaleIcon aria-hidden="true" />
Send to review with reply
</template>
<template #send-to-review>
<ScaleIcon aria-hidden="true" />
Send to review
</template>
</OverflowMenu>
</div>
</template>
</template>
</div>
</div>
</template>
</div>
</template>
<script setup>
import {
CheckCircleIcon,
CheckIcon,
DropdownIcon,
EyeOffIcon,
ReplyIcon,
ScaleIcon,
SendIcon,
XIcon,
} from '@modrinth/assets'
import {
Checkbox,
CopyCode,
injectNotificationManager,
MarkdownEditor,
OverflowMenu,
} from '@modrinth/ui'
import Modal from '~/components/ui/Modal.vue'
import ThreadMessage from '~/components/ui/thread/ThreadMessage.vue'
import { useImageUpload } from '~/composables/image-upload.ts'
import { isApproved, isRejected } from '~/helpers/projects.js'
import { isStaff } from '~/helpers/users.js'
const { addNotification } = injectNotificationManager()
const props = defineProps({
thread: {
type: Object,
required: true,
},
report: {
type: Object,
required: false,
default: null,
},
project: {
type: Object,
required: false,
default: null,
},
setStatus: {
type: Function,
required: false,
default: () => {},
},
currentMember: {
type: Object,
default() {
return null
},
},
auth: {
type: Object,
required: true,
},
})
const emit = defineEmits(['update-thread'])
const app = useNuxtApp()
const flags = useFeatureFlags()
const members = computed(() => {
const members = {}
for (const member of props.thread.members) {
members[member.id] = member
}
return members
})
const replyBody = ref('')
const sortedMessages = computed(() => {
if (props.thread !== null) {
return props.thread.messages
.slice()
.sort((a, b) => app.$dayjs(a.created) - app.$dayjs(b.created))
}
return []
})
const modalSubmit = ref(null)
const modalReply = ref(null)
async function updateThreadLocal() {
let threadId = null
if (props.project) {
threadId = props.project.thread_id
} else if (props.report) {
threadId = props.report.thread_id
}
let thread = null
if (threadId) {
thread = await useBaseFetch(`thread/${threadId}`)
}
emit('update-thread', thread)
}
const imageIDs = ref([])
async function onUploadImage(file) {
const response = await useImageUpload(file, { context: 'thread_message' })
imageIDs.value.push(response.id)
// Keep the last 10 entries of image IDs
imageIDs.value = imageIDs.value.slice(-10)
return response.url
}
async function sendReplyFromModal(status = null, privateMessage = false) {
modalReply.value.hide()
await sendReply(status, privateMessage)
}
async function sendReply(status = null, privateMessage = false) {
try {
const body = {
body: {
type: 'text',
body: replyBody.value,
private: privateMessage,
},
}
if (imageIDs.value.length > 0) {
body.body = {
...body.body,
uploaded_images: imageIDs.value,
}
}
await useBaseFetch(`thread/${props.thread.id}`, {
method: 'POST',
body,
})
replyBody.value = ''
await updateThreadLocal()
if (status !== null) {
props.setStatus(status)
}
} catch (err) {
addNotification({
title: 'Error sending message',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
async function closeReport(reply) {
if (reply) {
await sendReply()
}
try {
await useBaseFetch(`report/${props.report.id}`, {
method: 'PATCH',
body: {
closed: true,
},
})
await updateThreadLocal()
} catch (err) {
addNotification({
title: 'Error closing report',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
async function reopenReport() {
try {
await useBaseFetch(`report/${props.report.id}`, {
method: 'PATCH',
body: {
closed: false,
},
})
await updateThreadLocal()
} catch (err) {
addNotification({
title: 'Error reopening report',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
const replyWithSubmission = ref(false)
const submissionConfirmation = ref(false)
const replyConfirmation = ref(false)
function openResubmitModal(reply) {
submissionConfirmation.value = false
replyWithSubmission.value = reply
modalSubmit.value.show()
}
function openReplyModal() {
replyConfirmation.value = false
modalReply.value.show()
}
async function resubmit() {
if (replyWithSubmission.value) {
await sendReply('processing')
} else {
await props.setStatus('processing')
}
modalSubmit.value.hide()
}
const requestedStatus = computed(() => props.project.requested_status ?? 'approved')
</script>
<style lang="scss" scoped>
.markdown-editor-spacing {
margin-bottom: var(--gap-md);
}
.messages {
display: flex;
flex-direction: column;
padding: var(--spacing-card-md);
}
.resizable-textarea-wrapper {
margin-bottom: var(--spacing-card-sm);
textarea {
padding: var(--spacing-card-bg);
width: 100%;
}
.chips {
margin-bottom: var(--spacing-card-md);
}
.preview {
overflow-y: auto;
}
}
.thread-id {
margin-bottom: var(--spacing-card-md);
font-weight: bold;
color: var(--color-heading);
}
.input-group {
.spacer {
flex-grow: 1;
flex-shrink: 1;
}
.extra-options {
flex-basis: fit-content;
}
}
.modal-submit {
padding: var(--spacing-card-bg);
display: flex;
flex-direction: column;
gap: var(--spacing-card-lg);
.project-title {
font-weight: bold;
}
}
</style>