aboutsummaryrefslogtreecommitdiffstats
path: root/apps/comments/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/comments/src')
-rw-r--r--apps/comments/src/actions/inlineUnreadCommentsAction.spec.ts179
-rw-r--r--apps/comments/src/actions/inlineUnreadCommentsAction.ts46
-rw-r--r--apps/comments/src/comments-activity-tab.ts78
-rw-r--r--apps/comments/src/comments-app.js15
-rw-r--r--apps/comments/src/comments-tab.js60
-rw-r--r--apps/comments/src/components/Comment.vue384
-rw-r--r--apps/comments/src/init.ts8
-rw-r--r--apps/comments/src/logger.js11
-rw-r--r--apps/comments/src/mixins/CommentMixin.js115
-rw-r--r--apps/comments/src/mixins/CommentView.ts76
-rw-r--r--apps/comments/src/services/CommentsInstance.js55
-rw-r--r--apps/comments/src/services/DavClient.js27
-rw-r--r--apps/comments/src/services/DeleteComment.js20
-rw-r--r--apps/comments/src/services/EditComment.js32
-rw-r--r--apps/comments/src/services/GetComments.ts67
-rw-r--r--apps/comments/src/services/NewComment.js50
-rw-r--r--apps/comments/src/services/ReadComments.ts38
-rw-r--r--apps/comments/src/store/deletedCommentLimbo.js28
-rw-r--r--apps/comments/src/utils/cancelableRequest.js36
-rw-r--r--apps/comments/src/utils/davUtils.js12
-rw-r--r--apps/comments/src/utils/decodeHtmlEntities.js17
-rw-r--r--apps/comments/src/views/ActivityCommentAction.vue54
-rw-r--r--apps/comments/src/views/ActivityCommentEntry.vue71
-rw-r--r--apps/comments/src/views/Comments.vue279
24 files changed, 1758 insertions, 0 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/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
new file mode 100644
index 00000000000..a91a4bb37bb
--- /dev/null
+++ b/apps/comments/src/comments-app.js
@@ -0,0 +1,15 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import CommentsInstance from './services/CommentsInstance.js'
+
+// 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..d3ebe3e9596
--- /dev/null
+++ b/apps/comments/src/comments-tab.js
@@ -0,0 +1,60 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+// 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'
+
+// @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 = 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..80f035530fb
--- /dev/null
+++ b/apps/comments/src/components/Comment.vue
@@ -0,0 +1,384 @@
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <component :is="tag"
+ v-show="!deleted && !isLimbo"
+ :class="{'comment--loading': loading}"
+ class="comment">
+ <!-- Comment header toolbar -->
+ <div class="comment__side">
+ <!-- Author -->
+ <NcAvatar class="comment__avatar"
+ :display-name="actorDisplayName"
+ :user="actorId"
+ :size="32" />
+ </div>
+ <div class="comment__body">
+ <div class="comment__header">
+ <span class="comment__author">{{ actorDisplayName }}</span>
+
+ <!-- Comment actions,
+ show if we have a message id and current user is author -->
+ <NcActions v-if="isOwnComment && id && !loading" class="comment__actions">
+ <template v-if="!editing">
+ <NcActionButton close-after-click
+ @click="onEdit">
+ <template #icon>
+ <IconPencilOutline :size="20" />
+ </template>
+ {{ t('comments', 'Edit comment') }}
+ </NcActionButton>
+ <NcActionSeparator />
+ <NcActionButton close-after-click
+ @click="onDeleteWithUndo">
+ <template #icon>
+ <IconTrashCanOutline :size="20" />
+ </template>
+ {{ t('comments', 'Delete comment') }}
+ </NcActionButton>
+ </template>
+
+ <NcActionButton v-else @click="onEditCancel">
+ <template #icon>
+ <IconClose :size="20" />
+ </template>
+ {{ t('comments', 'Cancel edit') }}
+ </NcActionButton>
+ </NcActions>
+
+ <!-- Show loading if we're editing or deleting, not on new ones -->
+ <div v-if="id && loading" class="comment_loading icon-loading-small" />
+
+ <!-- Relative time to the comment creation -->
+ <NcDateTime v-else-if="creationDateTime"
+ class="comment__timestamp"
+ :timestamp="timestamp"
+ :ignore-seconds="true" />
+ </div>
+
+ <!-- Message editor -->
+ <form v-if="editor || editing" class="comment__editor" @submit.prevent>
+ <div class="comment__editor-group">
+ <NcRichContenteditable ref="editor"
+ :auto-complete="autoComplete"
+ :contenteditable="!loading"
+ :label="editor ? t('comments', 'New comment') : t('comments', 'Edit comment')"
+ :placeholder="t('comments', 'Write a comment …')"
+ :value="localMessage"
+ :user-data="userData"
+ aria-describedby="tab-comments__editor-description"
+ @update:value="updateLocalMessage"
+ @submit="onSubmit" />
+ <div class="comment__submit">
+ <NcButton type="tertiary-no-background"
+ native-type="submit"
+ :aria-label="t('comments', 'Post comment')"
+ :disabled="isEmptyMessage"
+ @click="onSubmit">
+ <template #icon>
+ <NcLoadingIcon v-if="loading" />
+ <IconArrowRight v-else :size="20" />
+ </template>
+ </NcButton>
+ </div>
+ </div>
+ <div id="tab-comments__editor-description" class="comment__editor-description">
+ {{ t('comments', '@ for mentions, : for emoji, / for smart picker') }}
+ </div>
+ </form>
+
+ <!-- Message content -->
+ <NcRichText v-else
+ class="comment__message"
+ :class="{'comment__message--expanded': expanded}"
+ :text="richContent.message"
+ :arguments="richContent.mentions"
+ @click="onExpand" />
+ </div>
+ </component>
+</template>
+
+<script>
+import { getCurrentUser } from '@nextcloud/auth'
+import { translate as t } from '@nextcloud/l10n'
+
+import NcActionButton from '@nextcloud/vue/components/NcActionButton'
+import NcActions from '@nextcloud/vue/components/NcActions'
+import NcActionSeparator from '@nextcloud/vue/components/NcActionSeparator'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcDateTime from '@nextcloud/vue/components/NcDateTime'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcUserBubble from '@nextcloud/vue/components/NcUserBubble'
+
+import IconArrowRight from 'vue-material-design-icons/ArrowRight.vue'
+import IconClose from 'vue-material-design-icons/Close.vue'
+import IconTrashCanOutline from 'vue-material-design-icons/TrashCanOutline.vue'
+import IconPencilOutline from 'vue-material-design-icons/PencilOutline.vue'
+
+import CommentMixin from '../mixins/CommentMixin.js'
+import { mapStores } from 'pinia'
+import { useDeletedCommentLimbo } from '../store/deletedCommentLimbo.js'
+
+// Dynamic loading
+const NcRichContenteditable = () => import('@nextcloud/vue/components/NcRichContenteditable')
+const NcRichText = () => import('@nextcloud/vue/components/NcRichText')
+
+export default {
+ name: 'Comment',
+
+ components: {
+ IconArrowRight,
+ IconClose,
+ IconTrashCanOutline,
+ IconPencilOutline,
+ NcActionButton,
+ NcActions,
+ NcActionSeparator,
+ NcAvatar,
+ NcButton,
+ NcDateTime,
+ NcLoadingIcon,
+ NcRichContenteditable,
+ NcRichText,
+ },
+ mixins: [CommentMixin],
+
+ inheritAttrs: false,
+
+ props: {
+ actorDisplayName: {
+ type: String,
+ required: true,
+ },
+ actorId: {
+ type: String,
+ required: true,
+ },
+ creationDateTime: {
+ type: String,
+ default: null,
+ },
+
+ /**
+ * Force the editor display
+ */
+ editor: {
+ type: Boolean,
+ default: false,
+ },
+
+ /**
+ * Provide the autocompletion data
+ */
+ autoComplete: {
+ type: Function,
+ required: true,
+ },
+ userData: {
+ type: Object,
+ default: () => ({}),
+ },
+
+ tag: {
+ type: String,
+ default: 'div',
+ },
+ },
+
+ data() {
+ return {
+ expanded: false,
+ // Only change data locally and update the original
+ // parent data when the request is sent and resolved
+ localMessage: '',
+ submitted: false,
+ }
+ },
+
+ computed: {
+ ...mapStores(useDeletedCommentLimbo),
+
+ /**
+ * Is the current user the author of this comment
+ *
+ * @return {boolean}
+ */
+ isOwnComment() {
+ return getCurrentUser().uid === this.actorId
+ },
+
+ richContent() {
+ const mentions = {}
+ let message = this.localMessage
+
+ Object.keys(this.userData).forEach((user, index) => {
+ const key = `mention-${index}`
+ const regex = new RegExp(`@${user}|@"${user}"`, 'g')
+ message = message.replace(regex, `{${key}}`)
+ mentions[key] = {
+ component: NcUserBubble,
+ props: {
+ user,
+ displayName: this.userData[user].label,
+ primary: this.userData[user].primary,
+ },
+ }
+ })
+
+ return { mentions, message }
+ },
+
+ isEmptyMessage() {
+ return !this.localMessage || this.localMessage.trim() === ''
+ },
+
+ /**
+ * Timestamp of the creation time (in ms UNIX time)
+ */
+ timestamp() {
+ return Date.parse(this.creationDateTime)
+ },
+
+ isLimbo() {
+ return this.deletedCommentLimboStore.checkForId(this.id)
+ },
+ },
+
+ watch: {
+ // If the data change, update the local value
+ message(message) {
+ this.updateLocalMessage(message)
+ },
+ },
+
+ beforeMount() {
+ // Init localMessage
+ this.updateLocalMessage(this.message)
+ },
+
+ methods: {
+ t,
+
+ /**
+ * Update local Message on outer change
+ *
+ * @param {string} message the message to set
+ */
+ updateLocalMessage(message) {
+ this.localMessage = message.toString()
+ this.submitted = false
+ },
+
+ /**
+ * Dispatch message between edit and create
+ */
+ onSubmit() {
+ // Do not submit if message is empty
+ if (this.localMessage.trim() === '') {
+ return
+ }
+
+ if (this.editor) {
+ this.onNewComment(this.localMessage.trim())
+ this.$nextTick(() => {
+ // Focus the editor again
+ this.$refs.editor.$el.focus()
+ })
+ return
+ }
+ this.onEditComment(this.localMessage.trim())
+ },
+
+ onExpand() {
+ this.expanded = true
+ },
+ },
+
+}
+</script>
+
+<style lang="scss" scoped>
+@use "sass:math";
+
+$comment-padding: 10px;
+
+.comment {
+ display: flex;
+ gap: 8px;
+ padding: 5px $comment-padding;
+
+ &__side {
+ display: flex;
+ align-items: flex-start;
+ padding-top: 6px;
+ }
+
+ &__body {
+ display: flex;
+ flex-grow: 1;
+ flex-direction: column;
+ }
+
+ &__header {
+ display: flex;
+ align-items: center;
+ min-height: 44px;
+ }
+
+ &__actions {
+ margin-inline-start: $comment-padding !important;
+ }
+
+ &__author {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ color: var(--color-text-maxcontrast);
+ }
+
+ &_loading,
+ &__timestamp {
+ margin-inline-start: auto;
+ text-align: end;
+ white-space: nowrap;
+ color: var(--color-text-maxcontrast);
+ }
+
+ &__editor-group {
+ position: relative;
+ }
+
+ &__editor-description {
+ color: var(--color-text-maxcontrast);
+ padding-block: var(--default-grid-baseline);
+ }
+
+ &__submit {
+ position: absolute !important;
+ bottom: 5px;
+ inset-inline-end: 0;
+ }
+
+ &__message {
+ white-space: pre-wrap;
+ word-break: normal;
+ max-height: 70px;
+ overflow: hidden;
+ margin-top: -6px;
+ &--expanded {
+ max-height: none;
+ overflow: visible;
+ }
+ }
+}
+
+.rich-contenteditable__input {
+ min-height: 44px;
+ margin: 0;
+ padding: $comment-padding;
+}
+
+</style>
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
new file mode 100644
index 00000000000..722ad3444ce
--- /dev/null
+++ b/apps/comments/src/mixins/CommentMixin.js
@@ -0,0 +1,115 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+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: {
+ id: {
+ type: Number,
+ default: null,
+ },
+ message: {
+ type: String,
+ default: '',
+ },
+ resourceId: {
+ type: [String, Number],
+ required: true,
+ },
+ resourceType: {
+ type: String,
+ default: 'files',
+ },
+ },
+
+ data() {
+ return {
+ deleted: false,
+ editing: false,
+ loading: false,
+ }
+ },
+
+ computed: {
+ ...mapStores(useDeletedCommentLimbo),
+ },
+
+ 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.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) {
+ showError(t('comments', 'An error occurred while trying to edit the comment'))
+ console.error(error)
+ } finally {
+ this.loading = false
+ }
+ },
+
+ // 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.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)
+ }
+ },
+
+ // CREATION
+ async onNewComment(message) {
+ this.loading = true
+ try {
+ 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
+ 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/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
new file mode 100644
index 00000000000..cc45d0cbea7
--- /dev/null
+++ b/apps/comments/src/services/CommentsInstance.js
@@ -0,0 +1,55 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+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'
+
+Vue.use(PiniaVuePlugin)
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = getCSPNonce()
+
+// 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} resourceType the comments endpoint type
+ * @param {object} options the vue options (propsData, parent, el...)
+ */
+ 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
new file mode 100644
index 00000000000..3e9a529283f
--- /dev/null
+++ b/apps/comments/src/services/DavClient.js
@@ -0,0 +1,27 @@
+/**
+ * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { createClient } from 'webdav'
+import { getRootPath } from '../utils/davUtils.js'
+import { getRequestToken, onRequestTokenUpdate } from '@nextcloud/auth'
+
+// init webdav client
+const client = createClient(getRootPath())
+
+// 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 ?? '',
+ })
+}
+
+// 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
new file mode 100644
index 00000000000..1ed63d7836a
--- /dev/null
+++ b/apps/comments/src/services/DeleteComment.js
@@ -0,0 +1,20 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import client from './DavClient.js'
+
+/**
+ * Delete a comment
+ *
+ * @param {string} resourceType the resource type
+ * @param {number} resourceId the resource ID
+ * @param {number} commentId the comment iD
+ */
+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
new file mode 100644
index 00000000000..4ec33415a72
--- /dev/null
+++ b/apps/comments/src/services/EditComment.js
@@ -0,0 +1,32 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import client from './DavClient.js'
+
+/**
+ * Edit an existing comment
+ *
+ * @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(resourceType, resourceId, commentId, message) {
+ const commentPath = ['', resourceType, resourceId, 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.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
new file mode 100644
index 00000000000..663b4d72e02
--- /dev/null
+++ b/apps/comments/src/services/NewComment.js
@@ -0,0 +1,50 @@
+/**
+ * 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.js'
+import { decodeHtmlEntities } from '../utils/decodeHtmlEntities.js'
+import axios from '@nextcloud/axios'
+import client from './DavClient.js'
+
+/**
+ * Retrieve the comments list
+ *
+ * @param {string} resourceType the resource type
+ * @param {number} resourceId the resource ID
+ * @param {string} message the message
+ * @return {object} the new comment
+ */
+export default async function(resourceType, resourceId, message) {
+ const resourcePath = ['', resourceType, resourceId].join('/')
+
+ const response = await axios.post(getRootPath() + resourcePath, {
+ actorDisplayName: getCurrentUser().displayName,
+ actorId: getCurrentUser().uid,
+ actorType: 'users',
+ creationDateTime: (new Date()).toUTCString(),
+ message,
+ objectType: resourceType,
+ verb: 'comment',
+ })
+
+ // Retrieve comment id from resource location
+ const commentId = parseInt(response.headers['content-location'].split('/').pop())
+ 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/utils/cancelableRequest.js b/apps/comments/src/utils/cancelableRequest.js
new file mode 100644
index 00000000000..c2d380c80f9
--- /dev/null
+++ b/apps/comments/src/utils/cancelableRequest.js
@@ -0,0 +1,36 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * Creates a cancelable axios 'request object'.
+ *
+ * @param {Function} request the axios promise request
+ * @return {object}
+ */
+const cancelableRequest = function(request) {
+ 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
+ */
+ const fetch = async function(url, options) {
+ const response = await request(
+ url,
+ Object.assign({ signal }, options),
+ )
+ return response
+ }
+
+ return {
+ request: fetch,
+ abort: () => controller.abort(),
+ }
+}
+
+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..33efc8e7d10
--- /dev/null
+++ b/apps/comments/src/utils/davUtils.js
@@ -0,0 +1,12 @@
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { generateRemoteUrl } from '@nextcloud/router'
+
+const getRootPath = function() {
+ return generateRemoteUrl('dav/comments')
+}
+
+export { getRootPath }
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/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
new file mode 100644
index 00000000000..657af888a12
--- /dev/null
+++ b/apps/comments/src/views/Comments.vue
@@ -0,0 +1,279 @@
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div v-element-visibility="onVisibilityChange"
+ class="comments"
+ :class="{ 'icon-loading': isFirstLoading }">
+ <!-- Editor -->
+ <Comment v-bind="editorData"
+ :auto-complete="autoComplete"
+ :resource-type="resourceType"
+ :editor="true"
+ :user-data="userData"
+ :resource-id="currentResourceId"
+ class="comments__writer"
+ @new="onNewComment" />
+
+ <template v-if="!isFirstLoading">
+ <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" />
+
+ <div v-else-if="hasComments && done" class="comments__info">
+ {{ t('comments', 'No more messages') }}
+ </div>
+
+ <!-- Error message -->
+ <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 { 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: {
+ Comment,
+ NcEmptyContent,
+ NcButton,
+ IconRefresh,
+ IconMessageReplyTextOutline,
+ IconAlertCircleOutline,
+ },
+
+ directives: {
+ elementVisibility,
+ },
+
+ mixins: [CommentView],
+
+ data() {
+ return {
+ error: '',
+ loading: false,
+ done: false,
+
+ currentResourceId: this.resourceId,
+ offset: 0,
+ comments: [],
+
+ cancelRequest: () => {},
+
+ Comment,
+ userData: {},
+ }
+ },
+
+ computed: {
+ hasComments() {
+ return this.comments.length > 0
+ },
+ isFirstLoading() {
+ return this.loading && this.offset === 0
+ },
+ },
+
+ 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 resourceId and fetch new data
+ *
+ * @param {number} resourceId the current resourceId (fileId...)
+ */
+ async update(resourceId) {
+ this.currentResourceId = resourceId
+ 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, abort } = cancelableRequest(getComments)
+ this.cancelRequest = abort
+
+ // Fetch comments
+ const { data: comments } = await request({
+ resourceType: this.resourceType,
+ resourceId: this.currentResourceId,
+ }, { offset: this.offset }) || { data: [] }
+
+ 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
+ }
+ this.error = t('comments', 'Unable to load the comments list')
+ console.error('Error loading the comments list', error)
+ } finally {
+ this.loading = false
+ }
+ },
+
+ /**
+ * 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.props.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 {
+ min-height: 100%;
+ display: flex;
+ flex-direction: column;
+
+ &__empty,
+ &__error {
+ flex: 1 0;
+ }
+
+ &__retry {
+ margin: 0 auto;
+ }
+
+ &__info {
+ height: 60px;
+ color: var(--color-text-maxcontrast);
+ text-align: center;
+ line-height: 60px;
+ }
+}
+</style>