Skip to content

Commit

Permalink
add comment functionality to activity tab
Browse files Browse the repository at this point in the history
Signed-off-by: Stephan Orbaugh <stephan.orbaugh@nextcloud.com>
Signed-off-by: Louis Chemineau <louis@chmn.me>
  • Loading branch information
artonge committed Nov 2, 2023
1 parent 34de1ef commit 2db136a
Show file tree
Hide file tree
Showing 17 changed files with 1,129 additions and 22 deletions.
2 changes: 1 addition & 1 deletion lib/Listener/LoadSidebarScripts.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,6 @@ public function handle(Event $event): void {
// TODO: make sure to only include the sidebar script when
// we properly split it between files list and sidebar
Util::addStyle(Application::APP_ID, 'style');
Util::addScript(Application::APP_ID, 'activity-sidebar');
Util::addScript(Application::APP_ID, 'activity-sidebar', 'files');
}
}
356 changes: 356 additions & 0 deletions src/components/Comment.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,356 @@
<!--
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<component :is="tag"
v-show="!deleted"
: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="true"
icon="icon-rename"
@click="onEdit">
{{ t('comments', 'Edit comment') }}
</NcActionButton>
<NcActionSeparator />
<NcActionButton :close-after-click="true"
icon="icon-delete"
@click="onDeleteWithUndo">
{{ t('comments', 'Delete comment') }}
</NcActionButton>
</template>

<NcActionButton v-else
icon="icon-close"
@click="onEditCancel">
{{ 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 -->
<Moment v-else-if="creationDateTime" class="comment__timestamp" :timestamp="timestamp" />
</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"
: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>
<span v-if="loading" class="icon-loading-small" />
<ArrowRight 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 -->
<!-- The html is escaped and sanitized before rendering -->
<!-- eslint-disable vue/no-v-html-->
<div v-else
:class="{'comment__message--expanded': expanded}"
class="comment__message"
@click="onExpand"
v-html="renderedContent" />
<!-- eslint-enable vue/no-v-html-->
</div>
</component>
</template>

<script>
import { getCurrentUser } from '@nextcloud/auth'
import moment from '@nextcloud/moment'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcActionSeparator from '@nextcloud/vue/dist/Components/NcActionSeparator.js'
import NcAvatar from '@nextcloud/vue/dist/Components/NcAvatar.js'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import RichEditorMixin from '@nextcloud/vue/dist/Mixins/richEditor.js'
import ArrowRight from 'vue-material-design-icons/ArrowRight.vue'
import Moment from './Moment.vue'
import CommentMixin from '../mixins/CommentMixin.js'
// Dynamic loading
const NcRichContenteditable = () => import('@nextcloud/vue/dist/Components/NcRichContenteditable.js')
export default {
name: 'Comment',
components: {
NcActionButton,
NcActions,
NcActionSeparator,
ArrowRight,
NcAvatar,
NcButton,
Moment,
NcRichContenteditable,
},
mixins: [RichEditorMixin, 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,
},
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: '',
}
},
computed: {
/**
* Is the current user the author of this comment
*
* @return {boolean}
*/
isOwnComment() {
return getCurrentUser().uid === this.actorId
},
/**
* Rendered content as html string
*
* @return {string}
*/
renderedContent() {
if (this.isEmptyMessage) {
return ''
}
return this.renderContent(this.localMessage)
},
isEmptyMessage() {
return !this.localMessage || this.localMessage.trim() === ''
},
timestamp() {
// seconds, not milliseconds
return parseInt(moment(this.creationDateTime).format('x'), 10) / 1000
},
},
watch: {
// If the data change, update the local value
message(message) {
this.updateLocalMessage(message)
},
},
beforeMount() {
// Init localMessage
this.updateLocalMessage(this.message)
},
methods: {
/**
* Update local Message on outer change
*
* @param {string} message the message to set
*/
updateLocalMessage(message) {
this.localMessage = message.toString()
},
/**
* 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: 16px;
padding: 5px $comment-padding;
&__side {
display: flex;
align-items: flex-start;
padding-top: 16px;
}
&__body {
display: flex;
flex-grow: 1;
flex-direction: column;
}
&__header {
display: flex;
align-items: center;
min-height: 44px;
}
&__actions {
margin-left: $comment-padding !important;
}
&__author {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--color-text-maxcontrast);
}
&_loading,
&__timestamp {
margin-left: auto;
text-align: right;
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: 0;
right: 0;
}
&__message {
white-space: pre-wrap;
word-break: break-word;
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>
31 changes: 31 additions & 0 deletions src/components/Moment.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!-- TODO: Move to vue components -->

<template>
<span class="live-relative-timestamp" :data-timestamp="timestamp * 1000" :title="title">{{ formatted }}</span>
</template>

<script>
import moment from '@nextcloud/moment'
export default {
name: 'Moment',
props: {
timestamp: {
type: Number,
required: true,
},
format: {
type: String,
default: 'LLL',
},
},
computed: {
title() {
return moment.unix(this.timestamp).format(this.format)
},
formatted() {
return moment.unix(this.timestamp).fromNow()
},
},
}
</script>
Loading

0 comments on commit 2db136a

Please sign in to comment.