-
-
+ updateThreadLocal()"
+ @open-image="openImage"
/>
@@ -127,9 +140,11 @@
-
+
+ >
+
+
+
+
-
+
+
+ {{ formatMessage(messages.replyAddresses) }}
+
+
+ {{ facet.label }}
+
+
+
+
sendReply(null, true))"
>
-
-
- {{ formatMessage(messages.actionResubmitForReviewWithReply) }}
-
-
-
- {{ formatMessage(messages.actionResubmitForReview) }}
-
+
+
+ {{ formatMessage(messages.actionResubmitForReviewWithReply) }}
+
+
+
+ {{ formatMessage(messages.actionResubmitForReview) }}
+
+
@@ -313,10 +350,7 @@
runBlockingAction('send-to-review-reply', () =>
sendReply('processing', true),
),
- disabled:
- project.status === 'processing' ||
- isLoading ||
- reviewSubmissionDisabled,
+ disabled: project.status === 'processing' || isLoading,
},
]
: [
@@ -345,10 +379,7 @@
hoverFilled: true,
action: () =>
runBlockingAction('send-to-review', () => setStatus('processing')),
- disabled:
- project.status === 'processing' ||
- isLoading ||
- reviewSubmissionDisabled,
+ disabled: project.status === 'processing' || isLoading,
},
]
"
@@ -400,6 +431,25 @@
+
+
+
+
+
+
+
@@ -407,6 +457,7 @@
import {
CheckCircleIcon,
CheckIcon,
+ ExternalIcon,
EyeOffIcon,
FileTextIcon,
ReplyIcon,
@@ -418,27 +469,58 @@ import {
} from '@modrinth/assets'
import {
Button,
+ ButtonLink,
Checkbox,
commonMessages,
CopyCode,
defineMessages,
+ ImageViewerEditor,
+ injectModrinthClient,
injectNotificationManager,
IntlFormatted,
MarkdownEditor,
NewModal,
SplitButton,
+ Tooltip,
useVIntl,
} from '@modrinth/ui'
+import { useMutation, useQueryClient } from '@tanstack/vue-query'
+import { computed, nextTick, ref, watch } from 'vue'
-import ThreadMessage from '~/components/ui/thread/ThreadMessage.vue'
+import IssueMessage from '~/components/ui/project-issue-card/issue-message.vue'
+import ThreadTimeline from '~/components/ui/thread/ThreadTimeline.vue'
import { useImageUpload } from '~/composables/image-upload.ts'
import { isApproved, isRejected } from '~/helpers/projects.js'
+import { sendThreadReply } from '~/helpers/thread-issues'
import { isStaff } from '~/helpers/users.js'
+const client = injectModrinthClient()
+const queryClient = useQueryClient()
+const route = useRoute()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
+ replyAddresses: {
+ id: 'conversation-thread.reply.addresses',
+ defaultMessage: 'This reply addresses the following requested explanations:',
+ },
+ replyFacet: {
+ id: 'conversation-thread.reply.facet',
+ defaultMessage: 'Explanation for {issue}',
+ },
+ unknownIssue: {
+ id: 'conversation-thread.reply.unknown-issue',
+ defaultMessage: 'Moderation issue',
+ },
+ changedThread: {
+ id: 'conversation-thread.reply.changed-thread',
+ defaultMessage: 'The selected thread changed. Open it again before replying.',
+ },
+ openImageExternally: {
+ id: 'conversation-thread.image.open-externally',
+ defaultMessage: 'Open externally',
+ },
resubmitModalHeaderResubmitting: {
id: 'conversation-thread.resubmit-modal.header.resubmitting',
defaultMessage: 'Resubmitting for review',
@@ -448,9 +530,14 @@ const messages = defineMessages({
defaultMessage: 'Submitting for review',
},
resubmitModalDescription: {
- id: 'conversation-thread.resubmit-modal.description',
+ id: 'conversation-thread.resubmit-modal.description.auto-approval',
+ defaultMessage:
+ "You're resubmitting
{projectTitle} . If all moderation issues are resolved, it may be approved automatically. Otherwise, it will be sent to the moderators for review.",
+ },
+ submitModalDescription: {
+ id: 'conversation-thread.submit-modal.description',
defaultMessage:
- "You're submitting
{projectTitle} to be reviewed again by the moderators.",
+ "You're submitting
{projectTitle} to be reviewed by the moderators.",
},
resubmitModalReminder: {
id: 'conversation-thread.resubmit-modal.reminder',
@@ -507,6 +594,10 @@ const messages = defineMessages({
id: 'conversation-thread.action.resubmit-for-review',
defaultMessage: 'Resubmit for review',
},
+ resubmitRequiredActionsTooltip: {
+ id: 'conversation-thread.resubmit-required-actions-tooltip',
+ defaultMessage: 'You must complete all required actions above to resubmit',
+ },
actionReplyToThread: {
id: 'conversation-thread.action.reply-to-thread',
defaultMessage: 'Reply to thread',
@@ -594,6 +685,10 @@ const messages = defineMessages({
})
const props = defineProps({
+ projectOwnerId: {
+ type: String,
+ default: null,
+ },
reviewSubmissionDisabled: {
type: Boolean,
default: false,
@@ -643,6 +738,78 @@ const members = computed(() => {
})
const replyBody = ref('')
+const replyEditor = ref(null)
+const selectedReplyFacetIds = ref([])
+const replyFacets = computed(() =>
+ props.project && (props.currentMember?.accepted || isStaff(props.auth.user))
+ ? (props.thread.issues ?? []).flatMap((issue) =>
+ issue.facets
+ .filter(
+ (facet) =>
+ facet.verdict === 'open' &&
+ facet.what.type === 'acknowledge' &&
+ facet.what.value.mode === 'reply',
+ )
+ .map((facet) => ({
+ id: facet.id,
+ label: formatMessage(messages.replyFacet, {
+ issue: issue.why?.title ?? formatMessage(messages.unknownIssue),
+ }),
+ })),
+ )
+ : [],
+)
+function selectReplyFacet(id, selected) {
+ selectedReplyFacetIds.value = selected
+ ? [...new Set([...selectedReplyFacetIds.value, id])]
+ : selectedReplyFacetIds.value.filter((entry) => entry !== id)
+}
+watch(
+ [() => route.query.reply_to_facet, replyFacets, replyEditor],
+ async ([id, facets]) => {
+ const ids = (Array.isArray(id) ? id : [id]).filter(
+ (id) => typeof id === 'string' && facets.some((facet) => facet.id === id),
+ )
+ if (!ids.length) return
+ for (const id of ids) selectReplyFacet(id, true)
+ await nextTick()
+ await replyEditor.value?.focus?.()
+ },
+ { immediate: true, flush: 'post' },
+)
+watch(
+ () => props.thread.id,
+ () => {
+ replyBody.value = ''
+ selectedReplyFacetIds.value = []
+ imageIDs.value = []
+ },
+)
+
+const imageViewer = ref(null)
+const imageItems = ref([])
+let imageTrigger
+
+async function openImage(image) {
+ imageTrigger = image.element
+ imageItems.value = [{ id: image.src, src: image.src, alt: image.alt }]
+ await nextTick()
+ imageViewer.value?.show(0)
+}
+
+function restoreImageFocus() {
+ if (imageTrigger?.isConnected) imageTrigger.focus({ preventScroll: true })
+ imageTrigger = undefined
+}
+
+watch(
+ () => props.thread?.id,
+ () => {
+ imageTrigger = undefined
+ imageViewer.value?.hide()
+ imageItems.value = []
+ },
+)
const sortedMessages = computed(() => {
if (props.thread !== null) {
@@ -671,78 +838,119 @@ async function runBlockingAction(actionId, action) {
}
}
-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)
+async function updateThreadLocal(threadId = props.thread.id) {
+ const thread = await queryClient.fetchQuery({
+ queryKey: ['thread', threadId],
+ queryFn: () => client.labrinth.threads_v3.getThread(threadId),
+ staleTime: 0,
+ })
+ if (props.thread.id === 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)
+ try {
+ const response = await useImageUpload(file, { context: 'thread_message' })
- return response.url
-}
+ imageIDs.value.push(response.id)
+ imageIDs.value = imageIDs.value.slice(-10)
-async function sendReplyFromModal(status = null, privateMessage = false) {
- await sendReply(status, privateMessage)
- modalReply.value.hide()
+ return response.url
+ } catch (error) {
+ addNotification({
+ title: formatMessage(commonMessages.errorNotificationTitle),
+ text: error instanceof Error ? error.message : String(error),
+ type: 'error',
+ })
+ throw error
+ }
}
-async function sendReply(status = null, privateMessage = false) {
- if (status === 'processing' && props.reviewSubmissionDisabled) return
- try {
- const body = {
- body: {
- type: 'text',
- body: replyBody.value,
- private: privateMessage,
- },
+const replyMutation = useMutation({
+ mutationFn: async ({ threadId, projectId, status, privateMessage }) => {
+ const assertCurrent = () => {
+ if (props.thread.id !== threadId) throw new Error(formatMessage(messages.changedThread))
}
-
- if (imageIDs.value.length > 0) {
- body.body = {
- ...body.body,
- uploaded_images: imageIDs.value,
+ if (replyBody.value.trim()) {
+ const reply = {
+ threadId,
+ body: replyBody.value,
+ images: [...imageIDs.value],
+ privateMessage,
+ }
+ const facetIds = privateMessage
+ ? []
+ : (props.thread.issues ?? []).flatMap((issue) =>
+ issue.facets.some((facet) => selectedReplyFacetIds.value.includes(facet.id))
+ ? issue.facets
+ .filter(
+ (facet) =>
+ facet.verdict === 'open' &&
+ facet.what.type === 'acknowledge' &&
+ (facet.what.value.mode === 'checkbox' ||
+ selectedReplyFacetIds.value.includes(facet.id)),
+ )
+ .map(({ id }) => id)
+ : [],
+ )
+ await sendThreadReply(reply, client, assertCurrent)
+ replyBody.value = ''
+ imageIDs.value = []
+ selectedReplyFacetIds.value = []
+ for (const facetId of facetIds) {
+ assertCurrent()
+ await client.labrinth.threads_v3.user_addressed(facetId)
}
}
-
- await useBaseFetch(`thread/${props.thread.id}`, {
- method: 'POST',
- body,
- })
-
- replyBody.value = ''
-
- await updateThreadLocal()
- if (status !== null) {
- await props.setStatus(status)
- }
- } catch (err) {
+ assertCurrent()
+ await updateThreadLocal(threadId)
+ if (projectId)
+ await queryClient.invalidateQueries({
+ queryKey: ['project', projectId, 'validation'],
+ })
+ assertCurrent()
+ if (status !== null) return (await props.setStatus(status)) !== false
+ return true
+ },
+ onError: (error) =>
addNotification({
title: formatMessage(messages.errorSendingMessage),
- text: err.data ? err.data.description : err,
+ text: error instanceof Error ? error.message : String(error),
type: 'error',
+ }),
+ onSettled: async (_, __, { threadId, projectId }) => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ['thread', threadId] }),
+ projectId
+ ? queryClient.invalidateQueries({
+ queryKey: ['project', projectId, 'validation'],
+ })
+ : Promise.resolve(),
+ ])
+ },
+})
+
+async function sendReplyFromModal(status = null, privateMessage = false) {
+ if (await sendReply(status, privateMessage)) modalReply.value.hide()
+}
+
+async function sendReply(status = null, privateMessage = false) {
+ if (status === 'processing' && props.reviewSubmissionDisabled && !isStaff(props.auth.user))
+ return false
+ return await replyMutation
+ .mutateAsync({
+ threadId: props.thread.id,
+ projectId: props.project?.id,
+ status,
+ privateMessage,
})
- }
+ .catch(() => false)
}
async function closeReport(reply) {
if (reply) {
- await sendReply()
+ if (!(await sendReply())) return
}
try {
@@ -798,14 +1006,18 @@ function openReplyModal() {
async function resubmit() {
if (props.reviewSubmissionDisabled) return
if (replyWithSubmission.value) {
- await sendReply('processing')
+ if (!(await sendReply('processing'))) return
} else {
- await props.setStatus('processing')
+ if ((await props.setStatus('processing')) === false) return
}
modalSubmit.value.hide()
}
-const requestedStatus = computed(() => props.project.requested_status ?? 'approved')
+const requestedStatus = computed(() =>
+ ['approved', 'unlisted', 'private'].includes(props.project?.requested_status)
+ ? props.project.requested_status
+ : 'approved',
+)
defineOptions({
inheritAttrs: false,
diff --git a/apps/frontend/src/components/ui/thread/ThreadIssueHistory.vue b/apps/frontend/src/components/ui/thread/ThreadIssueHistory.vue
new file mode 100644
index 00000000000..fda95c57725
--- /dev/null
+++ b/apps/frontend/src/components/ui/thread/ThreadIssueHistory.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ author.username }}
+
+
{{
+ formatMessage(messages.moderator)
+ }}
+
+ {{ ' ' }}
+
+
+
+
{{
+ relativeTime(entry.created)
+ }}
+
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/thread/ThreadMessage.vue b/apps/frontend/src/components/ui/thread/ThreadMessage.vue
index 08dd9399b96..612927b0bd7 100644
--- a/apps/frontend/src/components/ui/thread/ThreadMessage.vue
+++ b/apps/frontend/src/components/ui/thread/ThreadMessage.vue
@@ -1,152 +1,214 @@
-
-
-
-
-
-
- {{ members[message.author_id].username }}
-
-
-
-
-
+
+
+
+
+
+
+
+
+ {{ author.username }}
+
+
+
+
+
+ {{ formatMessage(imageMessages.preview) }}
+
+
+
+
- Preview
+ {{ formatMessage(isAutoApproval ? imageMessages.projectOwner : imageMessages.moderator) }}
+
-
-
-
+
-
-
+
+
-
- Moderator
-
-
-
-
-
-
- This project was published on Modrinth before moderation threads existed and may be missing
- moderation history.
-
- posted a message that has been deleted.
-
-
- submitted the project for review.
+
+ {{ formatMessage(imageMessages.legacyProjectMessage) }}
-
- reviewed the project and set its status to .
+
+ {{ formatMessage(imageMessages.deletedMessage) }}
-
- reverted this project back to a .
+
+
+
+
-
- changed the project's status from to
- .
+
+
+ {{ formatMessage(imageMessages.submittedForReview) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(imageMessages.threadClosed) }}
-
- closed the thread.
- reopened the thread.
-
- completed technical review and marked project as
- .
-
-
- The project has entered the technical review queue.
-
-
- The project has left the technical review queue as all pending traces have been resolved.
-
-
- The project has left the technical review queue as all files pending review were deleted by
- the user.
-
-
-
-
- {{ timeSincePosted }}
-
-
-
-
-
- Delete
-
+
+ {{ formatMessage(imageMessages.threadReopened) }}
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(imageMessages.techReviewEntered) }}
+
+
+ {{ formatMessage(imageMessages.techReviewExited) }}
+
+
+ {{ formatMessage(imageMessages.techReviewExitFileDeleted) }}
+
+
+
+
+
+ {{ timeSincePosted }}
+
+
+
+
+
+ {{ formatMessage(imageMessages.delete) }}
+
+
+
@@ -156,9 +218,7 @@ import {
EyeOffIcon,
InfoIcon,
MicrophoneIcon,
- ModrinthIcon,
MoreHorizontalIcon,
- ScaleIcon,
TrashIcon,
} from '@modrinth/assets'
import { moderationSettings } from '@modrinth/moderation'
@@ -166,19 +226,34 @@ import {
AutoLink,
Avatar,
Badge,
+ defineMessages,
+ injectModrinthClient,
+ IntlFormatted,
TeleportOverflowMenu,
useFormatDateTime,
useRelativeTime,
+ useVIntl,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
+import IssueMessage from '~/components/ui/project-issue-card/issue-message.vue'
import { isStaff } from '~/helpers/users.js'
+import ThreadRoleBadge from './ThreadRoleBadge.vue'
+
const props = defineProps({
message: {
type: Object,
required: true,
},
+ reviewIssueCount: {
+ type: Number,
+ default: 0,
+ },
+ projectOwnerId: {
+ type: String,
+ default: null,
+ },
report: {
type: Object,
default: null,
@@ -199,14 +274,150 @@ const props = defineProps({
type: Boolean,
default: false,
},
+ imagePreviews: {
+ type: Boolean,
+ default: false,
+ },
auth: {
type: Object,
required: true,
},
})
-const emit = defineEmits(['update-thread'])
+const hasBody = computed(() => props.message.body.type === 'text' && !props.forceCompact)
+const authorClasses = 'inline [&>svg]:ms-1 [&>svg]:me-1 [&>svg]:inline-block [&>svg]:align-middle'
+const bodyClasses = computed(() => (hasBody.value ? 'mt-1' : 'ms-1 inline'))
+
+const emit = defineEmits(['update-thread', 'open-image'])
const settings = useModerationSettings()
+const client = injectModrinthClient()
+const isAutoApproval = computed(() => props.message.body.type === 'auto_approval')
+const author = computed(
+ () => props.members[isAutoApproval.value ? props.projectOwnerId : props.message.author_id],
+)
+
+const { formatMessage } = useVIntl()
+
+const imageMessages = defineMessages({
+ projectOwner: {
+ id: 'thread.message.project-owner',
+ defaultMessage: 'Project owner',
+ },
+ moderator: {
+ id: 'thread.message.moderator',
+ defaultMessage: 'Moderator',
+ },
+ autoApproval: {
+ id: 'thread.message.auto-approval',
+ defaultMessage:
+ 'resolved all issues and the project status has been automatically set to
approved .',
+ },
+ review: {
+ id: 'thread.message.review',
+ defaultMessage: 'reviewed the project and set its status to
status .',
+ },
+ reviewWithIssues: {
+ id: 'thread.message.review-with-issues',
+ defaultMessage:
+ 'reviewed the project and set its status to
status with {count, plural, one {# issue} other {# issues}}.',
+ },
+ openImage: {
+ id: 'thread.message.open-image',
+ defaultMessage: 'Open image',
+ },
+ privateMessage: {
+ id: 'thread.message.private-message',
+ defaultMessage: 'Only visible to moderators',
+ },
+ reporter: { id: 'thread.message.reporter', defaultMessage: 'Reporter' },
+ preview: { id: 'thread.message.preview', defaultMessage: 'Preview' },
+ legacyProjectMessage: {
+ id: 'thread.message.legacy-project-message',
+ defaultMessage:
+ 'This project was published on Modrinth before moderation threads existed and may be missing moderation history.',
+ },
+ deletedMessage: {
+ id: 'thread.message.deleted-message',
+ defaultMessage: 'posted a message that has been deleted.',
+ },
+ submittedForReview: {
+ id: 'thread.message.submitted-for-review',
+ defaultMessage: 'submitted the project for review.',
+ },
+ revertedToDraft: {
+ id: 'thread.message.reverted-to-draft',
+ defaultMessage: 'reverted this project back to a
. ',
+ },
+ statusChanged: {
+ id: 'thread.message.status-changed',
+ defaultMessage:
+ "changed the project's status from
status to
. ",
+ },
+ threadClosed: {
+ id: 'thread.message.thread-closed',
+ defaultMessage: 'closed the thread.',
+ },
+ threadReopened: {
+ id: 'thread.message.thread-reopened',
+ defaultMessage: 'reopened the thread.',
+ },
+ techReviewCompleted: {
+ id: 'thread.message.tech-review-completed',
+ defaultMessage: 'completed technical review and marked project as
. ',
+ },
+ techReviewEntered: {
+ id: 'thread.message.tech-review-entered',
+ defaultMessage: 'The project has entered the technical review queue.',
+ },
+ techReviewExited: {
+ id: 'thread.message.tech-review-exited',
+ defaultMessage:
+ 'The project has left the technical review queue as all pending traces have been resolved.',
+ },
+ techReviewExitFileDeleted: {
+ id: 'thread.message.tech-review-exit-file-deleted',
+ defaultMessage:
+ 'The project has left the technical review queue as all files pending review were deleted by the user.',
+ },
+ moreOptions: { id: 'thread.message.more-options', defaultMessage: 'More options' },
+ delete: { id: 'thread.message.delete', defaultMessage: 'Delete' },
+ sentImage: { id: 'thread.message.sent-image', defaultMessage: 'sent an image.' },
+ sentMessage: { id: 'thread.message.sent-message', defaultMessage: 'sent a message.' },
+})
+
+function prepareImagePreviews(element, binding) {
+ if (!binding.value && !binding.oldValue) return
+ for (const image of element.querySelectorAll('img')) {
+ if (image.closest('button')) continue
+ if (binding.value && image.getAttribute('src')) {
+ image.setAttribute('role', 'button')
+ image.setAttribute('tabindex', '0')
+ image.setAttribute('aria-label', image.alt || binding.value)
+ } else {
+ image.removeAttribute('role')
+ image.removeAttribute('tabindex')
+ image.removeAttribute('aria-label')
+ }
+ }
+}
+
+const vImagePreviews = {
+ mounted: prepareImagePreviews,
+ updated: prepareImagePreviews,
+}
+
+function openImage(event) {
+ if (!props.imagePreviews || !(event.target instanceof HTMLImageElement)) return
+ const image = event.target
+ if (!image.getAttribute('src') || image.closest('button')) return
+ event.preventDefault()
+ event.stopPropagation()
+ emit('open-image', { src: image.currentSrc || image.src, alt: image.alt, element: image })
+}
+
+function handleImageKeydown(event) {
+ if (event.key === 'Enter' || event.key === ' ') openImage(event)
+}
const formattedMessage = computed(() => {
const body = renderString(props.message.body.body)
@@ -216,9 +427,9 @@ const formattedMessage = computed(() => {
if (noHtml.trim()) {
return noHtml
} else if (hasImage) {
- return 'sent an image.'
+ return formatMessage(imageMessages.sentImage)
} else {
- return 'sent a message.'
+ return formatMessage(imageMessages.sentMessage)
}
}
return body
@@ -245,191 +456,7 @@ const isPrivateMessage = computed(() => {
})
async function deleteMessage() {
- await useBaseFetch(`message/${props.message.id}`, {
- method: 'DELETE',
- })
+ await client.labrinth.threads_v3.deleteMessage(props.message.id)
emit('update-thread')
}
-
-
diff --git a/apps/frontend/src/components/ui/thread/ThreadRoleBadge.vue b/apps/frontend/src/components/ui/thread/ThreadRoleBadge.vue
new file mode 100644
index 00000000000..3b680ed47a3
--- /dev/null
+++ b/apps/frontend/src/components/ui/thread/ThreadRoleBadge.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/thread/ThreadSummary.vue b/apps/frontend/src/components/ui/thread/ThreadSummary.vue
index 3a348256ea4..441d1d479d1 100644
--- a/apps/frontend/src/components/ui/thread/ThreadSummary.vue
+++ b/apps/frontend/src/components/ui/thread/ThreadSummary.vue
@@ -12,6 +12,8 @@
v-for="message in displayMessages"
:key="message.id"
:message="message"
+ :review-issue-count="reviewIssueCounts.get(message) ?? 0"
+ :project-owner-id="projectOwner?.id"
:report="report"
:members="members"
:auth="auth"
@@ -27,6 +29,9 @@
import { ChevronRightIcon } from '@modrinth/assets'
import ThreadMessage from '~/components/ui/thread/ThreadMessage.vue'
+import { useThreadProjectOwner } from '~/composables/thread-project-owner'
+
+import { countThreadReviewIssues } from './timeline'
const props = defineProps({
thread: {
@@ -61,12 +66,18 @@ const props = defineProps({
const app = useNuxtApp()
+const projectOwner = useThreadProjectOwner(computed(() => props.thread))
+const reviewIssueCounts = computed(() =>
+ countThreadReviewIssues(props.thread.messages, props.thread.issues ?? []),
+)
+
const members = computed(() => {
const members = {}
for (const member of props.thread.members) {
members[member.id] = member
}
members[props.auth.user.id] = props.auth.user
+ if (projectOwner.value) members[projectOwner.value.id] = projectOwner.value
return members
})
diff --git a/apps/frontend/src/components/ui/thread/ThreadTimeline.vue b/apps/frontend/src/components/ui/thread/ThreadTimeline.vue
new file mode 100644
index 00000000000..bff5f57c461
--- /dev/null
+++ b/apps/frontend/src/components/ui/thread/ThreadTimeline.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
diff --git a/apps/frontend/src/components/ui/thread/ThreadView.vue b/apps/frontend/src/components/ui/thread/ThreadView.vue
index c1855b5cefa..638d2e1a6de 100644
--- a/apps/frontend/src/components/ui/thread/ThreadView.vue
+++ b/apps/frontend/src/components/ui/thread/ThreadView.vue
@@ -5,12 +5,14 @@
-