diff options
Diffstat (limited to 'apps/comments/src')
33 files changed, 1163 insertions, 1037 deletions
diff --git a/apps/comments/src/actions/inlineUnreadCommentsAction.spec.ts b/apps/comments/src/actions/inlineUnreadCommentsAction.spec.ts new file mode 100644 index 00000000000..e8020f1f029 --- /dev/null +++ b/apps/comments/src/actions/inlineUnreadCommentsAction.spec.ts @@ -0,0 +1,179 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { File, Permission, View, FileAction } from '@nextcloud/files' +import { describe, expect, test, vi } from 'vitest' + +import { action } from './inlineUnreadCommentsAction' +import logger from '../logger' + +const view = { + id: 'files', + name: 'Files', +} as View + +describe('Inline unread comments action display name tests', () => { + test('Default values', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + 'comments-unread': 1, + }, + }) + + expect(action).toBeInstanceOf(FileAction) + expect(action.id).toBe('comments-unread') + expect(action.displayName([file], view)).toBe('') + expect(action.title!([file], view)).toBe('1 new comment') + expect(action.iconSvgInline([], view)).toMatch(/<svg.+<\/svg>/) + expect(action.enabled!([file], view)).toBe(true) + expect(action.inline!(file, view)).toBe(true) + expect(action.default).toBeUndefined() + expect(action.order).toBe(-140) + }) + + test('Display name when file has two new comments', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + 'comments-unread': 2, + }, + }) + + expect(action.displayName([file], view)).toBe('') + expect(action.title!([file], view)).toBe('2 new comments') + }) +}) + +describe('Inline unread comments action enabled tests', () => { + test('Action is disabled when comments-unread attribute is missing', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { }, + }) + + expect(action.enabled!([file], view)).toBe(false) + }) + + test('Action is disabled when file does not have unread comments', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + 'comments-unread': 0, + }, + }) + + expect(action.enabled!([file], view)).toBe(false) + }) + + test('Action is enabled when file has a single unread comment', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + 'comments-unread': 1, + }, + }) + + expect(action.enabled!([file], view)).toBe(true) + }) + + test('Action is enabled when file has a two unread comments', () => { + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + 'comments-unread': 2, + }, + }) + + expect(action.enabled!([file], view)).toBe(true) + }) +}) + +describe('Inline unread comments action execute tests', () => { + test('Action opens sidebar', async () => { + const openMock = vi.fn() + const setActiveTabMock = vi.fn() + window.OCA = { + Files: { + Sidebar: { + open: openMock, + setActiveTab: setActiveTabMock, + }, + }, + } + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + 'comments-unread': 1, + }, + }) + + const result = await action.exec!(file, view, '/') + + expect(result).toBe(null) + expect(setActiveTabMock).toBeCalledWith('comments') + expect(openMock).toBeCalledWith('/foobar.txt') + }) + + test('Action handles sidebar open failure', async () => { + const openMock = vi.fn(() => { throw new Error('Mock error') }) + const setActiveTabMock = vi.fn() + window.OCA = { + Files: { + Sidebar: { + open: openMock, + setActiveTab: setActiveTabMock, + }, + }, + } + vi.spyOn(logger, 'error').mockImplementation(() => vi.fn()) + + const file = new File({ + id: 1, + source: 'https://cloud.domain.com/remote.php/dav/files/admin/foobar.txt', + owner: 'admin', + mime: 'text/plain', + permissions: Permission.ALL, + attributes: { + 'comments-unread': 1, + }, + }) + + const result = await action.exec!(file, view, '/') + + expect(result).toBe(false) + expect(setActiveTabMock).toBeCalledWith('comments') + expect(openMock).toBeCalledWith('/foobar.txt') + expect(logger.error).toBeCalledTimes(1) + }) +}) diff --git a/apps/comments/src/actions/inlineUnreadCommentsAction.ts b/apps/comments/src/actions/inlineUnreadCommentsAction.ts new file mode 100644 index 00000000000..0afd93d7606 --- /dev/null +++ b/apps/comments/src/actions/inlineUnreadCommentsAction.ts @@ -0,0 +1,46 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { FileAction, Node } from '@nextcloud/files' +import { translate as t, translatePlural as n } from '@nextcloud/l10n' +import CommentProcessingSvg from '@mdi/svg/svg/comment-processing.svg?raw' + +import logger from '../logger' + +export const action = new FileAction({ + id: 'comments-unread', + + title(nodes: Node[]) { + const unread = nodes[0].attributes['comments-unread'] as number + if (unread >= 0) { + return n('comments', '1 new comment', '{unread} new comments', unread, { unread }) + } + return t('comments', 'Comment') + }, + + // Empty string when rendered inline + displayName: () => '', + + iconSvgInline: () => CommentProcessingSvg, + + enabled(nodes: Node[]) { + const unread = nodes[0].attributes['comments-unread'] as number|undefined + return typeof unread === 'number' && unread > 0 + }, + + async exec(node: Node) { + try { + window.OCA.Files.Sidebar.setActiveTab('comments') + await window.OCA.Files.Sidebar.open(node.path) + return null + } catch (error) { + logger.error('Error while opening sidebar', { error }) + return false + } + }, + + inline: () => true, + + order: -140, +}) diff --git a/apps/comments/src/activitytabviewplugin.js b/apps/comments/src/activitytabviewplugin.js deleted file mode 100644 index c01cdb8146b..00000000000 --- a/apps/comments/src/activitytabviewplugin.js +++ /dev/null @@ -1,58 +0,0 @@ -/** - * @author Joas Schilling <coding@schilljs.com> - * Copyright (c) 2016 - * - * This file is licensed under the Affero General Public License version 3 - * or later. - * - * See the COPYING-README file. - */ - -(function() { - OCA.Comments.ActivityTabViewPlugin = { - - /** - * Prepare activity for display - * - * @param {OCA.Activity.ActivityModel} model for this activity - * @param {jQuery} $el jQuery handle for this activity - * @param {string} view The view that displayes this activity - */ - prepareModelForDisplay(model, $el, view) { - if (model.get('app') !== 'comments' || model.get('type') !== 'comments') { - return - } - - if (view === 'ActivityTabView') { - $el.addClass('comment') - if (model.get('message') && this._isLong(model.get('message'))) { - $el.addClass('collapsed') - const $overlay = $('<div>').addClass('message-overlay') - $el.find('.activitymessage').after($overlay) - $el.on('click', this._onClickCollapsedComment) - } - } - }, - - /* - * Copy of CommentsTabView._onClickComment() - */ - _onClickCollapsedComment(ev) { - let $row = $(ev.target) - if (!$row.is('.comment')) { - $row = $row.closest('.comment') - } - $row.removeClass('collapsed') - }, - - /* - * Copy of CommentsTabView._isLong() - */ - _isLong(message) { - return message.length > 250 || (message.match(/\n/g) || []).length > 1 - }, - } - -})() - -OC.Plugins.register('OCA.Activity.RenderingPlugins', OCA.Comments.ActivityTabViewPlugin) diff --git a/apps/comments/src/app.js b/apps/comments/src/app.js deleted file mode 100644 index 626d7703a3e..00000000000 --- a/apps/comments/src/app.js +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright (c) 2016 Vincent Petry <pvince81@owncloud.com> - * - * This file is licensed under the Affero General Public License version 3 - * or later. - * - * See the COPYING-README file. - * - */ - -(function() { - if (!OCA.Comments) { - /** - * @namespace - */ - OCA.Comments = {} - } - -})() diff --git a/apps/comments/src/comments-activity-tab.ts b/apps/comments/src/comments-activity-tab.ts new file mode 100644 index 00000000000..77f6c9bca04 --- /dev/null +++ b/apps/comments/src/comments-activity-tab.ts @@ -0,0 +1,78 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import moment from '@nextcloud/moment' +import Vue, { type ComponentPublicInstance } from 'vue' +import logger from './logger.js' +import { getComments } from './services/GetComments.js' + +import { PiniaVuePlugin, createPinia } from 'pinia' + +Vue.use(PiniaVuePlugin) + +let ActivityTabPluginView +let ActivityTabPluginInstance + +/** + * Register the comments plugins for the Activity sidebar + */ +export function registerCommentsPlugins() { + window.OCA.Activity.registerSidebarAction({ + mount: async (el, { fileInfo, reload }) => { + const pinia = createPinia() + + if (!ActivityTabPluginView) { + const { default: ActivityCommentAction } = await import('./views/ActivityCommentAction.vue') + // @ts-expect-error Types are broken for Vue2 + ActivityTabPluginView = Vue.extend(ActivityCommentAction) + } + ActivityTabPluginInstance = new ActivityTabPluginView({ + el, + pinia, + propsData: { + reloadCallback: reload, + resourceId: fileInfo.id, + }, + }) + logger.info('Comments plugin mounted in Activity sidebar action', { fileInfo }) + }, + unmount: () => { + // destroy previous instance if available + if (ActivityTabPluginInstance) { + ActivityTabPluginInstance.$destroy() + } + }, + }) + + window.OCA.Activity.registerSidebarEntries(async ({ fileInfo, limit, offset }) => { + const { data: comments } = await getComments({ resourceType: 'files', resourceId: fileInfo.id }, { limit, offset }) + logger.debug('Loaded comments', { fileInfo, comments }) + const { default: CommentView } = await import('./views/ActivityCommentEntry.vue') + // @ts-expect-error Types are broken for Vue2 + const CommentsViewObject = Vue.extend(CommentView) + + return comments.map((comment) => ({ + _CommentsViewInstance: undefined as ComponentPublicInstance | undefined, + + timestamp: moment(comment.props?.creationDateTime).toDate().getTime(), + + mount(element: HTMLElement, { reload }) { + this._CommentsViewInstance = new CommentsViewObject({ + el: element, + propsData: { + comment, + resourceId: fileInfo.id, + reloadCallback: reload, + }, + }) + }, + unmount() { + this._CommentsViewInstance?.$destroy() + }, + })) + }) + + window.OCA.Activity.registerSidebarFilter((activity) => activity.type !== 'comments') + logger.info('Comments plugin registered for Activity sidebar action') +} diff --git a/apps/comments/src/comments-app.js b/apps/comments/src/comments-app.js index ced5577d5f8..a91a4bb37bb 100644 --- a/apps/comments/src/comments-app.js +++ b/apps/comments/src/comments-app.js @@ -1,26 +1,9 @@ /** - * @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/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import CommentsInstance from './services/CommentsInstance' +import CommentsInstance from './services/CommentsInstance.js' // Init Comments if (window.OCA && !window.OCA.Comments) { diff --git a/apps/comments/src/comments-tab.js b/apps/comments/src/comments-tab.js index 50126dc8d2d..d3ebe3e9596 100644 --- a/apps/comments/src/comments-tab.js +++ b/apps/comments/src/comments-tab.js @@ -1,58 +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/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -// Init Comments tab component -let TabInstance = null -const commentTab = new OCA.Files.Sidebar.Tab({ - id: 'comments', - name: t('comments', 'Comments'), - icon: 'icon-comment', +// eslint-disable-next-line n/no-missing-import, import/no-unresolved +import MessageReplyText from '@mdi/svg/svg/message-reply-text.svg?raw' +import { getCSPNonce } from '@nextcloud/auth' +import { loadState } from '@nextcloud/initial-state' +import { registerCommentsPlugins } from './comments-activity-tab.ts' - async mount(el, fileInfo, context) { - if (TabInstance) { +// @ts-expect-error __webpack_nonce__ is injected by webpack +__webpack_nonce__ = getCSPNonce() + +if (loadState('comments', 'activityEnabled', false) && OCA?.Activity?.registerSidebarAction !== undefined) { + // Do not mount own tab but mount into activity + window.addEventListener('DOMContentLoaded', function() { + registerCommentsPlugins() + }) +} else { + // Init Comments tab component + let TabInstance = null + const commentTab = new OCA.Files.Sidebar.Tab({ + id: 'comments', + name: t('comments', 'Comments'), + iconSvg: MessageReplyText, + + async mount(el, fileInfo, context) { + if (TabInstance) { + TabInstance.$destroy() + } + TabInstance = new OCA.Comments.View('files', { + // Better integration with vue parent component + parent: context, + propsData: { + resourceId: fileInfo.id, + }, + }) + // 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 = 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() - }, -}) + TabInstance = null + }, + scrollBottomReached() { + TabInstance.onScrollBottomReached() + }, + }) -window.addEventListener('DOMContentLoaded', function() { - if (OCA.Files && OCA.Files.Sidebar) { - OCA.Files.Sidebar.registerTab(commentTab) - } -}) + window.addEventListener('DOMContentLoaded', function() { + if (OCA.Files && OCA.Files.Sidebar) { + OCA.Files.Sidebar.registerTab(commentTab) + } + }) +} diff --git a/apps/comments/src/comments.js b/apps/comments/src/comments.js deleted file mode 100644 index 3d7f52f4e74..00000000000 --- a/apps/comments/src/comments.js +++ /dev/null @@ -1,6 +0,0 @@ -import './app' -import './templates' -import './filesplugin' -import './activitytabviewplugin' - -window.OCA.Comments = OCA.Comments diff --git a/apps/comments/src/components/Comment.vue b/apps/comments/src/components/Comment.vue index fb1f073479c..80f035530fb 100644 --- a/apps/comments/src/components/Comment.vue +++ b/apps/comments/src/components/Comment.vue @@ -1,123 +1,150 @@ <!-- - - @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/>. - - - --> + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <div v-show="!deleted" + <component :is="tag" + v-show="!deleted && !isLimbo" :class="{'comment--loading': loading}" class="comment"> <!-- Comment header toolbar --> - <div class="comment__header"> + <div class="comment__side"> <!-- Author --> - <Avatar class="comment__avatar" + <NcAvatar 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__editor " v-if="editor || editing"> - <RichContenteditable ref="editor" - v-model="localMessage" - :auto-complete="autoComplete" - :contenteditable="!loading" - @submit="onSubmit" /> - <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 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> - - <!-- Message content --> - <!-- The html is escaped and sanitized before rendering --> - <!-- eslint-disable-next-line vue/no-v-html--> - <div v-else - :class="{'comment__message--expanded': expanded}" - class="comment__message" - @click="onExpand" - v-html="renderedContent" /> - </div> + </component> </template> <script> import { getCurrentUser } from '@nextcloud/auth' -import moment from '@nextcloud/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' +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: { - ActionButton, - Actions, - ActionSeparator, - Avatar, - Moment, - RichContenteditable, + IconArrowRight, + IconClose, + IconTrashCanOutline, + IconPencilOutline, + NcActionButton, + NcActions, + NcActionSeparator, + NcAvatar, + NcButton, + NcDateTime, + NcLoadingIcon, + NcRichContenteditable, + NcRichText, }, - mixins: [RichEditorMixin, CommentMixin], + mixins: [CommentMixin], inheritAttrs: false, @@ -150,6 +177,15 @@ export default { type: Function, required: true, }, + userData: { + type: Object, + default: () => ({}), + }, + + tag: { + type: String, + default: 'div', + }, }, data() { @@ -158,37 +194,56 @@ export default { // 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 - * @returns {boolean} + * + * @return {boolean} */ isOwnComment() { return getCurrentUser().uid === this.actorId }, - /** - * Rendered content as html string - * @returns {string} - */ - renderedContent() { - if (this.isEmptyMessage) { - return '' - } - return this.renderContent(this.localMessage) + 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() { - // seconds, not milliseconds - return parseInt(moment(this.creationDateTime).format('x'), 10) / 1000 + return Date.parse(this.creationDateTime) + }, + + isLimbo() { + return this.deletedCommentLimboStore.checkForId(this.id) }, }, @@ -205,12 +260,16 @@ export default { }, methods: { + t, + /** * Update local Message on outer change + * * @param {string} message the message to set */ updateLocalMessage(message) { this.localMessage = message.toString() + this.submitted = false }, /** @@ -242,22 +301,35 @@ export default { </script> <style lang="scss" scoped> +@use "sass:math"; + $comment-padding: 10px; .comment { - position: relative; - padding: $comment-padding 0 $comment-padding * 1.5; + 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; - padding: $comment-padding / 2 0; } - &__author, &__actions { - margin-left: $comment-padding !important; + margin-inline-start: $comment-padding !important; } &__author { @@ -269,46 +341,33 @@ $comment-padding: 10px; &_loading, &__timestamp { - margin-left: auto; + margin-inline-start: auto; + text-align: end; + white-space: nowrap; color: var(--color-text-maxcontrast); } - &__editor, - &__message { + &__editor-group { 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; - } + &__editor-description { + color: var(--color-text-maxcontrast); + padding-block: var(--default-grid-baseline); + } - &:focus, - &:hover { - opacity: 1; - } + &__submit { + position: absolute !important; + bottom: 5px; + inset-inline-end: 0; } &__message { white-space: pre-wrap; - word-break: break-word; + word-break: normal; max-height: 70px; overflow: hidden; + margin-top: -6px; &--expanded { max-height: none; overflow: visible; diff --git a/apps/comments/src/components/Moment.vue b/apps/comments/src/components/Moment.vue deleted file mode 100644 index a91ed8b9ce6..00000000000 --- a/apps/comments/src/components/Moment.vue +++ /dev/null @@ -1,31 +0,0 @@ -<!-- 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/filesplugin.js b/apps/comments/src/filesplugin.js deleted file mode 100644 index dc06cadcd84..00000000000 --- a/apps/comments/src/filesplugin.js +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright (c) 2016 Vincent Petry <pvince81@owncloud.com> - * - * This file is licensed under the Affero General Public License version 3 - * or later. - * - * See the COPYING-README file. - * - */ - -(function() { - - _.extend(OC.Files.Client, { - PROPERTY_COMMENTS_UNREAD: '{' + OC.Files.Client.NS_OWNCLOUD + '}comments-unread', - }) - - OCA.Comments = _.extend({}, OCA.Comments) - if (!OCA.Comments) { - /** - * @namespace - */ - OCA.Comments = {} - } - - /** - * @namespace - */ - OCA.Comments.FilesPlugin = { - ignoreLists: [ - 'trashbin', - 'files.public', - ], - - _formatCommentCount(count) { - return OCA.Comments.Templates.filesplugin({ - count, - countMessage: n('comments', '%n unread comment', '%n unread comments', count), - iconUrl: OC.imagePath('core', 'actions/comment'), - }) - }, - - attach(fileList) { - const self = this - if (this.ignoreLists.indexOf(fileList.id) >= 0) { - return - } - - const oldGetWebdavProperties = fileList._getWebdavProperties - fileList._getWebdavProperties = function() { - const props = oldGetWebdavProperties.apply(this, arguments) - props.push(OC.Files.Client.PROPERTY_COMMENTS_UNREAD) - return props - } - - fileList.filesClient.addFileInfoParser(function(response) { - const data = {} - const props = response.propStat[0].properties - const commentsUnread = props[OC.Files.Client.PROPERTY_COMMENTS_UNREAD] - if (!_.isUndefined(commentsUnread) && commentsUnread !== '') { - data.commentsUnread = parseInt(commentsUnread, 10) - } - return data - }) - - fileList.$el.addClass('has-comments') - const oldCreateRow = fileList._createRow - fileList._createRow = function(fileData) { - const $tr = oldCreateRow.apply(this, arguments) - if (fileData.commentsUnread) { - $tr.attr('data-comments-unread', fileData.commentsUnread) - } - return $tr - } - - // register "comment" action for reading comments - fileList.fileActions.registerAction({ - name: 'Comment', - displayName(context) { - if (context && context.$file) { - const unread = parseInt(context.$file.data('comments-unread'), 10) - if (unread >= 0) { - return n('comments', '1 new comment', '{unread} new comments', unread, { unread }) - } - } - return t('comments', 'Comment') - }, - mime: 'all', - order: -140, - iconClass: 'icon-comment', - permissions: OC.PERMISSION_READ, - type: OCA.Files.FileActions.TYPE_INLINE, - render(actionSpec, isDefault, context) { - const $file = context.$file - const unreadComments = $file.data('comments-unread') - if (unreadComments) { - const $actionLink = $(self._formatCommentCount(unreadComments)) - context.$file.find('a.name>span.fileactions').append($actionLink) - return $actionLink - } - return '' - }, - actionHandler(fileName, context) { - context.$file.find('.action-comment').tooltip('hide') - // open sidebar in comments section - OCA.Files.Sidebar.setActiveTab('comments') - OCA.Files.Sidebar.open('/' + fileName) - }, - }) - - // add attribute to "elementToFile" - const oldElementToFile = fileList.elementToFile - fileList.elementToFile = function($el) { - const fileInfo = oldElementToFile.apply(this, arguments) - const commentsUnread = $el.data('comments-unread') - if (commentsUnread) { - fileInfo.commentsUnread = commentsUnread - } - return fileInfo - } - }, - } - -})() - -OC.Plugins.register('OCA.Files.FileList', OCA.Comments.FilesPlugin) diff --git a/apps/comments/src/init.ts b/apps/comments/src/init.ts new file mode 100644 index 00000000000..675274b1b40 --- /dev/null +++ b/apps/comments/src/init.ts @@ -0,0 +1,8 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { registerFileAction } from '@nextcloud/files' +import { action } from './actions/inlineUnreadCommentsAction' + +registerFileAction(action) diff --git a/apps/comments/src/logger.js b/apps/comments/src/logger.js new file mode 100644 index 00000000000..a51bc6d750b --- /dev/null +++ b/apps/comments/src/logger.js @@ -0,0 +1,11 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { getLoggerBuilder } from '@nextcloud/logger' + +export default getLoggerBuilder() + .setApp('comments') + .detectUser() + .build() diff --git a/apps/comments/src/mixins/CommentMixin.js b/apps/comments/src/mixins/CommentMixin.js index 7b6e7a9b0a0..722ad3444ce 100644 --- a/apps/comments/src/mixins/CommentMixin.js +++ b/apps/comments/src/mixins/CommentMixin.js @@ -1,29 +1,15 @@ /** - * @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/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import NewComment from '../services/NewComment' -import DeleteComment from '../services/DeleteComment' -import EditComment from '../services/EditComment' import { showError, showUndo, TOAST_UNDO_TIMEOUT } from '@nextcloud/dialogs' +import NewComment from '../services/NewComment.js' +import DeleteComment from '../services/DeleteComment.js' +import EditComment from '../services/EditComment.js' +import { mapStores } from 'pinia' +import { useDeletedCommentLimbo } from '../store/deletedCommentLimbo.js' +import logger from '../logger.js' export default { props: { @@ -35,10 +21,14 @@ export default { type: String, default: '', }, - ressourceId: { + resourceId: { type: [String, Number], required: true, }, + resourceType: { + type: String, + default: 'files', + }, }, data() { @@ -49,6 +39,10 @@ export default { } }, + computed: { + ...mapStores(useDeletedCommentLimbo), + }, + methods: { // EDITION onEdit() { @@ -62,8 +56,8 @@ export default { 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 }) + await EditComment(this.resourceType, this.resourceId, this.id, message) + logger.debug('Comment edited', { resourceType: this.resourceType, resourceId: this.resourceId, id: this.id, message }) this.$emit('update:message', message) this.editing = false } catch (error) { @@ -76,22 +70,26 @@ export default { // DELETION onDeleteWithUndo() { + this.$emit('delete') this.deleted = true + this.deletedCommentLimboStore.addId(this.id) const timeOutDelete = setTimeout(this.onDelete, TOAST_UNDO_TIMEOUT) showUndo(t('comments', 'Comment deleted'), () => { clearTimeout(timeOutDelete) this.deleted = false + this.deletedCommentLimboStore.removeId(this.id) }) }, 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 }) + await DeleteComment(this.resourceType, this.resourceId, this.id) + logger.debug('Comment deleted', { resourceType: this.resourceType, resourceId: this.resourceId, 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 + this.deletedCommentLimboStore.removeId(this.id) } }, @@ -99,8 +97,8 @@ export default { 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 }) + const newComment = await NewComment(this.resourceType, this.resourceId, message) + logger.debug('New comment posted', { resourceType: this.resourceType, resourceId: this.resourceId, newComment }) this.$emit('new', newComment) // Clear old content diff --git a/apps/comments/src/mixins/CommentView.ts b/apps/comments/src/mixins/CommentView.ts new file mode 100644 index 00000000000..c6cb3aa9ee0 --- /dev/null +++ b/apps/comments/src/mixins/CommentView.ts @@ -0,0 +1,76 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import axios from '@nextcloud/axios' +import { getCurrentUser } from '@nextcloud/auth' +import { loadState } from '@nextcloud/initial-state' +import { generateOcsUrl } from '@nextcloud/router' +import { defineComponent } from 'vue' + +export default defineComponent({ + props: { + resourceId: { + type: Number, + required: true, + }, + resourceType: { + type: String, + default: 'files', + }, + }, + data() { + return { + editorData: { + actorDisplayName: getCurrentUser()!.displayName as string, + actorId: getCurrentUser()!.uid as string, + key: 'editor', + }, + userData: {}, + } + }, + methods: { + /** + * Autocomplete @mentions + * + * @param {string} search the query + * @param {Function} callback the callback to process the results with + */ + async autoComplete(search, callback) { + const { data } = await axios.get(generateOcsUrl('core/autocomplete/get'), { + params: { + search, + itemType: 'files', + itemId: this.resourceId, + sorter: 'commenters|share-recipients', + limit: loadState('comments', 'maxAutoCompleteResults'), + }, + }) + // Save user data so it can be used by the editor to replace mentions + data.ocs.data.forEach(user => { this.userData[user.id] = user }) + return callback(Object.values(this.userData)) + }, + + /** + * Make sure we have all mentions as Array of objects + * + * @param mentions the mentions list + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + genMentionsData(mentions: any[]): Record<string, object> { + Object.values(mentions) + .flat() + .forEach(mention => { + this.userData[mention.mentionId] = { + // TODO: support groups + icon: 'icon-user', + id: mention.mentionId, + label: mention.mentionDisplayName, + source: 'users', + primary: getCurrentUser()?.uid === mention.mentionId, + } + }) + return this.userData + }, + }, +}) diff --git a/apps/comments/src/services/CommentsInstance.js b/apps/comments/src/services/CommentsInstance.js index 9eeea198760..cc45d0cbea7 100644 --- a/apps/comments/src/services/CommentsInstance.js +++ b/apps/comments/src/services/CommentsInstance.js @@ -1,34 +1,18 @@ /** - * @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/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { getLoggerBuilder } from '@nextcloud/logger' -import { translate as t, translatePlural as n } from '@nextcloud/l10n' -import CommentsApp from '../views/Comments' +import { getCSPNonce } from '@nextcloud/auth' +import { t, n } from '@nextcloud/l10n' +import { PiniaVuePlugin, createPinia } from 'pinia' import Vue from 'vue' +import CommentsApp from '../views/Comments.vue' +import logger from '../logger.js' -const logger = getLoggerBuilder() - .setApp('comments') - .detectUser() - .build() +Vue.use(PiniaVuePlugin) +// eslint-disable-next-line camelcase +__webpack_nonce__ = getCSPNonce() // Add translates functions Vue.mixin({ @@ -48,19 +32,21 @@ 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...) + * @param {string} resourceType 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, - } - }, - }) + constructor(resourceType = 'files', options = {}) { + const pinia = createPinia() + // Merge options and set `resourceType` property + options = { + ...options, + propsData: { + ...(options.propsData ?? {}), + resourceType, + }, + pinia, + } // 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 index 9fc67b52c98..3e9a529283f 100644 --- a/apps/comments/src/services/DavClient.js +++ b/apps/comments/src/services/DavClient.js @@ -1,37 +1,27 @@ /** - * @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/>. - * + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import webdav from 'webdav' -import axios from '@nextcloud/axios' -import { getRootPath } from '../utils/davUtils' +import { createClient } from 'webdav' +import { getRootPath } from '../utils/davUtils.js' +import { getRequestToken, onRequestTokenUpdate } from '@nextcloud/auth' -// Add this so the server knows it is an request from the browser -axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest' +// init webdav client +const client = createClient(getRootPath()) -// force our axios -const patcher = webdav.getPatcher() -patcher.patch('request', axios) +// set CSRF token header +const setHeaders = (token) => { + client.setHeaders({ + // Add this so the server knows it is an request from the browser + 'X-Requested-With': 'XMLHttpRequest', + // Inject user auth + requesttoken: token ?? '', + }) +} -// init webdav client -const client = webdav.createClient(getRootPath()) +// refresh headers when request token changes +onRequestTokenUpdate(setHeaders) +setHeaders(getRequestToken()) export default client diff --git a/apps/comments/src/services/DeleteComment.js b/apps/comments/src/services/DeleteComment.js index d9954a5603e..1ed63d7836a 100644 --- a/apps/comments/src/services/DeleteComment.js +++ b/apps/comments/src/services/DeleteComment.js @@ -1,36 +1,19 @@ /** - * @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/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import client from './DavClient' +import client from './DavClient.js' /** * Delete a comment * - * @param {string} commentsType the ressource type - * @param {number} ressourceId the ressource ID + * @param {string} resourceType the resource type + * @param {number} resourceId the resource ID * @param {number} commentId the comment iD */ -export default async function(commentsType, ressourceId, commentId) { - const commentPath = ['', commentsType, ressourceId, commentId].join('/') +export default async function(resourceType, resourceId, commentId) { + const commentPath = ['', resourceType, resourceId, 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 index fd6624c7da8..4ec33415a72 100644 --- a/apps/comments/src/services/EditComment.js +++ b/apps/comments/src/services/EditComment.js @@ -1,37 +1,20 @@ /** - * @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/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -import client from './DavClient' +import client from './DavClient.js' /** * Edit an existing comment * - * @param {string} commentsType the ressource type - * @param {number} ressourceId the ressource ID + * @param {string} resourceType the resource type + * @param {number} resourceId the resource 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('/') +export default async function(resourceType, resourceId, commentId, message) { + const commentPath = ['', resourceType, resourceId, commentId].join('/') return await client.customRequest(commentPath, Object.assign({ method: 'PROPPATCH', diff --git a/apps/comments/src/services/GetComments.js b/apps/comments/src/services/GetComments.js deleted file mode 100644 index 4d2c4d21425..00000000000 --- a/apps/comments/src/services/GetComments.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * @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' - -export const DEFAULT_LIMIT = 20 -/** - * 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) -} - -// 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/GetComments.ts b/apps/comments/src/services/GetComments.ts new file mode 100644 index 00000000000..c42aa21d6cb --- /dev/null +++ b/apps/comments/src/services/GetComments.ts @@ -0,0 +1,67 @@ +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { parseXML, type DAVResult, type FileStat, type ResponseDataDetailed } from 'webdav' + +// https://github.com/perry-mitchell/webdav-client/issues/339 +import { processResponsePayload } from 'webdav/dist/node/response.js' +import { prepareFileFromProps } from 'webdav/dist/node/tools/dav.js' +import client from './DavClient.js' + +export const DEFAULT_LIMIT = 20 + +/** + * Retrieve the comments list + * + * @param {object} data destructuring object + * @param {string} data.resourceType the resource type + * @param {number} data.resourceId the resource ID + * @param {object} [options] optional options for axios + * @param {number} [options.offset] the pagination offset + * @param {number} [options.limit] the pagination limit, defaults to 20 + * @param {Date} [options.datetime] optional date to query + * @return {{data: object[]}} the comments list + */ +export const getComments = async function({ resourceType, resourceId }, options: { offset: number, limit?: number, datetime?: Date }) { + const resourcePath = ['', resourceType, resourceId].join('/') + const datetime = options.datetime ? `<oc:datetime>${options.datetime.toISOString()}</oc:datetime>` : '' + const response = await client.customRequest(resourcePath, 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>${options.limit ?? DEFAULT_LIMIT}</oc:limit> + <oc:offset>${options.offset || 0}</oc:offset> + ${datetime} + </oc:filter-comments>`, + }, options)) + + const responseData = await response.text() + const result = await parseXML(responseData) + const stat = getDirectoryFiles(result, true) + return processResponsePayload(response, stat, true) as ResponseDataDetailed<FileStat[]> +} + +// https://github.com/perry-mitchell/webdav-client/blob/8d9694613c978ce7404e26a401c39a41f125f87f/source/operations/directoryContents.ts +const getDirectoryFiles = function( + result: DAVResult, + isDetailed = false, +): Array<FileStat> { + // Extract the response items (directory contents) + const { + multistatus: { response: responseItems }, + } = result + + // Map all items to a consistent output structure (results) + return responseItems.map(item => { + // Each item should contain a stat object + const props = item.propstat!.prop! + + return prepareFileFromProps(props, props.id!.toString(), isDetailed) + }) +} diff --git a/apps/comments/src/services/NewComment.js b/apps/comments/src/services/NewComment.js index 5bf200d1c8e..663b4d72e02 100644 --- a/apps/comments/src/services/NewComment.js +++ b/apps/comments/src/services/NewComment.js @@ -1,59 +1,50 @@ /** - * @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/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { getCurrentUser } from '@nextcloud/auth' -import { getRootPath } from '../utils/davUtils' +import { getRootPath } from '../utils/davUtils.js' +import { decodeHtmlEntities } from '../utils/decodeHtmlEntities.js' import axios from '@nextcloud/axios' -import client from './DavClient' +import client from './DavClient.js' /** * Retrieve the comments list * - * @param {string} commentsType the ressource type - * @param {number} ressourceId the ressource ID + * @param {string} resourceType the resource type + * @param {number} resourceId the resource ID * @param {string} message the message - * @returns {Object} the new comment + * @return {object} the new comment */ -export default async function(commentsType, ressourceId, message) { - const ressourcePath = ['', commentsType, ressourceId].join('/') +export default async function(resourceType, resourceId, message) { + const resourcePath = ['', resourceType, resourceId].join('/') - const response = await axios.post(getRootPath() + ressourcePath, { + const response = await axios.post(getRootPath() + resourcePath, { actorDisplayName: getCurrentUser().displayName, actorId: getCurrentUser().uid, actorType: 'users', creationDateTime: (new Date()).toUTCString(), message, - objectType: 'files', + objectType: resourceType, verb: 'comment', }) - // Retrieve comment id from ressource location + // Retrieve comment id from resource location const commentId = parseInt(response.headers['content-location'].split('/').pop()) - const commentPath = ressourcePath + '/' + commentId + const commentPath = resourcePath + '/' + commentId // Fetch newly created comment data const comment = await client.stat(commentPath, { details: true, }) + const props = comment.data.props + // Decode twice to handle potentially double-encoded entities + // FIXME Remove this once https://github.com/nextcloud/server/issues/29306 + // is resolved + props.actorDisplayName = decodeHtmlEntities(props.actorDisplayName, 2) + props.message = decodeHtmlEntities(props.message, 2) + return comment.data } diff --git a/apps/comments/src/services/ReadComments.ts b/apps/comments/src/services/ReadComments.ts new file mode 100644 index 00000000000..73682e21d95 --- /dev/null +++ b/apps/comments/src/services/ReadComments.ts @@ -0,0 +1,38 @@ +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import client from './DavClient.js' + +import type { Response } from 'webdav' + +/** + * Mark comments older than the date timestamp as read + * + * @param resourceType the resource type + * @param resourceId the resource ID + * @param date the date object + */ +export const markCommentsAsRead = ( + resourceType: string, + resourceId: number, + date: Date, +): Promise<Response> => { + const resourcePath = ['', resourceType, resourceId].join('/') + const readMarker = date.toUTCString() + + return client.customRequest(resourcePath, { + method: 'PROPPATCH', + data: `<?xml version="1.0"?> + <d:propertyupdate + xmlns:d="DAV:" + xmlns:oc="http://owncloud.org/ns"> + <d:set> + <d:prop> + <oc:readMarker>${readMarker}</oc:readMarker> + </d:prop> + </d:set> + </d:propertyupdate>`, + }) +} diff --git a/apps/comments/src/store/deletedCommentLimbo.js b/apps/comments/src/store/deletedCommentLimbo.js new file mode 100644 index 00000000000..3e511addebb --- /dev/null +++ b/apps/comments/src/store/deletedCommentLimbo.js @@ -0,0 +1,28 @@ +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { defineStore } from 'pinia' + +export const useDeletedCommentLimbo = defineStore('deletedCommentLimbo', { + state: () => ({ + idsInLimbo: [], + }), + actions: { + addId(id) { + this.idsInLimbo.push(id) + }, + + removeId(id) { + const index = this.idsInLimbo.indexOf(id) + if (index > -1) { + this.idsInLimbo.splice(index, 1) + } + }, + + checkForId(id) { + this.idsInLimbo.includes(id) + }, + }, +}) diff --git a/apps/comments/src/templates.js b/apps/comments/src/templates.js deleted file mode 100644 index 26ca658c772..00000000000 --- a/apps/comments/src/templates.js +++ /dev/null @@ -1,123 +0,0 @@ -(function() { - var template = Handlebars.template, templates = OCA.Comments.Templates = OCA.Comments.Templates || {}; -templates['comment'] = template({"1":function(container,depth0,helpers,partials,data) { - return " unread"; -},"3":function(container,depth0,helpers,partials,data) { - return " collapsed"; -},"5":function(container,depth0,helpers,partials,data) { - return " currentUser"; -},"7":function(container,depth0,helpers,partials,data) { - var helper; - - return "data-username=\"" - + container.escapeExpression(((helper = (helper = helpers.actorId || (depth0 != null ? depth0.actorId : depth0)) != null ? helper : helpers.helperMissing),(typeof helper === "function" ? helper.call(depth0 != null ? depth0 : (container.nullContext || {}),{"name":"actorId","hash":{},"data":data}) : helper))) - + "\""; -},"9":function(container,depth0,helpers,partials,data) { - return " <a href=\"#\" class=\"action more icon icon-more has-tooltip\"></a>\n <div class=\"deleteLoading icon-loading-small hidden\"></div>\n"; -},"11":function(container,depth0,helpers,partials,data) { - return " <div class=\"message-overlay\"></div>\n"; -},"compiler":[7,">= 4.0.0"],"main":function(container,depth0,helpers,partials,data) { - var stack1, helper, alias1=depth0 != null ? depth0 : (container.nullContext || {}), alias2=helpers.helperMissing, alias3="function", alias4=container.escapeExpression; - - return "<li class=\"comment" - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.isUnread : depth0),{"name":"if","hash":{},"fn":container.program(1, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.isLong : depth0),{"name":"if","hash":{},"fn":container.program(3, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + "\" data-id=\"" - + alias4(((helper = (helper = helpers.id || (depth0 != null ? depth0.id : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"id","hash":{},"data":data}) : helper))) - + "\">\n <div class=\"authorRow\">\n <div class=\"avatar" - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.isUserAuthor : depth0),{"name":"if","hash":{},"fn":container.program(5, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + "\" " - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.actorId : depth0),{"name":"if","hash":{},"fn":container.program(7, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + "> </div>\n <div class=\"author" - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.isUserAuthor : depth0),{"name":"if","hash":{},"fn":container.program(5, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + "\">" - + alias4(((helper = (helper = helpers.actorDisplayName || (depth0 != null ? depth0.actorDisplayName : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"actorDisplayName","hash":{},"data":data}) : helper))) - + "</div>\n" - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.isUserAuthor : depth0),{"name":"if","hash":{},"fn":container.program(9, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + " <div class=\"date has-tooltip live-relative-timestamp\" data-timestamp=\"" - + alias4(((helper = (helper = helpers.timestamp || (depth0 != null ? depth0.timestamp : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"timestamp","hash":{},"data":data}) : helper))) - + "\" title=\"" - + alias4(((helper = (helper = helpers.altDate || (depth0 != null ? depth0.altDate : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"altDate","hash":{},"data":data}) : helper))) - + "\">" - + alias4(((helper = (helper = helpers.date || (depth0 != null ? depth0.date : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"date","hash":{},"data":data}) : helper))) - + "</div>\n </div>\n <div class=\"message\">" - + ((stack1 = ((helper = (helper = helpers.formattedMessage || (depth0 != null ? depth0.formattedMessage : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"formattedMessage","hash":{},"data":data}) : helper))) != null ? stack1 : "") - + "</div>\n" - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.isLong : depth0),{"name":"if","hash":{},"fn":container.program(11, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + "</li>\n"; -},"useData":true}); -templates['commentsmodifymenu'] = template({"1":function(container,depth0,helpers,partials,data) { - var stack1, helper, alias1=depth0 != null ? depth0 : (container.nullContext || {}), alias2=helpers.helperMissing, alias3="function", alias4=container.escapeExpression; - - return " <li>\n <a href=\"#\" class=\"menuitem action " - + alias4(((helper = (helper = helpers.name || (depth0 != null ? depth0.name : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"name","hash":{},"data":data}) : helper))) - + " permanent\" data-action=\"" - + alias4(((helper = (helper = helpers.name || (depth0 != null ? depth0.name : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"name","hash":{},"data":data}) : helper))) - + "\">\n" - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.iconClass : depth0),{"name":"if","hash":{},"fn":container.program(2, data, 0),"inverse":container.program(4, data, 0),"data":data})) != null ? stack1 : "") - + " <span>" - + alias4(((helper = (helper = helpers.displayName || (depth0 != null ? depth0.displayName : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"displayName","hash":{},"data":data}) : helper))) - + "</span>\n </a>\n </li>\n"; -},"2":function(container,depth0,helpers,partials,data) { - var helper; - - return " <span class=\"icon " - + container.escapeExpression(((helper = (helper = helpers.iconClass || (depth0 != null ? depth0.iconClass : depth0)) != null ? helper : helpers.helperMissing),(typeof helper === "function" ? helper.call(depth0 != null ? depth0 : (container.nullContext || {}),{"name":"iconClass","hash":{},"data":data}) : helper))) - + "\"></span>\n"; -},"4":function(container,depth0,helpers,partials,data) { - return " <span class=\"no-icon\"></span>\n"; -},"compiler":[7,">= 4.0.0"],"main":function(container,depth0,helpers,partials,data) { - var stack1; - - return "<ul>\n" - + ((stack1 = helpers.each.call(depth0 != null ? depth0 : (container.nullContext || {}),(depth0 != null ? depth0.items : depth0),{"name":"each","hash":{},"fn":container.program(1, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + "</ul>\n"; -},"useData":true}); -templates['edit_comment'] = template({"1":function(container,depth0,helpers,partials,data) { - var helper; - - return " <div class=\"action-container\">\n <a href=\"#\" class=\"action cancel icon icon-close has-tooltip\" title=\"" - + container.escapeExpression(((helper = (helper = helpers.cancelText || (depth0 != null ? depth0.cancelText : depth0)) != null ? helper : helpers.helperMissing),(typeof helper === "function" ? helper.call(depth0 != null ? depth0 : (container.nullContext || {}),{"name":"cancelText","hash":{},"data":data}) : helper))) - + "\"></a>\n </div>\n"; -},"compiler":[7,">= 4.0.0"],"main":function(container,depth0,helpers,partials,data) { - var stack1, helper, alias1=depth0 != null ? depth0 : (container.nullContext || {}), alias2=helpers.helperMissing, alias3="function", alias4=container.escapeExpression; - - return "<" - + alias4(((helper = (helper = helpers.tag || (depth0 != null ? depth0.tag : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"tag","hash":{},"data":data}) : helper))) - + " class=\"newCommentRow comment\" data-id=\"" - + alias4(((helper = (helper = helpers.id || (depth0 != null ? depth0.id : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"id","hash":{},"data":data}) : helper))) - + "\">\n <div class=\"authorRow\">\n <div class=\"avatar currentUser\" data-username=\"" - + alias4(((helper = (helper = helpers.actorId || (depth0 != null ? depth0.actorId : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"actorId","hash":{},"data":data}) : helper))) - + "\"></div>\n <div class=\"author currentUser\">" - + alias4(((helper = (helper = helpers.actorDisplayName || (depth0 != null ? depth0.actorDisplayName : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"actorDisplayName","hash":{},"data":data}) : helper))) - + "</div>\n" - + ((stack1 = helpers["if"].call(alias1,(depth0 != null ? depth0.isEditMode : depth0),{"name":"if","hash":{},"fn":container.program(1, data, 0),"inverse":container.noop,"data":data})) != null ? stack1 : "") - + " </div>\n <form class=\"newCommentForm\">\n <div contentEditable=\"true\" class=\"message\" data-placeholder=\"" - + alias4(((helper = (helper = helpers.newMessagePlaceholder || (depth0 != null ? depth0.newMessagePlaceholder : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"newMessagePlaceholder","hash":{},"data":data}) : helper))) - + "\">" - + alias4(((helper = (helper = helpers.message || (depth0 != null ? depth0.message : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"message","hash":{},"data":data}) : helper))) - + "</div>\n <input class=\"submit icon-confirm has-tooltip\" type=\"submit\" value=\"\" title=\"" - + alias4(((helper = (helper = helpers.submitText || (depth0 != null ? depth0.submitText : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"submitText","hash":{},"data":data}) : helper))) - + "\"/>\n <div class=\"submitLoading icon-loading-small hidden\"></div>\n </form>\n</" - + alias4(((helper = (helper = helpers.tag || (depth0 != null ? depth0.tag : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"tag","hash":{},"data":data}) : helper))) - + ">\n"; -},"useData":true}); -templates['filesplugin'] = template({"compiler":[7,">= 4.0.0"],"main":function(container,depth0,helpers,partials,data) { - var helper, alias1=depth0 != null ? depth0 : (container.nullContext || {}), alias2=helpers.helperMissing, alias3="function", alias4=container.escapeExpression; - - return "<a class=\"action action-comment permanent\" title=\"" - + alias4(((helper = (helper = helpers.countMessage || (depth0 != null ? depth0.countMessage : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"countMessage","hash":{},"data":data}) : helper))) - + "\" href=\"#\">\n <img class=\"svg\" src=\"" - + alias4(((helper = (helper = helpers.iconUrl || (depth0 != null ? depth0.iconUrl : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"iconUrl","hash":{},"data":data}) : helper))) - + "\"/>\n</a>\n"; -},"useData":true}); -templates['view'] = template({"compiler":[7,">= 4.0.0"],"main":function(container,depth0,helpers,partials,data) { - var helper, alias1=depth0 != null ? depth0 : (container.nullContext || {}), alias2=helpers.helperMissing, alias3="function", alias4=container.escapeExpression; - - return "<ul class=\"comments\">\n</ul>\n<div class=\"emptycontent hidden\"><div class=\"icon-comment\"></div>\n <p>" - + alias4(((helper = (helper = helpers.emptyResultLabel || (depth0 != null ? depth0.emptyResultLabel : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"emptyResultLabel","hash":{},"data":data}) : helper))) - + "</p></div>\n<input type=\"button\" class=\"showMore hidden\" value=\"" - + alias4(((helper = (helper = helpers.moreLabel || (depth0 != null ? depth0.moreLabel : depth0)) != null ? helper : alias2),(typeof helper === alias3 ? helper.call(alias1,{"name":"moreLabel","hash":{},"data":data}) : helper))) - + "\" name=\"show-more\" id=\"show-more\" />\n<div class=\"loading hidden\" style=\"height: 50px\"></div>\n"; -},"useData":true}); -})();
\ No newline at end of file diff --git a/apps/comments/src/templates/filesplugin.handlebars b/apps/comments/src/templates/filesplugin.handlebars deleted file mode 100644 index 8f3699380be..00000000000 --- a/apps/comments/src/templates/filesplugin.handlebars +++ /dev/null @@ -1,3 +0,0 @@ -<a class="action action-comment permanent" title="{{countMessage}}" href="#"> - <img class="svg" src="{{iconUrl}}"/> -</a> diff --git a/apps/comments/src/utils/cancelableRequest.js b/apps/comments/src/utils/cancelableRequest.js index 425e94a787a..c2d380c80f9 100644 --- a/apps/comments/src/utils/cancelableRequest.js +++ b/apps/comments/src/utils/cancelableRequest.js @@ -1,61 +1,35 @@ /** - * @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/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ -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} + * @param {Function} request the axios promise request + * @return {object} */ const cancelableRequest = function(request) { - /** - * Generate an axios cancel token - */ - const cancelToken = createCancelToken() + const controller = new AbortController() + const signal = controller.signal /** * Execute the request * * @param {string} url the url to send the request to - * @param {Object} [options] optional config for the request + * @param {object} [options] optional config for the request */ const fetch = async function(url, options) { - return request( + const response = await request( url, - Object.assign({ cancelToken: cancelToken.token }, options) + Object.assign({ signal }, options), ) + return response } return { request: fetch, - cancel: cancelToken.cancel, + abort: () => controller.abort(), } } diff --git a/apps/comments/src/utils/davUtils.js b/apps/comments/src/utils/davUtils.js index b10b62e4f34..33efc8e7d10 100644 --- a/apps/comments/src/utils/davUtils.js +++ b/apps/comments/src/utils/davUtils.js @@ -1,23 +1,6 @@ /** - * @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/>. - * + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ import { generateRemoteUrl } from '@nextcloud/router' diff --git a/apps/comments/src/utils/decodeHtmlEntities.js b/apps/comments/src/utils/decodeHtmlEntities.js new file mode 100644 index 00000000000..4c492954256 --- /dev/null +++ b/apps/comments/src/utils/decodeHtmlEntities.js @@ -0,0 +1,17 @@ +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * @param {any} value - + * @param {any} passes - + */ +export function decodeHtmlEntities(value, passes = 1) { + const parser = new DOMParser() + let decoded = value + for (let i = 0; i < passes; i++) { + decoded = parser.parseFromString(decoded, 'text/html').documentElement.textContent + } + return decoded +} diff --git a/apps/comments/src/utils/numberUtil.js b/apps/comments/src/utils/numberUtil.js deleted file mode 100644 index 018c34c49e8..00000000000 --- a/apps/comments/src/utils/numberUtil.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @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/ActivityCommentAction.vue b/apps/comments/src/views/ActivityCommentAction.vue new file mode 100644 index 00000000000..f9a9a97796f --- /dev/null +++ b/apps/comments/src/views/ActivityCommentAction.vue @@ -0,0 +1,54 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <Comment v-bind="editorData" + :auto-complete="autoComplete" + :resource-type="resourceType" + :editor="true" + :user-data="userData" + :resource-id="resourceId" + class="comments-action" + @new="onNewComment" /> +</template> + +<script lang="ts"> +import { defineComponent } from 'vue' +import Comment from '../components/Comment.vue' +import CommentView from '../mixins/CommentView.js' +import logger from '../logger' +import { showError } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' + +export default defineComponent({ + components: { + Comment, + }, + mixins: [CommentView], + props: { + reloadCallback: { + type: Function, + required: true, + }, + }, + methods: { + onNewComment() { + try { + // just force reload + this.reloadCallback() + } catch (e) { + showError(t('comments', 'Could not reload comments')) + logger.debug(e) + } + }, + }, +}) +</script> + +<style scoped> +.comments-action { + padding: 0; +} +</style> diff --git a/apps/comments/src/views/ActivityCommentEntry.vue b/apps/comments/src/views/ActivityCommentEntry.vue new file mode 100644 index 00000000000..bbfe530b2e3 --- /dev/null +++ b/apps/comments/src/views/ActivityCommentEntry.vue @@ -0,0 +1,71 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <Comment ref="comment" + tag="li" + v-bind="comment.props" + :auto-complete="autoComplete" + :resource-type="resourceType" + :message="commentMessage" + :resource-id="resourceId" + :user-data="genMentionsData(comment.props.mentions)" + class="comments-activity" + @delete="reloadCallback()" /> +</template> + +<script lang="ts"> +import type { PropType } from 'vue' +import { translate as t } from '@nextcloud/l10n' + +import Comment from '../components/Comment.vue' +import CommentView from '../mixins/CommentView' + +export default { + name: 'ActivityCommentEntry', + + components: { + Comment, + }, + + mixins: [CommentView], + props: { + comment: { + type: Object, + required: true, + }, + reloadCallback: { + type: Function as PropType<() => void>, + required: true, + }, + }, + + data() { + return { + commentMessage: '', + } + }, + + watch: { + comment() { + this.commentMessage = this.comment.props.message + }, + }, + + mounted() { + this.commentMessage = this.comment.props.message + }, + + methods: { + t, + }, +} +</script> + +<style scoped> +.comments-activity { + padding: 0; +} +</style> diff --git a/apps/comments/src/views/Comments.vue b/apps/comments/src/views/Comments.vue index 586a12ed8d6..657af888a12 100644 --- a/apps/comments/src/views/Comments.vue +++ b/apps/comments/src/views/Comments.vue @@ -1,51 +1,44 @@ <!-- - - @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/>. - - - --> + - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> <template> - <div class="comments" :class="{ 'icon-loading': isFirstLoading }"> + <div v-element-visibility="onVisibilityChange" + class="comments" + :class="{ 'icon-loading': isFirstLoading }"> <!-- Editor --> <Comment v-bind="editorData" :auto-complete="autoComplete" + :resource-type="resourceType" :editor="true" - :ressource-id="ressourceId" + :user-data="userData" + :resource-id="currentResourceId" 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.props.id" - v-bind="comment.props" - :auto-complete="autoComplete" - :message.sync="comment.props.message" - :ressource-id="ressourceId" - :user-data="genMentionsData(comment.props.mentions)" - class="comments__list" - @delete="onDelete" /> + <NcEmptyContent v-if="!hasComments && done" + class="comments__empty" + :name="t('comments', 'No comments yet, start the conversation!')"> + <template #icon> + <IconMessageReplyTextOutline /> + </template> + </NcEmptyContent> + <ul v-else> + <!-- Comments --> + <Comment v-for="comment in comments" + :key="comment.props.id" + tag="li" + v-bind="comment.props" + :auto-complete="autoComplete" + :resource-type="resourceType" + :message.sync="comment.props.message" + :resource-id="currentResourceId" + :user-data="genMentionsData(comment.props.mentions)" + class="comments__list" + @delete="onDelete" /> + </ul> <!-- Loading more message --> <div v-if="loading && !isFirstLoading" class="comments__info icon-loading" /> @@ -55,61 +48,72 @@ </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 v-else-if="error"> + <NcEmptyContent class="comments__error" :name="error"> + <template #icon> + <IconAlertCircleOutline /> + </template> + </NcEmptyContent> + <NcButton class="comments__retry" @click="getComments"> + <template #icon> + <IconRefresh /> + </template> + {{ t('comments', 'Retry') }} + </NcButton> + </template> </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) +import { showError } from '@nextcloud/dialogs' +import { translate as t } from '@nextcloud/l10n' +import { vElementVisibility as elementVisibility } from '@vueuse/components' + +import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' +import NcButton from '@nextcloud/vue/components/NcButton' +import IconRefresh from 'vue-material-design-icons/Refresh.vue' +import IconMessageReplyTextOutline from 'vue-material-design-icons/MessageReplyTextOutline.vue' +import IconAlertCircleOutline from 'vue-material-design-icons/AlertCircleOutline.vue' + +import Comment from '../components/Comment.vue' +import CommentView from '../mixins/CommentView' +import cancelableRequest from '../utils/cancelableRequest.js' +import { getComments, DEFAULT_LIMIT } from '../services/GetComments.ts' +import { markCommentsAsRead } from '../services/ReadComments.ts' export default { name: 'Comments', components: { - // Avatar, Comment, - EmptyContent, + NcEmptyContent, + NcButton, + IconRefresh, + IconMessageReplyTextOutline, + IconAlertCircleOutline, + }, + + directives: { + elementVisibility, }, + mixins: [CommentView], + data() { return { error: '', loading: false, done: false, - ressourceId: null, + currentResourceId: this.resourceId, offset: 0, comments: [], cancelRequest: () => {}, - editorData: { - actorDisplayName: getCurrentUser().displayName, - actorId: getCurrentUser().uid, - key: 'editor', - }, - Comment, + userData: {}, } }, @@ -122,13 +126,32 @@ export default { }, }, + watch: { + resourceId() { + this.currentResourceId = this.resourceId + }, + }, + methods: { + t, + + async onVisibilityChange(isVisible) { + if (isVisible) { + try { + await markCommentsAsRead(this.resourceType, this.currentResourceId, new Date()) + } catch (e) { + showError(e.message || t('comments', 'Failed to mark comments as read')) + } + } + }, + /** - * Update current ressourceId and fetch new data - * @param {Number} ressourceId the current ressourceId (fileId...) + * Update current resourceId and fetch new data + * + * @param {number} resourceId the current resourceId (fileId...) */ - async update(ressourceId) { - this.ressourceId = ressourceId + async update(resourceId) { + this.currentResourceId = resourceId this.resetState() this.getComments() }, @@ -150,26 +173,6 @@ export default { }, /** - * Make sure we have all mentions as Array of objects - * @param {Array} mentions the mentions list - * @returns {Object[]} - */ - genMentionsData(mentions) { - const list = Object.values(mentions).flat() - return list.reduce((mentions, mention) => { - mentions[mention.mentionId] = { - // TODO: support groups - icon: 'icon-user', - id: mention.mentionId, - label: mention.mentionDisplayName, - source: 'users', - primary: getCurrentUser().uid === mention.mentionId, - } - return mentions - }, {}) - }, - - /** * Get the existing shares infos */ async getComments() { @@ -181,14 +184,14 @@ export default { this.error = '' // Init cancellable request - const { request, cancel } = cancelableRequest(getComments) - this.cancelRequest = cancel + const { request, abort } = cancelableRequest(getComments) + this.cancelRequest = abort // Fetch comments - const comments = await request({ - commentsType: this.commentsType, - ressourceId: this.ressourceId, - }, { offset: this.offset }) + const { data: comments } = await request({ + resourceType: this.resourceType, + resourceId: this.currentResourceId, + }, { offset: this.offset }) || { data: [] } this.logger.debug(`Processed ${comments.length} comments`, { comments }) @@ -215,26 +218,9 @@ export default { }, /** - * 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 + * + * @param {object} comment the new comment */ onNewComment(comment) { this.comments.unshift(comment) @@ -242,6 +228,7 @@ export default { /** * Remove deleted comment from the list + * * @param {number} id the deleted comment */ onDelete(id) { @@ -269,9 +256,17 @@ export default { <style lang="scss" scoped> .comments { - // Do not add emptycontent top margin - &__error{ - margin-top: 0; + min-height: 100%; + display: flex; + flex-direction: column; + + &__empty, + &__error { + flex: 1 0; + } + + &__retry { + margin: 0 auto; } &__info { |