Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add comment functionality to activity tab #1387

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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æ <[email protected]>
-
- @author John Molakvoæ <[email protected]>
-
- @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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Licence is missing

Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<!-- TODO: Move to vue components -->
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO


<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',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Name is too generic

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
Loading