1
0

Threads and more! (#1232)

* Begin UI for threads and moderation overhaul

* Hide close button on non-report threads

* Fix review age coloring

* Add project count

* Remove action buttons from queue page and add queued date to project page

* Hook up to actual data

* Remove unused icon

* Get up to 1000 projects in queue

* prettier

* more prettier

* Changed all the things

* lint

* rebuild

* Add omorphia

* Workaround formatjs bug in ThreadSummary.vue

* Fix notifications page on prod

* Fix a few notifications and threads bugs

* lockfile

* Fix duplicate button styles

* more fixes and polishing

* More fixes

* Remove legacy pages

* More bugfixes

* Add some error catching for reports and notifications

* More error handling

* fix lint

* Add inbox links

* Remove loading component and rename member header

* Rely on threads always existing

* Handle if project update notifs are not grouped

* oops

* Fix chips on notifications page

* Import ModalModeration

* finish threads

---------

Co-authored-by: triphora <emma@modrinth.com>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
This commit is contained in:
Prospector
2023-07-15 20:39:33 -07:00
committed by GitHub
parent 1a2b45eebd
commit a5613ebb10
67 changed files with 3613 additions and 776 deletions

View File

@@ -0,0 +1,381 @@
<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()"
>
<ModerationIcon /> Resubmit for review
</button>
</div>
</div>
</Modal>
<div v-if="$cosmetics.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"
raised
/>
</div>
<span v-if="report && report.closed">
This thread is closed and new messages cannot be sent to it.
</span>
<template v-else-if="!report || !report.closed">
<div class="resizable-textarea-wrapper">
<Chips v-model="replyViewMode" class="chips" :items="['source', 'preview']" />
<textarea
v-if="replyViewMode === 'source'"
v-model="replyBody"
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
/>
<div v-else class="markdown-body preview" v-html="renderString(replyBody)" />
</div>
<div class="input-group">
<button
v-if="sortedMessages.length > 0"
class="iconified-button brand-button"
:disabled="!replyBody"
@click="sendReply()"
>
<ReplyIcon /> Reply
</button>
<button
v-else
class="iconified-button brand-button"
:disabled="!replyBody"
@click="sendReply()"
>
<SendIcon /> Send
</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)"
>
<ModerationIcon /> Resubmit for review with reply
</button>
<button
v-else
class="iconified-button moderation-button"
@click="openResubmitModal(false)"
>
<ModerationIcon /> 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)"
>
<CloseIcon /> Close with reply
</button>
<button v-else class="iconified-button danger-button" @click="closeReport()">
<CloseIcon /> Close thread
</button>
</template>
</template>
<template v-if="project">
<template v-if="isStaff($auth.user)">
<button
v-if="replyBody"
class="iconified-button brand-button"
@click="sendReply(requestedStatus)"
>
<CheckIcon /> Approve with reply
</button>
<button
v-else
class="iconified-button brand-button"
:disabled="isApproved(project)"
@click="setStatus(requestedStatus)"
>
<CheckIcon /> Approve project
</button>
<button
v-if="replyBody"
class="iconified-button moderation-button"
:disabled="project.status === 'withheld'"
@click="sendReply('withheld')"
>
<EyeOffIcon /> Withhold with reply
</button>
<button
v-else
class="iconified-button moderation-button"
:disabled="project.status === 'withheld'"
@click="setStatus('withheld')"
>
<EyeOffIcon /> Withhold project
</button>
<button
v-if="replyBody"
class="iconified-button danger-button"
:disabled="project.status === 'rejected'"
@click="sendReply('rejected')"
>
<CrossIcon /> Reject with reply
</button>
<button
v-else
class="iconified-button danger-button"
:disabled="project.status === 'rejected'"
@click="setStatus('rejected')"
>
<CrossIcon /> Reject project
</button>
</template>
</template>
</div>
</div>
</template>
</div>
</template>
<script setup>
import Chips from '~/components/ui/Chips.vue'
import CopyCode from '~/components/ui/CopyCode.vue'
import ReplyIcon from '~/assets/images/utils/reply.svg'
import SendIcon from '~/assets/images/utils/send.svg'
import CloseIcon from '~/assets/images/utils/check-circle.svg'
import CrossIcon from '~/assets/images/utils/x.svg'
import EyeOffIcon from '~/assets/images/utils/eye-off.svg'
import CheckIcon from '~/assets/images/utils/check.svg'
import ModerationIcon from '~/assets/images/sidebar/admin.svg'
import { renderString } from '~/helpers/parse.js'
import ThreadMessage from '~/components/ui/thread/ThreadMessage.vue'
import { isStaff } from '~/helpers/users.js'
import { isApproved, isRejected } from '~/helpers/projects.js'
import Modal from '~/components/ui/Modal.vue'
import Checkbox from '~/components/ui/Checkbox.vue'
const props = defineProps({
thread: {
type: Object,
required: true,
},
report: {
type: Object,
required: false,
default: null,
},
project: {
type: Object,
required: false,
default: null,
},
updateThread: {
type: Function,
required: false,
default: () => {},
},
setStatus: {
type: Function,
required: false,
default: () => {},
},
currentMember: {
type: Object,
default() {
return null
},
},
})
const app = useNuxtApp()
const members = computed(() => {
const members = {}
for (const member of props.thread.members) {
members[member.id] = member
}
return members
})
const replyViewMode = ref('source')
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)
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}`, app.$defaultHeaders())
}
props.updateThread(thread)
}
async function sendReply(status = null) {
try {
await useBaseFetch(`thread/${props.thread.id}`, {
method: 'POST',
body: {
body: {
type: 'text',
body: replyBody.value,
},
},
...app.$defaultHeaders(),
})
replyBody.value = ''
await updateThreadLocal()
if (status !== null) {
props.setStatus(status)
}
} catch (err) {
app.$notify({
group: 'main',
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,
},
...app.$defaultHeaders(),
})
await updateThreadLocal()
} catch (err) {
app.$notify({
group: 'main',
title: 'Error closing report',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
const replyWithSubmission = ref(false)
const submissionConfirmation = ref(false)
function openResubmitModal(reply) {
submissionConfirmation.value = false
replyWithSubmission.value = reply
modalSubmit.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>
.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>

View File

@@ -0,0 +1,309 @@
<template>
<div
class="message"
:class="{
'has-body': message.body.type === 'text' && !forceCompact,
'no-actions': noLinks,
private: message.body.private,
}"
>
<template v-if="members[message.author_id]">
<ConditionalNuxtLink
class="message__icon"
:is-link="!noLinks"
:to="`/user/${members[message.author_id].username}`"
tabindex="-1"
aria-hidden="true"
>
<Avatar
class="message__icon"
:src="members[message.author_id].avatar_url"
circle
:raised="raised"
/>
</ConditionalNuxtLink>
<span :class="`message__author role-${members[message.author_id].role}`">
<PrivateIcon
v-if="message.body.private"
v-tooltip="'Only visible by moderators'"
class="private-icon"
/>
<ConditionalNuxtLink
:is-link="!noLinks"
:to="`/user/${members[message.author_id].username}`"
>
{{ members[message.author_id].username }}
</ConditionalNuxtLink>
<ModeratorIcon
v-if="members[message.author_id].role === 'moderator'"
v-tooltip="'Moderator'"
/>
<ModrinthIcon
v-else-if="members[message.author_id].role === 'admin'"
v-tooltip="'Modrinth Team'"
/>
<MicIcon
v-if="report && message.author_id === report.reporterUser.id"
v-tooltip="'Reporter'"
class="reporter-icon"
/>
</span>
</template>
<template v-else>
<div class="message__icon backed-svg circle moderation-color" :class="{ raised: raised }">
<ModeratorIcon />
</div>
<span class="message__author moderation-color">
Moderator
<ModeratorIcon v-tooltip="'Moderator'" />
</span>
</template>
<div
v-if="message.body.type === 'text'"
class="message__body markdown-body"
v-html="formattedMessage"
/>
<div v-else class="message__body status-message">
<span v-if="message.body.type === 'deleted'"> posted a message that has been deleted. </span>
<template v-else-if="message.body.type === 'status_change'">
<span v-if="message.body.new_status === 'processing'">
submitted the project for review.
</span>
<span v-else>
changed the project's status from <Badge :type="message.body.old_status" /> to
<Badge :type="message.body.new_status" />.
</span>
</template>
<span v-else-if="message.body.type === 'thread_closure'">closed the thread.</span>
</div>
<span class="message__date">
<span v-tooltip="$dayjs(message.created).format('MMMM D, YYYY [at] h:mm A')">
{{ timeSincePosted }}
</span>
</span>
<!-- <div class="message__actions">-->
<!-- <Button icon-only><MoreIcon /></Button>-->
<!-- </div>-->
</div>
</template>
<script setup>
import Avatar from '~/components/ui/Avatar.vue'
import Badge from '~/components/ui/Badge.vue'
import ModeratorIcon from '~/assets/images/sidebar/admin.svg'
import ModrinthIcon from '~/assets/images/utils/modrinth.svg'
import MicIcon from '~/assets/images/utils/mic.svg'
import PrivateIcon from '~/assets/images/utils/lock.svg'
import { renderString } from '~/helpers/parse.js'
import ConditionalNuxtLink from '~/components/ui/ConditionalNuxtLink.vue'
const props = defineProps({
message: {
type: Object,
required: true,
},
report: {
type: Object,
default: null,
},
members: {
type: Object,
default: () => {},
},
forceCompact: {
type: Boolean,
default: false,
},
noLinks: {
type: Boolean,
default: false,
},
raised: {
type: Boolean,
default: false,
},
})
const formattedMessage = computed(() => {
const body = renderString(props.message.body.body)
if (props.forceCompact) {
const hasImage = body.includes('<img')
const noHtml = body.replace(/<\/?[^>]+(>|$)/g, '')
if (noHtml.trim()) {
return noHtml
} else if (hasImage) {
return 'sent an image.'
} else {
return 'sent a message.'
}
}
return body
})
const formatRelativeTime = useRelativeTime()
const timeSincePosted = ref(formatRelativeTime(props.message.created))
</script>
<style lang="scss" scoped>
.message {
--gap-size: var(--spacing-card-xs);
display: flex;
flex-direction: row;
gap: var(--gap-size);
flex-wrap: wrap;
align-items: center;
border-radius: var(--size-rounded-card);
padding: var(--spacing-card-md);
word-break: break-word;
.avatar,
.backed-svg {
--size: 1.5rem;
}
&.has-body {
--gap-size: var(--spacing-card-sm);
display: grid;
grid-template:
'icon author actions'
'icon body actions'
'date date date';
grid-template-columns: min-content auto 1fr;
column-gap: var(--gap-size);
row-gap: var(--spacing-card-xs);
.message__icon {
margin-bottom: auto;
}
.avatar,
.backed-svg {
--size: 3rem;
}
}
&:not(.no-actions):hover,
&:not(.no-actions):focus-within {
background-color: var(--color-table-alternate-row);
.message__actions {
opacity: 1;
}
}
&.no-actions {
padding: 0;
.message__actions {
display: none;
}
}
}
.message__icon {
grid-area: icon;
}
.message__author {
grid-area: author;
font-weight: bold;
display: flex;
gap: var(--spacing-card-xs);
flex-wrap: wrap;
flex-shrink: 0;
}
.message__date {
grid-area: date;
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
}
.message__actions {
grid-area: actions;
margin-left: auto;
opacity: 0;
}
.message__body {
grid-area: body;
}
.status-message > span {
display: flex;
align-items: center;
gap: var(--spacing-card-xs);
flex-wrap: wrap;
}
a {
display: flex;
align-items: center;
text-decoration: none;
}
a:focus-visible + .message__author a,
a:hover + .message__author a,
.message__author a:focus-visible,
.message__author a:hover {
text-decoration: underline;
filter: var(--hover-filter);
}
a:active + .message__author a,
.message__author a:active {
filter: var(--active-filter);
}
.moderation-color,
role-moderator {
color: var(--color-special-orange);
}
.role-admin {
color: var(--color-brand-green);
}
.reporter-icon {
color: var(--color-special-purple);
}
.private-icon {
color: var(--color-special-gray);
}
@media screen and (min-width: 600px) {
.message {
//grid-template:
// 'icon author body'
// 'date date date';
//grid-template-columns: min-content auto 1fr;
&.has-body {
grid-template:
'icon author actions'
'icon body actions'
'date date date';
grid-template-columns: min-content auto 1fr;
}
}
}
@media screen and (min-width: 1024px) {
.message {
//grid-template: 'icon author body date';
//grid-template-columns: min-content auto 1fr auto;
&.has-body {
grid-template:
'icon author date actions'
'icon body body actions';
grid-template-columns: min-content auto 1fr;
grid-template-rows: min-content 1fr auto;
}
}
}
.private {
color: var(--color-icon);
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<nuxt-link :to="link" class="thread-summary" :class="{ raised: raised }">
<div class="thread-title-row">
<span v-if="report" class="thread-title">Report thread</span>
<span v-else class="thread-title">Thread</span>
<span class="thread-messages"
>{{ props.thread.messages.length }} messages <ChevronRightIcon
/></span>
</div>
<template v-if="displayMessages.length > 0">
<ThreadMessage
v-for="message in displayMessages"
:key="message.id"
:message="message"
:report="report"
:members="members"
force-compact
no-links
/>
</template>
<span v-else>There are no messages in this thread yet.</span>
</nuxt-link>
</template>
<script setup>
import ChevronRightIcon from '~/assets/images/utils/chevron-right.svg'
import ThreadMessage from '~/components/ui/thread/ThreadMessage.vue'
const props = defineProps({
thread: {
type: Object,
required: true,
},
report: {
type: Object,
required: false,
default: null,
},
raised: {
type: Boolean,
default: false,
},
link: {
type: String,
required: true,
},
messages: {
type: Array,
required: false,
default() {
return []
},
},
})
const app = useNuxtApp()
const members = computed(() => {
const members = {}
for (const member of props.thread.members) {
members[member.id] = member
}
members[app.$auth.user.id] = app.$auth.user
return members
})
const displayMessages = computed(() => {
const sortedMessages = props.thread.messages
.slice()
.sort((a, b) => app.$dayjs(a.created) - app.$dayjs(b.created))
if (props.messages.length > 0) {
return sortedMessages.filter((msg) => props.messages.includes(msg.id))
} else {
return sortedMessages.length > 0 ? [sortedMessages[sortedMessages.length - 1]] : []
}
})
</script>
<style lang="scss" scoped>
.thread-summary {
display: flex;
flex-direction: column;
background-color: var(--color-bg);
padding: var(--spacing-card-bg);
border-radius: var(--size-rounded-card);
border: 1px solid var(--color-divider-dark);
gap: var(--spacing-card-sm);
.thread-title-row {
display: flex;
flex-direction: row;
align-items: center;
.thread-title {
font-weight: bold;
color: var(--color-heading);
}
.thread-messages {
margin-left: auto;
color: var(--color-link);
svg {
vertical-align: top;
}
}
}
.thread-message {
.user {
font-weight: bold;
}
.date {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
}
}
.thread-message,
.thread-message > span {
display: flex;
flex-direction: row;
gap: var(--spacing-card-xs);
align-items: center;
}
&.raised {
background-color: var(--color-raised-bg);
}
&:hover .thread-title-row,
&:focus-visible .thread-title-row {
text-decoration: underline;
filter: var(--hover-filter);
}
&:active .thread-title-row {
filter: var(--active-filter);
}
}
</style>