mirror of
https://github.com/nextcloud/server.git
synced 2026-02-24 18:37:44 -05:00
402 lines
8.8 KiB
Vue
402 lines
8.8 KiB
Vue
<!--
|
||
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
||
- SPDX-License-Identifier: AGPL-3.0-or-later
|
||
-->
|
||
<template>
|
||
<component
|
||
:is="tag"
|
||
v-show="!deleted && !isLimbo"
|
||
:class="{ 'comment--loading': loading }"
|
||
class="comment">
|
||
<!-- Comment header toolbar -->
|
||
<div class="comment__side">
|
||
<!-- Author -->
|
||
<NcAvatar
|
||
class="comment__avatar"
|
||
:display-name="actorDisplayName"
|
||
:user="actorId"
|
||
:size="32" />
|
||
</div>
|
||
<div class="comment__body">
|
||
<div class="comment__header">
|
||
<span class="comment__author">{{ actorDisplayName }}</span>
|
||
|
||
<!-- Comment actions,
|
||
show if we have a message id and current user is author -->
|
||
<NcActions v-if="isOwnComment && id && !loading" class="comment__actions">
|
||
<template v-if="!editing">
|
||
<NcActionButton
|
||
close-after-click
|
||
@click="onEdit">
|
||
<template #icon>
|
||
<IconPencilOutline :size="20" />
|
||
</template>
|
||
{{ t('comments', 'Edit comment') }}
|
||
</NcActionButton>
|
||
<NcActionSeparator />
|
||
<NcActionButton
|
||
close-after-click
|
||
@click="onDeleteWithUndo">
|
||
<template #icon>
|
||
<IconTrashCanOutline :size="20" />
|
||
</template>
|
||
{{ t('comments', 'Delete comment') }}
|
||
</NcActionButton>
|
||
</template>
|
||
|
||
<NcActionButton v-else @click="onEditCancel">
|
||
<template #icon>
|
||
<IconClose :size="20" />
|
||
</template>
|
||
{{ t('comments', 'Cancel edit') }}
|
||
</NcActionButton>
|
||
</NcActions>
|
||
|
||
<!-- Show loading if we're editing or deleting, not on new ones -->
|
||
<div v-if="id && loading" class="comment_loading icon-loading-small" />
|
||
|
||
<!-- Relative time to the comment creation -->
|
||
<NcDateTime
|
||
v-else-if="creationDateTime"
|
||
class="comment__timestamp"
|
||
:timestamp="timestamp"
|
||
:ignore-seconds="true" />
|
||
</div>
|
||
|
||
<!-- Message editor -->
|
||
<form v-if="editor || editing" class="comment__editor" @submit.prevent>
|
||
<div class="comment__editor-group">
|
||
<NcRichContenteditable
|
||
ref="editor"
|
||
:auto-complete="autoComplete"
|
||
:contenteditable="!loading"
|
||
:label="editor ? t('comments', 'New comment') : t('comments', 'Edit comment')"
|
||
:placeholder="t('comments', 'Write a comment …')"
|
||
:model-value="localMessage"
|
||
:user-data="userData"
|
||
aria-describedby="tab-comments__editor-description"
|
||
@update:value="updateLocalMessage"
|
||
@submit="onSubmit" />
|
||
<div class="comment__submit">
|
||
<NcButton
|
||
variant="tertiary-no-background"
|
||
type="submit"
|
||
:aria-label="t('comments', 'Post comment')"
|
||
:disabled="isEmptyMessage"
|
||
@click="onSubmit">
|
||
<template #icon>
|
||
<NcLoadingIcon v-if="loading" />
|
||
<IconArrowRight v-else :size="20" />
|
||
</template>
|
||
</NcButton>
|
||
</div>
|
||
</div>
|
||
<div id="tab-comments__editor-description" class="comment__editor-description">
|
||
{{ t('comments', '@ for mentions, : for emoji, / for smart picker') }}
|
||
</div>
|
||
</form>
|
||
|
||
<!-- Message content -->
|
||
<NcRichText
|
||
v-else
|
||
class="comment__message"
|
||
:class="{ 'comment__message--expanded': expanded }"
|
||
:text="richContent.message"
|
||
:arguments="richContent.mentions"
|
||
use-markdown
|
||
@click.native="onExpand" />
|
||
</div>
|
||
</component>
|
||
</template>
|
||
|
||
<script>
|
||
import { getCurrentUser } from '@nextcloud/auth'
|
||
import { translate as t } from '@nextcloud/l10n'
|
||
import { mapStores } from 'pinia'
|
||
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
|
||
import NcActions from '@nextcloud/vue/components/NcActions'
|
||
import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
|
||
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
|
||
import NcButton from '@nextcloud/vue/components/NcButton'
|
||
import NcDateTime from '@nextcloud/vue/components/NcDateTime'
|
||
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
|
||
import NcUserBubble from '@nextcloud/vue/components/NcUserBubble'
|
||
import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
|
||
import IconClose from 'vue-material-design-icons/Close.vue'
|
||
import IconPencilOutline from 'vue-material-design-icons/PencilOutline.vue'
|
||
import IconTrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
|
||
import CommentMixin from '../mixins/CommentMixin.js'
|
||
import { useDeletedCommentLimbo } from '../store/deletedCommentLimbo.js'
|
||
|
||
// Dynamic loading
|
||
const NcRichContenteditable = () => import('@nextcloud/vue/components/NcRichContenteditable')
|
||
const NcRichText = () => import('@nextcloud/vue/components/NcRichText')
|
||
|
||
export default {
|
||
/* eslint vue/multi-word-component-names: "warn" */
|
||
name: 'Comment',
|
||
|
||
components: {
|
||
IconArrowRight,
|
||
IconClose,
|
||
IconTrashCanOutline,
|
||
IconPencilOutline,
|
||
NcActionButton,
|
||
NcActions,
|
||
NcActionSeparator,
|
||
NcAvatar,
|
||
NcButton,
|
||
NcDateTime,
|
||
NcLoadingIcon,
|
||
NcRichContenteditable,
|
||
NcRichText,
|
||
},
|
||
|
||
mixins: [CommentMixin],
|
||
|
||
inheritAttrs: false,
|
||
|
||
props: {
|
||
actorDisplayName: {
|
||
type: String,
|
||
required: true,
|
||
},
|
||
|
||
actorId: {
|
||
type: String,
|
||
required: true,
|
||
},
|
||
|
||
creationDateTime: {
|
||
type: String,
|
||
default: null,
|
||
},
|
||
|
||
/**
|
||
* Force the editor display
|
||
*/
|
||
editor: {
|
||
type: Boolean,
|
||
default: false,
|
||
},
|
||
|
||
/**
|
||
* Provide the autocompletion data
|
||
*/
|
||
autoComplete: {
|
||
type: Function,
|
||
required: true,
|
||
},
|
||
|
||
userData: {
|
||
type: Object,
|
||
default: () => ({}),
|
||
},
|
||
|
||
tag: {
|
||
type: String,
|
||
default: 'div',
|
||
},
|
||
},
|
||
|
||
data() {
|
||
return {
|
||
expanded: false,
|
||
// Only change data locally and update the original
|
||
// parent data when the request is sent and resolved
|
||
localMessage: '',
|
||
submitted: false,
|
||
}
|
||
},
|
||
|
||
computed: {
|
||
...mapStores(useDeletedCommentLimbo),
|
||
|
||
/**
|
||
* Is the current user the author of this comment
|
||
*
|
||
* @return {boolean}
|
||
*/
|
||
isOwnComment() {
|
||
return getCurrentUser().uid === this.actorId
|
||
},
|
||
|
||
richContent() {
|
||
const mentions = {}
|
||
let message = this.localMessage
|
||
|
||
Object.keys(this.userData).forEach((user, index) => {
|
||
const key = `mention-${index}`
|
||
const regex = new RegExp(`@${user}|@"${user}"`, 'g')
|
||
message = message.replace(regex, `{${key}}`)
|
||
mentions[key] = {
|
||
component: NcUserBubble,
|
||
props: {
|
||
user,
|
||
displayName: this.userData[user].label,
|
||
primary: this.userData[user].primary,
|
||
},
|
||
}
|
||
})
|
||
|
||
return { mentions, message }
|
||
},
|
||
|
||
isEmptyMessage() {
|
||
return !this.localMessage || this.localMessage.trim() === ''
|
||
},
|
||
|
||
/**
|
||
* Timestamp of the creation time (in ms UNIX time)
|
||
*/
|
||
timestamp() {
|
||
return Date.parse(this.creationDateTime)
|
||
},
|
||
|
||
isLimbo() {
|
||
return this.deletedCommentLimboStore.checkForId(this.id)
|
||
},
|
||
},
|
||
|
||
watch: {
|
||
// If the data change, update the local value
|
||
message(message) {
|
||
this.updateLocalMessage(message)
|
||
},
|
||
},
|
||
|
||
beforeMount() {
|
||
// Init localMessage
|
||
this.updateLocalMessage(this.message)
|
||
},
|
||
|
||
methods: {
|
||
t,
|
||
|
||
/**
|
||
* Update local Message on outer change
|
||
*
|
||
* @param {string} message the message to set
|
||
*/
|
||
updateLocalMessage(message) {
|
||
this.localMessage = message.toString()
|
||
this.submitted = false
|
||
},
|
||
|
||
/**
|
||
* Dispatch message between edit and create
|
||
*/
|
||
onSubmit() {
|
||
// Do not submit if message is empty
|
||
if (this.localMessage.trim() === '') {
|
||
return
|
||
}
|
||
|
||
if (this.editor) {
|
||
this.onNewComment(this.localMessage.trim())
|
||
this.$nextTick(() => {
|
||
// Focus the editor again
|
||
this.$refs.editor.$el.focus()
|
||
})
|
||
return
|
||
}
|
||
this.onEditComment(this.localMessage.trim())
|
||
},
|
||
|
||
onExpand() {
|
||
this.expanded = true
|
||
},
|
||
},
|
||
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
@use "sass:math";
|
||
|
||
$comment-padding: 10px;
|
||
|
||
.comment {
|
||
display: flex;
|
||
gap: 8px;
|
||
padding: 5px $comment-padding;
|
||
|
||
&__side {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
padding-top: 6px;
|
||
}
|
||
|
||
&__body {
|
||
display: flex;
|
||
flex-grow: 1;
|
||
flex-direction: column;
|
||
container-type: inline-size;
|
||
}
|
||
|
||
&__header {
|
||
display: flex;
|
||
align-items: center;
|
||
min-height: 44px;
|
||
}
|
||
|
||
&__actions {
|
||
margin-inline-start: $comment-padding !important;
|
||
}
|
||
|
||
&__author {
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
color: var(--color-text-maxcontrast);
|
||
}
|
||
|
||
&_loading,
|
||
&__timestamp {
|
||
margin-inline-start: auto;
|
||
text-align: end;
|
||
white-space: nowrap;
|
||
color: var(--color-text-maxcontrast);
|
||
}
|
||
|
||
&__editor-group {
|
||
position: relative;
|
||
}
|
||
|
||
&__editor-description {
|
||
color: var(--color-text-maxcontrast);
|
||
padding-block: var(--default-grid-baseline);
|
||
}
|
||
|
||
&__submit {
|
||
position: absolute !important;
|
||
bottom: 5px;
|
||
inset-inline-end: 0;
|
||
}
|
||
|
||
&__message {
|
||
white-space: pre-wrap;
|
||
word-break: normal;
|
||
max-height: 200px;
|
||
overflow: auto;
|
||
scrollbar-gutter: stable;
|
||
scrollbar-width: thin;
|
||
margin-top: -6px;
|
||
&--expanded {
|
||
max-height: none;
|
||
overflow: visible;
|
||
}
|
||
:deep(img) {
|
||
max-width: 100%;
|
||
height: auto;
|
||
}
|
||
}
|
||
}
|
||
|
||
.rich-contenteditable__input {
|
||
min-height: 44px;
|
||
margin: 0;
|
||
padding: $comment-padding;
|
||
}
|
||
|
||
</style>
|