forked from didirus/AstralRinth
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>
This commit is contained in:
@@ -217,6 +217,14 @@
|
||||
hoverFilled: true,
|
||||
disabled: project.status === 'withheld',
|
||||
},
|
||||
{
|
||||
id: 'send-to-review-reply',
|
||||
action: () => {
|
||||
sendReply('processing', true)
|
||||
},
|
||||
hoverFilled: true,
|
||||
disabled: project.status === 'processing',
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
@@ -228,6 +236,14 @@
|
||||
hoverFilled: true,
|
||||
disabled: project.status === 'withheld',
|
||||
},
|
||||
{
|
||||
id: 'send-to-review',
|
||||
action: () => {
|
||||
setStatus('processing')
|
||||
},
|
||||
hoverFilled: true,
|
||||
disabled: project.status === 'processing',
|
||||
},
|
||||
]
|
||||
"
|
||||
>
|
||||
@@ -240,6 +256,14 @@
|
||||
<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>
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="flags.developerMode" class="mb-4 font-bold text-heading">
|
||||
Thread ID:
|
||||
<CopyCode :text="thread.id" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="sortedMessages.length > 0"
|
||||
class="bg-raised flex flex-col space-y-4 rounded-xl p-3 sm:p-4"
|
||||
>
|
||||
<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="reportClosed">
|
||||
<p class="text-secondary">This thread is closed and new messages cannot be sent to it.</p>
|
||||
<ButtonStyled v-if="isStaff(auth.user)" color="green" class="mt-2 w-full sm:w-auto">
|
||||
<button
|
||||
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||
@click="reopenReport()"
|
||||
>
|
||||
<CheckCircleIcon class="size-4" />
|
||||
Reopen Thread
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="mt-4">
|
||||
<MarkdownEditor
|
||||
v-model="replyBody"
|
||||
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
|
||||
:on-image-upload="onUploadImage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-4 flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center sm:gap-2"
|
||||
>
|
||||
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||
<ButtonStyled v-if="sortedMessages.length > 0" color="brand" class="w-full sm:w-auto">
|
||||
<button
|
||||
:disabled="!replyBody"
|
||||
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||
@click="sendReply()"
|
||||
>
|
||||
<ReplyIcon class="size-4" />
|
||||
Reply
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="brand" class="w-full sm:w-auto">
|
||||
<button
|
||||
:disabled="!replyBody"
|
||||
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||
@click="sendReply()"
|
||||
>
|
||||
<SendIcon class="size-4" />
|
||||
Send
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="isStaff(auth.user)" class="w-full sm:w-auto">
|
||||
<button
|
||||
:disabled="!replyBody"
|
||||
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||
@click="sendReply(true)"
|
||||
>
|
||||
<ScaleIcon class="size-4" />
|
||||
<span class="hidden sm:inline">Add private note</span>
|
||||
<span class="sm:hidden">Private note</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||
<template v-if="isStaff(auth.user)">
|
||||
<ButtonStyled v-if="replyBody" color="red" class="w-full sm:w-auto">
|
||||
<button
|
||||
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||
@click="closeReport(true)"
|
||||
>
|
||||
<CheckCircleIcon class="size-4" />
|
||||
<span class="hidden sm:inline">Close with reply</span>
|
||||
<span class="sm:hidden">Close & reply</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="red" class="w-full sm:w-auto">
|
||||
<button
|
||||
class="flex w-full items-center justify-center gap-2 sm:w-auto"
|
||||
@click="closeReport()"
|
||||
>
|
||||
<CheckCircleIcon class="size-4" />
|
||||
Close report
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, ReplyIcon, ScaleIcon, SendIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, CopyCode, injectNotificationManager, MarkdownEditor } from '@modrinth/ui'
|
||||
import type { Report, Thread, ThreadMessage as TypeThreadMessage, User } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { useImageUpload } from '~/composables/image-upload.ts'
|
||||
import { isStaff } from '~/helpers/users.js'
|
||||
|
||||
import ThreadMessage from './ThreadMessage.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const props = defineProps<{
|
||||
thread: Thread
|
||||
reporter: User
|
||||
report: Report
|
||||
}>()
|
||||
|
||||
defineExpose({
|
||||
setReplyContent,
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateThread: [thread: Thread]
|
||||
}>()
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
const members = computed(() => {
|
||||
const membersMap: Record<string, User> = {
|
||||
[props.reporter.id]: props.reporter,
|
||||
}
|
||||
for (const member of props.thread.members) {
|
||||
membersMap[member.id] = member
|
||||
}
|
||||
return membersMap
|
||||
})
|
||||
|
||||
const replyBody = ref('')
|
||||
function setReplyContent(content: string) {
|
||||
replyBody.value = content
|
||||
}
|
||||
|
||||
const sortedMessages = computed(() => {
|
||||
const messages: TypeThreadMessage[] = [
|
||||
{
|
||||
id: null,
|
||||
author_id: props.reporter.id,
|
||||
body: {
|
||||
type: 'text',
|
||||
body: props.report.body || 'Report opened.',
|
||||
private: false,
|
||||
replying_to: null,
|
||||
associated_images: [],
|
||||
},
|
||||
created: props.report.created,
|
||||
hide_identity: false,
|
||||
},
|
||||
]
|
||||
if (props.thread) {
|
||||
messages.push(
|
||||
...[...props.thread.messages].sort(
|
||||
(a, b) => dayjs(a.created).toDate().getTime() - dayjs(b.created).toDate().getTime(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return messages
|
||||
})
|
||||
|
||||
async function updateThreadLocal() {
|
||||
const threadId = props.report.thread_id
|
||||
if (threadId) {
|
||||
try {
|
||||
const thread = (await useBaseFetch(`thread/${threadId}`)) as Thread
|
||||
emit('updateThread', thread)
|
||||
} catch (error) {
|
||||
console.error('Failed to update thread:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const imageIDs = ref<string[]>([])
|
||||
|
||||
async function onUploadImage(file: File) {
|
||||
const response = await useImageUpload(file, { context: 'thread_message' })
|
||||
|
||||
imageIDs.value.push(response.id)
|
||||
imageIDs.value = imageIDs.value.slice(-10)
|
||||
|
||||
return response.url
|
||||
}
|
||||
|
||||
async function sendReply(privateMessage = false) {
|
||||
try {
|
||||
const body: any = {
|
||||
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()
|
||||
} catch (err: any) {
|
||||
addNotification({
|
||||
title: 'Error sending message',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const didCloseReport = ref(false)
|
||||
const reportClosed = computed(() => {
|
||||
return didCloseReport.value || (props.report && props.report.closed)
|
||||
})
|
||||
|
||||
async function closeReport(reply = false) {
|
||||
if (reply) {
|
||||
await sendReply()
|
||||
}
|
||||
|
||||
try {
|
||||
await useBaseFetch(`report/${props.report.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
closed: true,
|
||||
},
|
||||
})
|
||||
await updateThreadLocal()
|
||||
didCloseReport.value = true
|
||||
} catch (err: any) {
|
||||
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: any) {
|
||||
addNotification({
|
||||
title: 'Error reopening report',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -4,7 +4,7 @@
|
||||
:class="{
|
||||
'has-body': message.body.type === 'text' && !forceCompact,
|
||||
'no-actions': noLinks,
|
||||
private: message.body.private,
|
||||
private: isPrivateMessage,
|
||||
}"
|
||||
>
|
||||
<template v-if="members[message.author_id]">
|
||||
@@ -23,7 +23,7 @@
|
||||
</AutoLink>
|
||||
<span :class="`message__author role-${members[message.author_id].role}`">
|
||||
<LockIcon
|
||||
v-if="message.body.private"
|
||||
v-if="isPrivateMessage"
|
||||
v-tooltip="'Only visible to moderators'"
|
||||
class="private-icon"
|
||||
/>
|
||||
@@ -40,13 +40,30 @@
|
||||
v-tooltip="'Reporter'"
|
||||
class="reporter-icon"
|
||||
/>
|
||||
<span
|
||||
v-if="message.preview"
|
||||
class="border-blue/60 rounded-full border border-solid bg-highlight-blue px-2 py-0.5 text-xs font-semibold text-blue"
|
||||
>
|
||||
Preview
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="message__icon backed-svg circle moderation-color" :class="{ raised: raised }">
|
||||
<div
|
||||
class="message__icon backed-svg circle moderation-color"
|
||||
:class="{
|
||||
raised: raised,
|
||||
'system-message-icon': ['tech_review_entered', 'tech_review_exit_file_deleted'].includes(
|
||||
message.body.type,
|
||||
),
|
||||
}"
|
||||
>
|
||||
<ScaleIcon />
|
||||
</div>
|
||||
<span class="message__author moderation-color">
|
||||
<span
|
||||
v-if="!['tech_review_entered', 'tech_review_exit_file_deleted'].includes(message.body.type)"
|
||||
class="message__author moderation-color"
|
||||
>
|
||||
Moderator
|
||||
<ScaleIcon v-tooltip="'Moderator'" />
|
||||
</span>
|
||||
@@ -69,6 +86,17 @@
|
||||
</template>
|
||||
<span v-else-if="message.body.type === 'thread_closure'">closed the thread.</span>
|
||||
<span v-else-if="message.body.type === 'thread_reopen'">reopened the thread.</span>
|
||||
<span v-else-if="message.body.type === 'tech_review'">
|
||||
completed technical review and marked project as
|
||||
<Badge :type="message.body.verdict" />.
|
||||
</span>
|
||||
<span v-else-if="message.body.type === 'tech_review_entered'">
|
||||
The project has entered the technical review queue.
|
||||
</span>
|
||||
<span v-else-if="message.body.type === 'tech_review_exit_file_deleted'">
|
||||
The project has left the technical review queue as all files pending review were deleted by
|
||||
the user.
|
||||
</span>
|
||||
</div>
|
||||
<span class="message__date">
|
||||
<span v-tooltip="$dayjs(message.created).format('MMMM D, YYYY [at] h:mm A')">
|
||||
@@ -160,6 +188,15 @@ const formattedMessage = computed(() => {
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const timeSincePosted = ref(formatRelativeTime(props.message.created))
|
||||
|
||||
const isPrivateMessage = computed(() => {
|
||||
return (
|
||||
props.message.body.private ||
|
||||
['tech_review', 'tech_review_entered', 'tech_review_exit_file_deleted'].includes(
|
||||
props.message.body.type,
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
async function deleteMessage() {
|
||||
await useBaseFetch(`message/${props.message.id}`, {
|
||||
method: 'DELETE',
|
||||
@@ -333,4 +370,8 @@ a:active + .message__author a,
|
||||
.private {
|
||||
color: var(--color-icon);
|
||||
}
|
||||
|
||||
.system-message-icon {
|
||||
--size: 2rem !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
227
apps/frontend/src/components/ui/thread/ThreadView.vue
Normal file
227
apps/frontend/src/components/ui/thread/ThreadView.vue
Normal file
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="flags.developerMode" class="mt-4 font-bold text-heading">
|
||||
Thread ID:
|
||||
<CopyCode :text="thread.id" />
|
||||
</div>
|
||||
|
||||
<div v-if="sortedMessages.length > 0" class="flex flex-col space-y-4 rounded-xl p-3 sm:p-4">
|
||||
<ThreadMessage
|
||||
v-for="message in sortedMessages"
|
||||
:key="'message-' + message.id"
|
||||
:thread="thread"
|
||||
:message="message"
|
||||
:members="members"
|
||||
:auth="auth"
|
||||
raised
|
||||
@update-thread="() => updateThreadLocal()"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex flex-col items-center justify-center space-y-3 py-12">
|
||||
<MessageIcon class="size-12 text-secondary" />
|
||||
<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>
|
||||
|
||||
<template v-else>
|
||||
<div>
|
||||
<MarkdownEditor
|
||||
v-model="replyBody"
|
||||
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
|
||||
:on-image-upload="onUploadImage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-4 flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center sm:gap-2"
|
||||
>
|
||||
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||
<ButtonStyled v-if="sortedMessages.length > 0" color="brand">
|
||||
<button :disabled="!replyBody" class="w-full gap-2 sm:w-auto" @click="sendReply()">
|
||||
<ReplyIcon class="size-4" />
|
||||
Reply
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="brand">
|
||||
<button :disabled="!replyBody" class="w-full gap-2 sm:w-auto" @click="sendReply()">
|
||||
<SendIcon class="size-4" />
|
||||
Send
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="isStaff(auth.user)">
|
||||
<button :disabled="!replyBody" class="w-full sm:w-auto" @click="sendReply(true)">
|
||||
Add note
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="visibleQuickReplies.length > 0">
|
||||
<OverflowMenu :options="visibleQuickReplies">
|
||||
Quick Reply
|
||||
<ChevronDownIcon />
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
|
||||
<slot name="additionalActions" :has-reply="!!replyBody" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { MessageIcon, ReplyIcon, SendIcon } from '@modrinth/assets'
|
||||
import type { QuickReply } from '@modrinth/moderation'
|
||||
import {
|
||||
ButtonStyled,
|
||||
CopyCode,
|
||||
injectNotificationManager,
|
||||
MarkdownEditor,
|
||||
OverflowMenu,
|
||||
type OverflowMenuOption,
|
||||
} from '@modrinth/ui'
|
||||
import type { Thread, User } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { useImageUpload } from '~/composables/image-upload.ts'
|
||||
import { isStaff } from '~/helpers/users.js'
|
||||
|
||||
import ChevronDownIcon from '../servers/icons/ChevronDownIcon.vue'
|
||||
import ThreadMessage from './ThreadMessage.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
|
||||
const replies = props.quickReplies
|
||||
const context = props.quickReplyContext
|
||||
|
||||
if (!replies || !context) return []
|
||||
|
||||
return replies
|
||||
.filter((reply) => {
|
||||
if (reply.shouldShow === undefined) return true
|
||||
return reply.shouldShow(context)
|
||||
})
|
||||
.map(
|
||||
(reply) =>
|
||||
({
|
||||
id: reply.label,
|
||||
action: () => handleQuickReply(reply, context),
|
||||
}) as OverflowMenuOption,
|
||||
)
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
await nextTick()
|
||||
setReplyContent(message)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
setReplyContent,
|
||||
getReplyContent,
|
||||
sendReply,
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updateThread: [thread: Thread]
|
||||
}>()
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
const members = computed(() => {
|
||||
const membersMap: Record<string, User> = {}
|
||||
for (const member of props.thread.members) {
|
||||
membersMap[member.id] = member
|
||||
}
|
||||
return membersMap
|
||||
})
|
||||
|
||||
const replyBody = ref('')
|
||||
|
||||
function setReplyContent(content: string) {
|
||||
replyBody.value = content
|
||||
}
|
||||
|
||||
function getReplyContent(): string {
|
||||
return replyBody.value
|
||||
}
|
||||
|
||||
const sortedMessages = computed(() => {
|
||||
if (!props.thread) return []
|
||||
|
||||
return [...props.thread.messages].sort(
|
||||
(a, b) => dayjs(a.created).toDate().getTime() - dayjs(b.created).toDate().getTime(),
|
||||
)
|
||||
})
|
||||
|
||||
async function updateThreadLocal() {
|
||||
const threadId = props.thread.id
|
||||
if (threadId) {
|
||||
try {
|
||||
const thread = (await useBaseFetch(`thread/${threadId}`)) as Thread
|
||||
emit('updateThread', thread)
|
||||
} catch (error) {
|
||||
console.error('Failed to update thread:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const imageIDs = ref<string[]>([])
|
||||
|
||||
async function onUploadImage(file: File) {
|
||||
const response = await useImageUpload(file, { context: 'thread_message' })
|
||||
|
||||
imageIDs.value.push(response.id)
|
||||
imageIDs.value = imageIDs.value.slice(-10)
|
||||
|
||||
return response.url
|
||||
}
|
||||
|
||||
async function sendReply(privateMessage = false) {
|
||||
try {
|
||||
const body: any = {
|
||||
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()
|
||||
} catch (err: any) {
|
||||
addNotification({
|
||||
title: 'Error sending message',
|
||||
text: err.data ? err.data.description : err,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user