diff options
30 files changed, 1642 insertions, 19 deletions
diff --git a/apps/comments/composer/composer/autoload_classmap.php b/apps/comments/composer/composer/autoload_classmap.php index c4d8a9e331b..b0485a5c4e3 100644 --- a/apps/comments/composer/composer/autoload_classmap.php +++ b/apps/comments/composer/composer/autoload_classmap.php @@ -15,9 +15,11 @@ return array( 'OCA\\Comments\\Collaboration\\CommentersSorter' => $baseDir . '/../lib/Collaboration/CommentersSorter.php', 'OCA\\Comments\\Controller\\Notifications' => $baseDir . '/../lib/Controller/Notifications.php', 'OCA\\Comments\\EventHandler' => $baseDir . '/../lib/EventHandler.php', + 'OCA\\Comments\\Event\\LoadCommentsApp' => $baseDir . '/../lib/Event/LoadCommentsApp.php', 'OCA\\Comments\\JSSettingsHelper' => $baseDir . '/../lib/JSSettingsHelper.php', 'OCA\\Comments\\Listener\\CommentsEntityEventListener' => $baseDir . '/../lib/Listener/CommentsEntityEventListener.php', 'OCA\\Comments\\Listener\\LoadAdditionalScripts' => $baseDir . '/../lib/Listener/LoadAdditionalScripts.php', + 'OCA\\Comments\\Listener\\LoadCommentsAppListener' => $baseDir . '/../lib/Listener/LoadCommentsAppListener.php', 'OCA\\Comments\\Listener\\LoadSidebarScripts' => $baseDir . '/../lib/Listener/LoadSidebarScripts.php', 'OCA\\Comments\\Notification\\Listener' => $baseDir . '/../lib/Notification/Listener.php', 'OCA\\Comments\\Notification\\Notifier' => $baseDir . '/../lib/Notification/Notifier.php', diff --git a/apps/comments/composer/composer/autoload_static.php b/apps/comments/composer/composer/autoload_static.php index 72b37969ec0..bc69b25743d 100644 --- a/apps/comments/composer/composer/autoload_static.php +++ b/apps/comments/composer/composer/autoload_static.php @@ -30,9 +30,11 @@ class ComposerStaticInitComments 'OCA\\Comments\\Collaboration\\CommentersSorter' => __DIR__ . '/..' . '/../lib/Collaboration/CommentersSorter.php', 'OCA\\Comments\\Controller\\Notifications' => __DIR__ . '/..' . '/../lib/Controller/Notifications.php', 'OCA\\Comments\\EventHandler' => __DIR__ . '/..' . '/../lib/EventHandler.php', + 'OCA\\Comments\\Event\\LoadCommentsApp' => __DIR__ . '/..' . '/../lib/Event/LoadCommentsApp.php', 'OCA\\Comments\\JSSettingsHelper' => __DIR__ . '/..' . '/../lib/JSSettingsHelper.php', 'OCA\\Comments\\Listener\\CommentsEntityEventListener' => __DIR__ . '/..' . '/../lib/Listener/CommentsEntityEventListener.php', 'OCA\\Comments\\Listener\\LoadAdditionalScripts' => __DIR__ . '/..' . '/../lib/Listener/LoadAdditionalScripts.php', + 'OCA\\Comments\\Listener\\LoadCommentsAppListener' => __DIR__ . '/..' . '/../lib/Listener/LoadCommentsAppListener.php', 'OCA\\Comments\\Listener\\LoadSidebarScripts' => __DIR__ . '/..' . '/../lib/Listener/LoadSidebarScripts.php', 'OCA\\Comments\\Notification\\Listener' => __DIR__ . '/..' . '/../lib/Notification/Listener.php', 'OCA\\Comments\\Notification\\Notifier' => __DIR__ . '/..' . '/../lib/Notification/Notifier.php', diff --git a/apps/comments/lib/AppInfo/Application.php b/apps/comments/lib/AppInfo/Application.php index 4eb097ff001..0f22cd309ec 100644 --- a/apps/comments/lib/AppInfo/Application.php +++ b/apps/comments/lib/AppInfo/Application.php @@ -30,6 +30,8 @@ namespace OCA\Comments\AppInfo; use Closure; use OCA\Comments\Capabilities; use OCA\Comments\Controller\Notifications; +use OCA\Comments\Event\LoadCommentsApp; +use OCA\Comments\Listener\LoadCommentsAppListener; use OCA\Comments\EventHandler; use OCA\Comments\JSSettingsHelper; use OCA\Comments\Listener\CommentsEntityEventListener; @@ -71,6 +73,10 @@ class Application extends App implements IBootstrap { LoadSidebarScripts::class ); $context->registerEventListener( + LoadCommentsApp::class, + LoadCommentsAppListener::class + ); + $context->registerEventListener( CommentsEntityEvent::EVENT_ENTITY, CommentsEntityEventListener::class ); diff --git a/apps/comments/lib/Event/LoadCommentsApp.php b/apps/comments/lib/Event/LoadCommentsApp.php new file mode 100644 index 00000000000..74ed93ad447 --- /dev/null +++ b/apps/comments/lib/Event/LoadCommentsApp.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); +/** + * @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/>. + * + */ +namespace OCA\Comments\Event; + +use OCP\EventDispatcher\Event; + +/** + * This event is used to load and init the comments app + * + * @since 21.0.0 + */ +class LoadCommentsApp extends Event { +} diff --git a/apps/comments/lib/Listener/LoadCommentsAppListener.php b/apps/comments/lib/Listener/LoadCommentsAppListener.php new file mode 100644 index 00000000000..755bdaee1ba --- /dev/null +++ b/apps/comments/lib/Listener/LoadCommentsAppListener.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +/** + * @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/>. + * + */ + +namespace OCA\Comments\Listener; + +use OCA\Comments\AppInfo\Application; +use OCA\Comments\Event\LoadCommentsApp; +use OCP\AppFramework\Services\IInitialState; +use OCP\Comments\IComment; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Util; + +class LoadCommentsAppListener implements IEventListener { + + /** @var IInitialState */ + private $initialStateService; + + public function __construct(IInitialState $initialStateService) { + $this->initialStateService = $initialStateService; + } + + public function handle(Event $event): void { + if (!($event instanceof LoadCommentsApp)) { + return; + } + + $this->initialStateService->provideInitialState('max-message-length', IComment::MAX_MESSAGE_LENGTH); + + Util::addScript(Application::APP_ID, 'comments-app'); + } +} diff --git a/apps/comments/lib/Listener/LoadSidebarScripts.php b/apps/comments/lib/Listener/LoadSidebarScripts.php index dfa7e511b1f..0b76d88363d 100644 --- a/apps/comments/lib/Listener/LoadSidebarScripts.php +++ b/apps/comments/lib/Listener/LoadSidebarScripts.php @@ -28,19 +28,32 @@ declare(strict_types=1); namespace OCA\Comments\Listener; use OCA\Comments\AppInfo\Application; +use OCA\Comments\Event\LoadCommentsApp; use OCA\Files\Event\LoadSidebar; use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; use OCP\EventDispatcher\IEventListener; use OCP\Util; class LoadSidebarScripts implements IEventListener { + + /** @var IEventDispatcher */ + private $eventDispatcher; + + public function __construct(IEventDispatcher $eventDispatcher) { + $this->eventDispatcher = $eventDispatcher; + } + public function handle(Event $event): void { if (!($event instanceof LoadSidebar)) { return; } + $this->eventDispatcher->dispatchTyped(new LoadCommentsApp()); + // TODO: make sure to only include the sidebar script when // we properly split it between files list and sidebar Util::addScript(Application::APP_ID, 'comments'); + Util::addScript(Application::APP_ID, 'comments-tab'); } } diff --git a/apps/comments/src/comments-app.js b/apps/comments/src/comments-app.js new file mode 100644 index 00000000000..ced5577d5f8 --- /dev/null +++ b/apps/comments/src/comments-app.js @@ -0,0 +1,32 @@ +/** + * @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/>. + * + */ + +import CommentsInstance from './services/CommentsInstance' + +// Init Comments +if (window.OCA && !window.OCA.Comments) { + Object.assign(window.OCA, { Comments: {} }) +} + +// Init Comments App view +Object.assign(window.OCA.Comments, { View: CommentsInstance }) +console.debug('OCA.Comments.View initialized') diff --git a/apps/comments/src/comments-tab.js b/apps/comments/src/comments-tab.js new file mode 100644 index 00000000000..50126dc8d2d --- /dev/null +++ b/apps/comments/src/comments-tab.js @@ -0,0 +1,58 @@ +/** + * @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/>. + * + */ + +// Init Comments tab component +let TabInstance = null +const commentTab = new OCA.Files.Sidebar.Tab({ + id: 'comments', + name: t('comments', 'Comments'), + icon: 'icon-comment', + + async mount(el, fileInfo, context) { + if (TabInstance) { + TabInstance.$destroy() + } + TabInstance = new OCA.Comments.View('files', { + // Better integration with vue parent component + parent: context, + }) + // Only mount after we have all the info we need + await TabInstance.update(fileInfo.id) + TabInstance.$mount(el) + }, + update(fileInfo) { + TabInstance.update(fileInfo.id) + }, + destroy() { + TabInstance.$destroy() + TabInstance = null + }, + scrollBottomReached() { + TabInstance.onScrollBottomReached() + }, +}) + +window.addEventListener('DOMContentLoaded', function() { + if (OCA.Files && OCA.Files.Sidebar) { + OCA.Files.Sidebar.registerTab(commentTab) + } +}) diff --git a/apps/comments/src/components/Comment.vue b/apps/comments/src/components/Comment.vue new file mode 100644 index 00000000000..acacb156f75 --- /dev/null +++ b/apps/comments/src/components/Comment.vue @@ -0,0 +1,295 @@ +<!-- + - @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> + <div v-show="!deleted" + :class="{'comment--loading': loading}" + class="comment"> + <!-- Comment header toolbar --> + <div class="comment__header"> + <!-- Author --> + <Avatar class="comment__avatar" + :display-name="actorDisplayName" + :user="actorId" + :size="32" /> + <span class="comment__author">{{ actorDisplayName }}</span> + + <!-- Comment actions, + show if we have a message id and current user is author --> + <Actions v-if="isOwnComment && id && !loading" class="comment__actions"> + <template v-if="!editing"> + <ActionButton + :close-after-click="true" + icon="icon-rename" + @click="onEdit"> + {{ t('comments', 'Edit comment') }} + </ActionButton> + <ActionSeparator /> + <ActionButton + :close-after-click="true" + icon="icon-delete" + @click="onDeleteWithUndo"> + {{ t('comments', 'Delete comment') }} + </ActionButton> + </template> + + <ActionButton v-else + icon="icon-close" + @click="onEditCancel"> + {{ t('comments', 'Cancel edit') }} + </ActionButton> + </Actions> + + <!-- 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 --> + <div class="comment__message" v-if="editor || editing"> + <RichContenteditable v-model="localMessage" :auto-complete="autoComplete" :contenteditable="!loading" /> + <input v-tooltip="t('comments', 'Post comment')" + :class="loading ? 'icon-loading-small' :'icon-confirm'" + class="comment__submit" + type="submit" + :disabled="isEmptyMessage" + value="" + @click="onSubmit"> + </div> + + <!-- Message content --> + <!-- The html is escaped and sanitized before rendering --> + <!-- eslint-disable-next-line vue/no-v-html--> + <div v-else class="comment__message" v-html="renderedContent" /> + </div> +</template> + +<script> +import { getCurrentUser } from '@nextcloud/auth' +import moment from 'moment' + +import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' +import Actions from '@nextcloud/vue/dist/Components/Actions' +import ActionSeparator from '@nextcloud/vue/dist/Components/ActionSeparator' +import Avatar from '@nextcloud/vue/dist/Components/Avatar' +import RichContenteditable from '@nextcloud/vue/dist/Components/RichContenteditable' +import RichEditorMixin from '@nextcloud/vue/dist/Mixins/richEditor' + +import Moment from './Moment' +import CommentMixin from '../mixins/CommentMixin' + +export default { + name: 'Comment', + + components: { + ActionButton, + Actions, + ActionSeparator, + Avatar, + Moment, + RichContenteditable, + }, + mixins: [RichEditorMixin, CommentMixin], + + inheritAttrs: false, + + props: { + source: { + type: Object, + default: () => ({}), + }, + 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, + }, + }, + + data() { + return { + // 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 + * @returns {boolean} + */ + isOwnComment() { + return getCurrentUser().uid === this.actorId + }, + + /** + * Rendered content as html string + * @returns {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() { + if (this.editor) { + this.onNewComment(this.localMessage) + return + } + this.onEditComment(this.localMessage) + }, + }, + +} +</script> + +<style lang="scss" scoped> +$comment-padding: 10px; + +.comment { + position: relative; + padding: $comment-padding 0 $comment-padding * 1.5; + + &__header { + display: flex; + align-items: center; + min-height: 44px; + padding: $comment-padding / 2 0; + } + + &__author, + &__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; + color: var(--color-text-maxcontrast); + } + + &__message { + position: relative; + // Avatar size, align with author name + padding-left: 32px + $comment-padding; + } + + &__submit { + position: absolute; + right: 0; + bottom: 0; + width: 44px; + height: 44px; + // Align with input border + margin: 1px; + cursor: pointer; + opacity: .7; + border: none; + background-color: transparent !important; + + &:disabled { + cursor: not-allowed; + opacity: .5; + } + + &:focus, + &:hover { + opacity: 1; + } + } +} + +.rich-contenteditable__input { + margin: 0; + padding: $comment-padding; + min-height: 44px; +} + +</style> diff --git a/apps/comments/src/components/Moment.vue b/apps/comments/src/components/Moment.vue new file mode 100644 index 00000000000..a91ed8b9ce6 --- /dev/null +++ b/apps/comments/src/components/Moment.vue @@ -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> diff --git a/apps/comments/src/mixins/CommentMixin.js b/apps/comments/src/mixins/CommentMixin.js new file mode 100644 index 00000000000..03f5db0846f --- /dev/null +++ b/apps/comments/src/mixins/CommentMixin.js @@ -0,0 +1,117 @@ +/** + * @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/>. + * + */ + +import NewComment from '../services/NewComment' +import DeleteComment from '../services/DeleteComment' +import EditComment from '../services/EditComment' +import { showError, showUndo, TOAST_UNDO_TIMEOUT } from '@nextcloud/dialogs' + +export default { + props: { + id: { + type: Number, + default: null, + }, + message: { + // GenFileInfo can convert message as numbers if they doesn't contains text + type: [String, Number], + default: '', + }, + ressourceId: { + type: [String, Number], + required: true, + }, + }, + + data() { + return { + deleted: false, + editing: false, + loading: false, + } + }, + + methods: { + // EDITION + onEdit() { + this.editing = true + }, + onEditCancel() { + this.editing = false + // Restore original value + this.updateLocalMessage(this.message) + }, + async onEditComment(message) { + this.loading = true + try { + await EditComment(this.commentsType, this.ressourceId, this.id, message) + this.logger.debug('Comment edited', { commentsType: this.commentsType, ressourceId: this.ressourceId, id: this.id, message }) + this.$emit('update:message', message) + this.editing = false + } catch (error) { + showError(t('comments', 'An error occurred while trying to edit the comment')) + console.error(error) + } finally { + this.loading = false + } + }, + + // DELETION + onDeleteWithUndo() { + this.deleted = true + const timeOutDelete = setTimeout(this.onDelete, TOAST_UNDO_TIMEOUT) + showUndo(t('comments', 'Comment deleted'), () => { + clearTimeout(timeOutDelete) + this.deleted = false + }) + }, + async onDelete() { + try { + await DeleteComment(this.commentsType, this.ressourceId, this.id) + this.logger.debug('Comment deleted', { commentsType: this.commentsType, ressourceId: this.ressourceId, id: this.id }) + this.$emit('delete', this.id) + } catch (error) { + showError(t('comments', 'An error occurred while trying to delete the comment')) + console.error(error) + this.deleted = false + } + }, + + // CREATION + async onNewComment(message) { + this.loading = true + try { + const newComment = await NewComment(this.commentsType, this.ressourceId, message) + this.logger.debug('New comment posted', { commentsType: this.commentsType, ressourceId: this.ressourceId, newComment }) + this.$emit('new', newComment) + // Clear old content + this.$emit('update:message', '') + this.localMessage = '' + } catch (error) { + showError(t('comments', 'An error occurred while trying to create the comment')) + console.error(error) + } finally { + this.loading = false + } + }, + }, +} diff --git a/apps/comments/src/services/CommentsInstance.js b/apps/comments/src/services/CommentsInstance.js new file mode 100644 index 00000000000..9eeea198760 --- /dev/null +++ b/apps/comments/src/services/CommentsInstance.js @@ -0,0 +1,69 @@ +/** + * @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/>. + * + */ + +import { getLoggerBuilder } from '@nextcloud/logger' +import { translate as t, translatePlural as n } from '@nextcloud/l10n' +import CommentsApp from '../views/Comments' +import Vue from 'vue' + +const logger = getLoggerBuilder() + .setApp('comments') + .detectUser() + .build() + +// Add translates functions +Vue.mixin({ + data() { + return { + logger, + } + }, + methods: { + t, + n, + }, +}) + +export default class CommentInstance { + + /** + * Initialize a new Comments instance for the desired type + * + * @param {string} commentsType the comments endpoint type + * @param {Object} options the vue options (propsData, parent, el...) + */ + constructor(commentsType = 'files', options) { + // Add comments type as a global mixin + Vue.mixin({ + data() { + return { + commentsType, + } + }, + }) + + // Init Comments component + const View = Vue.extend(CommentsApp) + return new View(options) + } + +} diff --git a/apps/comments/src/services/DavClient.js b/apps/comments/src/services/DavClient.js new file mode 100644 index 00000000000..9fc67b52c98 --- /dev/null +++ b/apps/comments/src/services/DavClient.js @@ -0,0 +1,37 @@ +/** + * @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/>. + * + */ + +import webdav from 'webdav' +import axios from '@nextcloud/axios' +import { getRootPath } from '../utils/davUtils' + +// Add this so the server knows it is an request from the browser +axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest' + +// force our axios +const patcher = webdav.getPatcher() +patcher.patch('request', axios) + +// init webdav client +const client = webdav.createClient(getRootPath()) + +export default client diff --git a/apps/comments/src/services/DeleteComment.js b/apps/comments/src/services/DeleteComment.js new file mode 100644 index 00000000000..d9954a5603e --- /dev/null +++ b/apps/comments/src/services/DeleteComment.js @@ -0,0 +1,37 @@ +/** + * @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/>. + * + */ + +import client from './DavClient' + +/** + * Delete a comment + * + * @param {string} commentsType the ressource type + * @param {number} ressourceId the ressource ID + * @param {number} commentId the comment iD + */ +export default async function(commentsType, ressourceId, commentId) { + const commentPath = ['', commentsType, ressourceId, commentId].join('/') + + // Fetch newly created comment data + await client.deleteFile(commentPath) +} diff --git a/apps/comments/src/services/EditComment.js b/apps/comments/src/services/EditComment.js new file mode 100644 index 00000000000..fd6624c7da8 --- /dev/null +++ b/apps/comments/src/services/EditComment.js @@ -0,0 +1,49 @@ +/** + * @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/>. + * + */ + +import client from './DavClient' + +/** + * Edit an existing comment + * + * @param {string} commentsType the ressource type + * @param {number} ressourceId the ressource ID + * @param {number} commentId the comment iD + * @param {string} message the message content + */ +export default async function(commentsType, ressourceId, commentId, message) { + const commentPath = ['', commentsType, ressourceId, commentId].join('/') + + return await client.customRequest(commentPath, Object.assign({ + method: 'PROPPATCH', + data: `<?xml version="1.0"?> + <d:propertyupdate + xmlns:d="DAV:" + xmlns:oc="http://owncloud.org/ns"> + <d:set> + <d:prop> + <oc:message>${message}</oc:message> + </d:prop> + </d:set> + </d:propertyupdate>`, + })) +} diff --git a/apps/comments/src/services/GetComments.js b/apps/comments/src/services/GetComments.js new file mode 100644 index 00000000000..a1ac89069ee --- /dev/null +++ b/apps/comments/src/services/GetComments.js @@ -0,0 +1,80 @@ +/** + * @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/>. + * + */ + +import { parseXML, prepareFileFromProps } from 'webdav/dist/node/interface/dav' +import { processResponsePayload } from 'webdav/dist/node/response' +import client from './DavClient' +import { genFileInfo } from '../utils/fileUtils' + +export const DEFAULT_LIMIT = 5 +/** + * Retrieve the comments list + * + * @param {Object} data destructuring object + * @param {string} data.commentsType the ressource type + * @param {number} data.ressourceId the ressource ID + * @param {Object} [options] optional options for axios + * @returns {Object[]} the comments list + */ +export default async function({ commentsType, ressourceId }, options = {}) { + let response = null + const ressourcePath = ['', commentsType, ressourceId].join('/') + + return await client.customRequest(ressourcePath, Object.assign({ + method: 'REPORT', + data: `<?xml version="1.0"?> + <oc:filter-comments + xmlns:d="DAV:" + xmlns:oc="http://owncloud.org/ns" + xmlns:nc="http://nextcloud.org/ns" + xmlns:ocs="http://open-collaboration-services.org/ns"> + <oc:limit>${DEFAULT_LIMIT}</oc:limit> + <oc:offset>${options.offset || 0}</oc:offset> + </oc:filter-comments>`, + }, options)) + // See example on how it's done normaly + // https://github.com/perry-mitchell/webdav-client/blob/9de2da4a2599e06bd86c2778145b7ade39fe0b3c/source/interface/stat.js#L19 + // Waiting for proper REPORT integration https://github.com/perry-mitchell/webdav-client/issues/207 + .then(res => { + response = res + return res.data + }) + .then(parseXML) + .then(xml => processMultistatus(xml, true)) + .then(comments => processResponsePayload(response, comments, true)) + .then(response => response.data.map(genFileInfo)) +} + +// https://github.com/perry-mitchell/webdav-client/blob/9de2da4a2599e06bd86c2778145b7ade39fe0b3c/source/interface/directoryContents.js#L32 +function processMultistatus(result, isDetailed = false) { + // Extract the response items (directory contents) + const { + multistatus: { response: responseItems }, + } = result + return responseItems.map(item => { + // Each item should contain a stat object + const { + propstat: { prop: props }, + } = item + return prepareFileFromProps(props, props.id.toString(), isDetailed) + }) +} diff --git a/apps/comments/src/services/NewComment.js b/apps/comments/src/services/NewComment.js new file mode 100644 index 00000000000..96aee85e010 --- /dev/null +++ b/apps/comments/src/services/NewComment.js @@ -0,0 +1,60 @@ +/** + * @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/>. + * + */ + +import { genFileInfo } from '../utils/fileUtils' +import { getCurrentUser } from '@nextcloud/auth' +import { getRootPath } from '../utils/davUtils' +import axios from '@nextcloud/axios' +import client from './DavClient' + +/** + * Retrieve the comments list + * + * @param {string} commentsType the ressource type + * @param {number} ressourceId the ressource ID + * @param {string} message the message + * @returns {Object} the new comment + */ +export default async function(commentsType, ressourceId, message) { + const ressourcePath = ['', commentsType, ressourceId].join('/') + + const response = await axios.post(getRootPath() + ressourcePath, { + actorDisplayName: getCurrentUser().displayName, + actorId: getCurrentUser().uid, + actorType: 'users', + creationDateTime: (new Date()).toUTCString(), + message, + objectType: 'files', + verb: 'comment', + }) + + // Retrieve comment id from ressource location + const commentId = parseInt(response.headers['content-location'].split('/').pop()) + const commentPath = ressourcePath + '/' + commentId + + // Fetch newly created comment data + const comment = await client.stat(commentPath, { + details: true, + }) + + return genFileInfo(comment) +} diff --git a/apps/comments/src/utils/cancelableRequest.js b/apps/comments/src/utils/cancelableRequest.js new file mode 100644 index 00000000000..425e94a787a --- /dev/null +++ b/apps/comments/src/utils/cancelableRequest.js @@ -0,0 +1,62 @@ +/** + * @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/>. + * + */ + +import axios from '@nextcloud/axios' + +/** + * Create a cancel token + * @returns {CancelTokenSource} + */ +const createCancelToken = () => axios.CancelToken.source() + +/** + * Creates a cancelable axios 'request object'. + * + * @param {function} request the axios promise request + * @returns {Object} + */ +const cancelableRequest = function(request) { + /** + * Generate an axios cancel token + */ + const cancelToken = createCancelToken() + + /** + * Execute the request + * + * @param {string} url the url to send the request to + * @param {Object} [options] optional config for the request + */ + const fetch = async function(url, options) { + return request( + url, + Object.assign({ cancelToken: cancelToken.token }, options) + ) + } + + return { + request: fetch, + cancel: cancelToken.cancel, + } +} + +export default cancelableRequest diff --git a/apps/comments/src/utils/davUtils.js b/apps/comments/src/utils/davUtils.js new file mode 100644 index 00000000000..b10b62e4f34 --- /dev/null +++ b/apps/comments/src/utils/davUtils.js @@ -0,0 +1,29 @@ +/** + * @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/>. + * + */ + +import { generateRemoteUrl } from '@nextcloud/router' + +const getRootPath = function() { + return generateRemoteUrl('dav/comments') +} + +export { getRootPath } diff --git a/apps/comments/src/utils/fileUtils.js b/apps/comments/src/utils/fileUtils.js new file mode 100644 index 00000000000..298732c8af0 --- /dev/null +++ b/apps/comments/src/utils/fileUtils.js @@ -0,0 +1,122 @@ +/** + * @copyright Copyright (c) 2019 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/>. + * + */ +import camelcase from 'camelcase' +import { isNumber } from './numberUtil' + +/** + * Get an url encoded path + * + * @param {String} path the full path + * @returns {string} url encoded file path + */ +const encodeFilePath = function(path) { + const pathSections = (path.startsWith('/') ? path : `/${path}`).split('/') + let relativePath = '' + pathSections.forEach((section) => { + if (section !== '') { + relativePath += '/' + encodeURIComponent(section) + } + }) + return relativePath +} + +/** + * Extract dir and name from file path + * + * @param {String} path the full path + * @returns {String[]} [dirPath, fileName] + */ +const extractFilePaths = function(path) { + const pathSections = path.split('/') + const fileName = pathSections[pathSections.length - 1] + const dirPath = pathSections.slice(0, pathSections.length - 1).join('/') + return [dirPath, fileName] +} + +/** + * Sorting comparison function + * + * @param {Object} fileInfo1 file 1 fileinfo + * @param {Object} fileInfo2 file 2 fileinfo + * @param {string} key key to sort with + * @param {boolean} [asc=true] sort ascending? + * @returns {number} + */ +const sortCompare = function(fileInfo1, fileInfo2, key, asc = true) { + + if (fileInfo1.isFavorite && !fileInfo2.isFavorite) { + return -1 + } else if (!fileInfo1.isFavorite && fileInfo2.isFavorite) { + return 1 + } + + // if this is a number, let's sort by integer + if (isNumber(fileInfo1[key]) && isNumber(fileInfo2[key])) { + return Number(fileInfo1[key]) - Number(fileInfo2[key]) + } + + // else we sort by string, so let's sort directories first + if (fileInfo1.type === 'directory' && fileInfo2.type !== 'directory') { + return -1 + } else if (fileInfo1.type !== 'directory' && fileInfo2.type === 'directory') { + return 1 + } + + // finally sort by name + return asc + ? fileInfo1[key].localeCompare(fileInfo2[key], OC.getLanguage()) + : -fileInfo1[key].localeCompare(fileInfo2[key], OC.getLanguage()) +} + +/** + * Generate a fileinfo object based on the full dav properties + * It will flatten everything and put all keys to camelCase + * + * @param {Object} obj the object + * @returns {Object} + */ +const genFileInfo = function(obj) { + const fileInfo = {} + + Object.keys(obj).forEach(key => { + const data = obj[key] + + // flatten object if any + if (!!data && typeof data === 'object' && !Array.isArray(data)) { + Object.assign(fileInfo, genFileInfo(data)) + } else { + // format key and add it to the fileInfo + if (data === 'false') { + fileInfo[camelcase(key)] = false + } else if (data === 'true') { + fileInfo[camelcase(key)] = true + } else { + fileInfo[camelcase(key)] = isNumber(data) + ? Number(data) + : data + } + } + }) + return fileInfo +} + +export { encodeFilePath, extractFilePaths, sortCompare, genFileInfo } diff --git a/apps/comments/src/utils/numberUtil.js b/apps/comments/src/utils/numberUtil.js new file mode 100644 index 00000000000..018c34c49e8 --- /dev/null +++ b/apps/comments/src/utils/numberUtil.js @@ -0,0 +1,30 @@ +/** + * @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/>. + * + */ + +const isNumber = function(num) { + if (!num) { + return false + } + return Number(num).toString() === num.toString() +} + +export { isNumber } diff --git a/apps/comments/src/views/Comments.vue b/apps/comments/src/views/Comments.vue new file mode 100644 index 00000000000..8c3ec66c323 --- /dev/null +++ b/apps/comments/src/views/Comments.vue @@ -0,0 +1,264 @@ +<!-- + - @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> + <div class="comments" :class="{ 'icon-loading': isFirstLoading }"> + <!-- Editor --> + <Comment v-bind="editorData" + :auto-complete="autoComplete" + :editor="true" + :ressource-id="ressourceId" + class="comments__writer" + @new="onNewComment" /> + + <template v-if="!isFirstLoading"> + <EmptyContent v-if="!hasComments && done" icon="icon-comment"> + {{ t('comments', 'No comments yet, start the conversation!') }} + </EmptyContent> + + <!-- Comments --> + <Comment v-for="comment in comments" + v-else + :key="comment.id" + v-bind="comment" + :auto-complete="autoComplete" + :ressource-id="ressourceId" + :message.sync="comment.message" + class="comments__list" + @delete="onDelete" /> + + <!-- Loading more message --> + <div v-if="loading && !isFirstLoading" class="comments__info icon-loading" /> + + <div v-else-if="hasComments && done" class="comments__info"> + {{ t('comments', 'No more messages') }} + </div> + + <!-- Error message --> + <EmptyContent v-else-if="error" class="comments__error" icon="icon-error"> + {{ error }} + <template #desc> + <button icon="icon-history" @click="getComments"> + {{ t('comments', 'Retry') }} + </button> + </template> + </EmptyContent> + </template> + </div> +</template> + +<script> +import { generateOcsUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import axios from '@nextcloud/axios' +import VTooltip from 'v-tooltip' +import Vue from 'vue' + +import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent' + +import Comment from '../components/Comment' +import getComments, { DEFAULT_LIMIT } from '../services/GetComments' +import cancelableRequest from '../utils/cancelableRequest' + +Vue.use(VTooltip) + +export default { + name: 'Comments', + + components: { + // Avatar, + Comment, + EmptyContent, + }, + + data() { + return { + error: '', + loading: false, + done: false, + + ressourceId: null, + offset: 0, + comments: [], + + cancelRequest: () => {}, + + editorData: { + actorDisplayName: getCurrentUser().displayName, + actorId: getCurrentUser().uid, + key: 'editor', + }, + + Comment, + } + }, + + computed: { + hasComments() { + return this.comments.length > 0 + }, + isFirstLoading() { + return this.loading && this.offset === 0 + }, + }, + + methods: { + /** + * Update current ressourceId and fetch new data + * @param {Number} ressourceId the current ressourceId (fileId...) + */ + async update(ressourceId) { + this.ressourceId = ressourceId + this.resetState() + this.getComments() + }, + + /** + * Ran when the bottom of the tab is reached + */ + onScrollBottomReached() { + /** + * Do not fetch more if we: + * - are showing an error + * - already fetched everything + * - are currently loading + */ + if (this.error || this.done || this.loading) { + return + } + this.getComments() + }, + + /** + * Get the existing shares infos + */ + async getComments() { + // Cancel any ongoing request + this.cancelRequest('cancel') + + try { + this.loading = true + this.error = '' + + // Init cancellable request + const { request, cancel } = cancelableRequest(getComments) + this.cancelRequest = cancel + + // Fetch comments + const comments = await request({ + commentsType: this.commentsType, + ressourceId: this.ressourceId, + }, { offset: this.offset }) + + this.logger.debug(`Processed ${comments.length} comments`, { comments }) + + // We received less than the requested amount, + // we're done fetching comments + if (comments.length < DEFAULT_LIMIT) { + this.done = true + } + + // Insert results + this.comments.push(...comments) + + // Increase offset for next fetch + this.offset += DEFAULT_LIMIT + } catch (error) { + if (error.message === 'cancel') { + return + } + // Reverting offset + this.error = t('comments', 'Unable to load the comments list') + console.error('Error loading the comments list', error) + } finally { + this.loading = false + } + }, + + /** + * Autocomplete @mentions + * @param {string} search the query + * @param {Function} callback the callback to process the results with + */ + async autoComplete(search, callback) { + const results = await axios.get(generateOcsUrl('core', 2) + 'autocomplete/get', { + params: { + search, + itemType: 'files', + itemId: this.ressourceId, + sorter: 'commenters|share-recipients', + limit: OC.appConfig?.comments?.maxAutoCompleteResults || 25, + }, + }) + return callback(results.data.ocs.data) + }, + + /** + * Add newly created comment to the list + * @param {Object} comment the new comment + */ + onNewComment(comment) { + this.comments.unshift(comment) + }, + + /** + * Remove deleted comment from the list + * @param {number} id the deleted comment + */ + onDelete(id) { + const index = this.comments.findIndex(comment => comment.id === id) + if (index > -1) { + this.comments.splice(index, 1) + } else { + console.error('Could not find the deleted comment in the list', id) + } + }, + + /** + * Reset the current view to its default state + */ + resetState() { + this.error = '' + this.loading = false + this.done = false + this.offset = 0 + this.comments = [] + }, + }, +} +</script> + +<style lang="scss" scoped> +.comments { + // Do not add emptycontent top margin + &__error{ + margin-top: 0; + } + + &__info { + height: 60px; + color: var(--color-text-maxcontrast); + text-align: center; + line-height: 60px; + } +} +</style> diff --git a/apps/comments/webpack.js b/apps/comments/webpack.js index 8244389aeac..b9cba1ca218 100644 --- a/apps/comments/webpack.js +++ b/apps/comments/webpack.js @@ -1,14 +1,18 @@ const path = require('path') module.exports = { - entry: path.join(__dirname, 'src', 'comments.js'), + entry: { + comments: path.join(__dirname, 'src', 'comments.js'), + 'comments-app': path.join(__dirname, 'src', 'comments-app.js'), + 'comments-tab': path.join(__dirname, 'src', 'comments-tab.js'), + }, output: { path: path.resolve(__dirname, './js'), publicPath: '/js/', - filename: 'comments.js', - jsonpFunction: 'webpackJsonpComments' + filename: '[name].js', + jsonpFunction: 'webpackJsonpComments', }, externals: { - jquery: 'jQuery' - } + jquery: 'jQuery', + }, } diff --git a/apps/files/src/components/SidebarTab.vue b/apps/files/src/components/SidebarTab.vue index 1fc93486bc0..bead5cad1ef 100644 --- a/apps/files/src/components/SidebarTab.vue +++ b/apps/files/src/components/SidebarTab.vue @@ -25,7 +25,8 @@ :id="id" ref="tab" :name="name" - :icon="icon"> + :icon="icon" + @bottomReached="onScrollBottomReached"> <!-- Fallback loading --> <EmptyContent v-if="loading" icon="icon-loading" /> @@ -83,6 +84,10 @@ export default { type: Function, required: true, }, + onScrollBottomReached: { + type: Function, + default: () => {}, + }, }, data() { @@ -120,6 +125,5 @@ export default { // unmount the tab await this.onDestroy() }, - } </script> diff --git a/apps/files/src/models/Tab.js b/apps/files/src/models/Tab.js index 2c587e5f70a..670c72e3a3a 100644 --- a/apps/files/src/models/Tab.js +++ b/apps/files/src/models/Tab.js @@ -29,6 +29,7 @@ export default class Tab { #update #destroy #enabled + #scrollBottomReached /** * Create a new tab instance @@ -41,11 +42,15 @@ export default class Tab { * @param {Function} options.update function to update the tab * @param {Function} options.destroy function to destroy the tab * @param {Function} [options.enabled] define conditions whether this tab is active. Must returns a boolean + * @param {Function} [options.scrollBottomReached] executed when the tab is scrolled to the bottom */ - constructor({ id, name, icon, mount, update, destroy, enabled } = {}) { + constructor({ id, name, icon, mount, update, destroy, enabled, scrollBottomReached } = {}) { if (enabled === undefined) { enabled = () => true } + if (scrollBottomReached === undefined) { + scrollBottomReached = () => {} + } // Sanity checks if (typeof id !== 'string' || id.trim() === '') { @@ -69,6 +74,9 @@ export default class Tab { if (typeof enabled !== 'function') { throw new Error('The enabled argument should be a function') } + if (typeof scrollBottomReached !== 'function') { + throw new Error('The scrollBottomReached argument should be a function') + } this.#id = id this.#name = name @@ -77,6 +85,7 @@ export default class Tab { this.#update = update this.#destroy = destroy this.#enabled = enabled + this.#scrollBottomReached = scrollBottomReached } @@ -108,4 +117,8 @@ export default class Tab { return this.#enabled } + get scrollBottomReached() { + return this.#scrollBottomReached + } + } diff --git a/apps/files/src/views/Sidebar.vue b/apps/files/src/views/Sidebar.vue index 664bc6f4075..0b3f2bb1741 100644 --- a/apps/files/src/views/Sidebar.vue +++ b/apps/files/src/views/Sidebar.vue @@ -69,6 +69,7 @@ :on-mount="tab.mount" :on-update="tab.update" :on-destroy="tab.destroy" + :on-scroll-bottom-reached="tab.scrollBottomReached" :file-info="fileInfo" /> </template> </AppSidebar> diff --git a/core/Controller/AutoCompleteController.php b/core/Controller/AutoCompleteController.php index 56ad21f421c..0a29d2fa157 100644 --- a/core/Controller/AutoCompleteController.php +++ b/core/Controller/AutoCompleteController.php @@ -41,18 +41,18 @@ use OCP\Share\IShare; class AutoCompleteController extends Controller { /** @var ISearch */ private $collaboratorSearch; + /** @var IManager */ private $autoCompleteManager; + /** @var IEventDispatcher */ private $dispatcher; - public function __construct( - string $appName, - IRequest $request, - ISearch $collaboratorSearch, - IManager $autoCompleteManager, - IEventDispatcher $dispatcher - ) { + public function __construct(string $appName, + IRequest $request, + ISearch $collaboratorSearch, + IManager $autoCompleteManager, + IEventDispatcher $dispatcher) { parent::__construct($appName, $request); $this->collaboratorSearch = $collaboratorSearch; @@ -114,7 +114,10 @@ class AutoCompleteController extends Controller { $output[] = [ 'id' => (string) $result['value']['shareWith'], 'label' => $result['label'], + 'icon' => $result['icon'], 'source' => $type, + 'status' => $result['status'], + 'subline' => $result['subline'] ]; } } diff --git a/lib/private/Collaboration/Collaborators/UserPlugin.php b/lib/private/Collaboration/Collaborators/UserPlugin.php index 2d21c6a16f7..0b3a279182b 100644 --- a/lib/private/Collaboration/Collaborators/UserPlugin.php +++ b/lib/private/Collaboration/Collaborators/UserPlugin.php @@ -156,6 +156,8 @@ class UserPlugin implements ISearchPlugin { } $result['exact'][] = [ 'label' => $userDisplayName, + 'subline' => $status['message'], + 'icon' => 'icon-user', 'value' => [ 'shareType' => IShare::TYPE_USER, 'shareWith' => $uid, @@ -178,6 +180,8 @@ class UserPlugin implements ISearchPlugin { if ($addToWideResults) { $result['wide'][] = [ 'label' => $userDisplayName, + 'subline' => $status['message'], + 'icon' => 'icon-user', 'value' => [ 'shareType' => IShare::TYPE_USER, 'shareWith' => $uid, @@ -217,6 +221,8 @@ class UserPlugin implements ISearchPlugin { $result['exact'][] = [ 'label' => $user->getDisplayName(), + 'icon' => 'icon-user', + 'subline' => $status['message'], 'value' => [ 'shareType' => IShare::TYPE_USER, 'shareWith' => $user->getUID(), diff --git a/package-lock.json b/package-lock.json index c60f22c98ec..15857a58db1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2354,6 +2354,11 @@ } } }, + "base-64": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/base-64/-/base-64-0.1.0.tgz", + "integrity": "sha1-eAqZyE59YAJgNhURxId2E78k9rs=" + }, "base64-js": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", @@ -2692,9 +2697,9 @@ "dev": true }, "camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.0.0.tgz", + "integrity": "sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w==" }, "camelcase-keys": { "version": "2.1.0", @@ -3159,6 +3164,12 @@ "semver": "^6.3.0" }, "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, "semver": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", @@ -4431,6 +4442,11 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, + "fast-xml-parser": { + "version": "3.17.4", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-3.17.4.tgz", + "integrity": "sha512-qudnQuyYBgnvzf5Lj/yxMcf4L9NcVWihXJg7CiU1L+oUCq8MUnFEfH2/nXR/W5uq+yvUN1h7z6s7vs2v1WkL1A==" + }, "fastparse": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz", @@ -5187,6 +5203,11 @@ "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==", "dev": true }, + "hot-patcher": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/hot-patcher/-/hot-patcher-0.5.0.tgz", + "integrity": "sha512-2Uu2W0s8+dnqXzdlg0MRsRzPoDCs1wVjOGSyMRRaMzLDX4bgHw6xDYKccsWafXPPxQpkQfEjgW6+17pwcg60bw==" + }, "html-encoding-sniffer": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", @@ -6658,6 +6679,11 @@ "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, + "nested-property": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/nested-property/-/nested-property-1.0.4.tgz", + "integrity": "sha512-6fNIumJJUyP3rkB4FyVYCYpdW+PKUCaxRWRGLLf0kv/RKoG4mbTvInedA9x3zOyuOmOkGudKuAtPSI+dnhwj2g==" + }, "nextcloud-vue-collections": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/nextcloud-vue-collections/-/nextcloud-vue-collections-0.8.1.tgz", @@ -7286,6 +7312,11 @@ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" }, + "path-posix": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/path-posix/-/path-posix-1.0.0.tgz", + "integrity": "sha1-BrJhE/Vr6rBCVFojv6iAA8ysJg8=" + }, "path-to-regexp": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", @@ -7656,6 +7687,11 @@ "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=", "dev": true }, + "querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -9340,6 +9376,15 @@ } } }, + "url-parse": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.4.7.tgz", + "integrity": "sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg==", + "requires": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "url-search-params-polyfill": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/url-search-params-polyfill/-/url-search-params-polyfill-8.1.0.tgz", @@ -9566,6 +9611,11 @@ "integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==", "dev": true }, + "vue-virtual-scroll-list": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/vue-virtual-scroll-list/-/vue-virtual-scroll-list-2.3.1.tgz", + "integrity": "sha512-2p0bvcmUIMet5tln+cOKt/XjNvgP+ebq9bBD+gquK2rivsSSAFHeqQidzMO3wPFfxWeTB1JpoSzkyL9nzZ9yfA==" + }, "vue-virtual-scroller": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/vue-virtual-scroller/-/vue-virtual-scroller-1.0.10.tgz", @@ -9759,6 +9809,54 @@ "chokidar": "^2.1.8" } }, + "webdav": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/webdav/-/webdav-3.3.0.tgz", + "integrity": "sha512-wTfLNbeK1++T1ooL/ZJaUTJGb5NUuO4zAwuTShNPbzN0mRMRIaoZYG7sI5TtyH1uqOPIOW5ZGTtZiBypLG86KQ==", + "requires": { + "axios": "^0.19.2", + "base-64": "^0.1.0", + "fast-xml-parser": "^3.16.0", + "he": "^1.2.0", + "hot-patcher": "^0.5.0", + "minimatch": "^3.0.4", + "nested-property": "^1.0.4", + "path-posix": "^1.0.0", + "url-join": "^4.0.1", + "url-parse": "^1.4.7" + }, + "dependencies": { + "axios": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.19.2.tgz", + "integrity": "sha512-fjgm5MvRHLhx+osE2xoekY70AhARk3a6hkN+3Io1jc00jtquGvxYlKlsFUhmUET0V5te6CcZI7lcv2Ym61mjHA==", + "requires": { + "follow-redirects": "1.5.10" + } + }, + "debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "requires": { + "ms": "2.0.0" + } + }, + "follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "requires": { + "debug": "=3.1.0" + } + }, + "url-join": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz", + "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==" + } + } + }, "webidl-conversions": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", @@ -10066,6 +10164,13 @@ "requires": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" + }, + "dependencies": { + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" + } } }, "yargs-unparser": { diff --git a/package.json b/package.json index 291fafe8d28..ebecf31559e 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "backbone": "^1.4.0", "blueimp-md5": "^2.18.0", "bootstrap": "^4.5.2", + "camelcase": "^6.0.0", "clipboard": "^2.0.6", "core-js": "^3.6.5", "css-vars-ponyfill": "^2.3.2", @@ -85,7 +86,8 @@ "vue-router": "^3.4.7", "vuedraggable": "^2.24.2", "vuex": "^3.5.1", - "vuex-router-sync": "^5.0.0" + "vuex-router-sync": "^5.0.0", + "webdav": "^3.3.0" }, "devDependencies": { "@babel/core": "^7.11.6", |