aboutsummaryrefslogtreecommitdiffstats
path: root/apps/comments/src/components/Comment.vue
diff options
context:
space:
mode:
Diffstat (limited to 'apps/comments/src/components/Comment.vue')
-rw-r--r--apps/comments/src/components/Comment.vue384
1 files changed, 384 insertions, 0 deletions
diff --git a/apps/comments/src/components/Comment.vue b/apps/comments/src/components/Comment.vue
new file mode 100644
index 00000000000..80f035530fb
--- /dev/null
+++ b/apps/comments/src/components/Comment.vue
@@ -0,0 +1,384 @@
+<!--
+ - 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 …')"
+ :value="localMessage"
+ :user-data="userData"
+ aria-describedby="tab-comments__editor-description"
+ @update:value="updateLocalMessage"
+ @submit="onSubmit" />
+ <div class="comment__submit">
+ <NcButton type="tertiary-no-background"
+ native-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"
+ @click="onExpand" />
+ </div>
+ </component>
+</template>
+
+<script>
+import { getCurrentUser } from '@nextcloud/auth'
+import { translate as t } from '@nextcloud/l10n'
+
+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 IconTrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
+import IconPencilOutline from 'vue-material-design-icons/PencilOutline.vue'
+
+import CommentMixin from '../mixins/CommentMixin.js'
+import { mapStores } from 'pinia'
+import { useDeletedCommentLimbo } from '../store/deletedCommentLimbo.js'
+
+// Dynamic loading
+const NcRichContenteditable = () => import('@nextcloud/vue/components/NcRichContenteditable')
+const NcRichText = () => import('@nextcloud/vue/components/NcRichText')
+
+export default {
+ 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;
+ }
+
+ &__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: 70px;
+ overflow: hidden;
+ margin-top: -6px;
+ &--expanded {
+ max-height: none;
+ overflow: visible;
+ }
+ }
+}
+
+.rich-contenteditable__input {
+ min-height: 44px;
+ margin: 0;
+ padding: $comment-padding;
+}
+
+</style>