feat(comments): Use activity tab to mount comments sidebar section if available

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
pull/41491/head
Ferdinand Thiessen 2 years ago
parent 9c3350b313
commit db2fec1cec
No known key found for this signature in database
GPG Key ID: 45FAE7268762B400
  1. 6
      apps/comments/lib/Listener/LoadSidebarScripts.php
  2. 85
      apps/comments/src/comments-activity-tab.ts
  3. 79
      apps/comments/src/comments-tab.js
  4. 5
      apps/comments/src/components/Comment.vue
  5. 10
      apps/comments/src/mixins/CommentMixin.js
  6. 68
      apps/comments/src/mixins/CommentView.ts
  7. 15
      apps/comments/src/services/GetComments.ts
  8. 70
      apps/comments/src/views/ActivityCommentAction.vue
  9. 86
      apps/comments/src/views/ActivityCommentEntry.vue
  10. 60
      apps/comments/src/views/Comments.vue

@ -28,6 +28,8 @@ namespace OCA\Comments\Listener;
use OCA\Comments\AppInfo\Application;
use OCA\Files\Event\LoadSidebar;
use OCP\App\IAppManager;
use OCP\AppFramework\Services\IInitialState;
use OCP\Comments\ICommentsManager;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
@ -36,6 +38,8 @@ use OCP\Util;
class LoadSidebarScripts implements IEventListener {
public function __construct(
private ICommentsManager $commentsManager,
private IInitialState $initialState,
private IAppManager $appManager,
) {
}
@ -46,6 +50,8 @@ class LoadSidebarScripts implements IEventListener {
$this->commentsManager->load();
$this->initialState->provideInitialState('activityEnabled', $this->appManager->isEnabledForUser('activity'));
// TODO: make sure to only include the sidebar script when
// we properly split it between files list and sidebar
Util::addScript(Application::APP_ID, 'comments');

@ -0,0 +1,85 @@
/**
* @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
*
* @author Ferdinand Thiessen <opensource@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* 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 moment from '@nextcloud/moment'
import Vue from 'vue'
import logger from './logger.js'
import { getComments } from './services/GetComments.js'
let ActivityTabPluginView
let ActivityTabPluginInstance
/**
* Register the comments plugins for the Activity sidebar
*/
export function registerCommentsPlugins() {
window.OCA.Activity.registerSidebarAction({
mount: async (el, { context, fileInfo, reload }) => {
if (!ActivityTabPluginView) {
const { default: ActivityCommmentAction } = await import('./views/ActivityCommentAction.vue')
ActivityTabPluginView = Vue.extend(ActivityCommmentAction)
}
ActivityTabPluginInstance = new ActivityTabPluginView({
parent: context,
propsData: {
reloadCallback: reload,
ressourceId: fileInfo.id,
},
})
ActivityTabPluginInstance.$mount(el)
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({ commentsType: 'files', ressourceId: fileInfo.id }, { limit, offset })
logger.debug('Loaded comments', { fileInfo, comments })
const { default: CommentView } = await import('./views/ActivityCommentEntry.vue')
const CommentsViewObject = Vue.extend(CommentView)
return comments.map((comment) => ({
timestamp: moment(comment.props.creationDateTime).toDate().getTime(),
mount(element, { context, reload }) {
this._CommentsViewInstance = new CommentsViewObject({
parent: context,
propsData: {
comment,
ressourceId: fileInfo.id,
reloadCallback: reload,
},
})
this._CommentsViewInstance.$mount(element)
},
unmount() {
this._CommentsViewInstance.$destroy()
},
}))
})
window.OCA.Activity.registerSidebarFilter((activity) => activity.type !== 'comments')
logger.info('Comments plugin registered for Activity sidebar action')
}

@ -22,40 +22,53 @@
// eslint-disable-next-line n/no-missing-import, import/no-unresolved
import MessageReplyText from '@mdi/svg/svg/message-reply-text.svg?raw'
import { getRequestToken } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
import { registerCommentsPlugins } from './comments-activity-tab.ts'
// Init Comments tab component
let TabInstance = null
const commentTab = new OCA.Files.Sidebar.Tab({
id: 'comments',
name: t('comments', 'Comments'),
iconSvg: MessageReplyText,
// @ts-expect-error __webpack_nonce__ is injected by webpack
__webpack_nonce__ = btoa(getRequestToken())
async mount(el, fileInfo, context) {
if (TabInstance) {
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,
})
// 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)
}
})
}

@ -111,6 +111,7 @@
<script>
import { getCurrentUser } from '@nextcloud/auth'
import { translate as t } from '@nextcloud/l10n'
import moment from '@nextcloud/moment'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
@ -235,6 +236,8 @@ export default {
},
methods: {
t,
/**
* Update local Message on outer change
*
@ -279,7 +282,7 @@ $comment-padding: 10px;
.comment {
display: flex;
gap: 16px;
gap: 8px;
padding: 5px $comment-padding;
&__side {

@ -20,10 +20,11 @@
*
*/
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 { showError, showUndo, TOAST_UNDO_TIMEOUT } from '@nextcloud/dialogs'
import logger from '../logger.js'
export default {
props: {
@ -46,6 +47,7 @@ export default {
deleted: false,
editing: false,
loading: false,
commentsType: 'files',
}
},
@ -63,7 +65,7 @@ export default {
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 })
logger.debug('Comment edited', { commentsType: this.commentsType, ressourceId: this.ressourceId, id: this.id, message })
this.$emit('update:message', message)
this.editing = false
} catch (error) {
@ -86,7 +88,7 @@ export default {
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 })
logger.debug('Comment deleted', { commentsType: this.commentsType, ressourceId: this.ressourceId, id: this.id })
this.$emit('delete', this.id)
} catch (error) {
showError(t('comments', 'An error occurred while trying to delete the comment'))
@ -100,7 +102,7 @@ export default {
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 })
logger.debug('New comment posted', { commentsType: this.commentsType, ressourceId: this.ressourceId, newComment })
this.$emit('new', newComment)
// Clear old content

@ -0,0 +1,68 @@
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: {
ressourceId: {
type: Number,
required: true,
},
},
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.ressourceId,
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
},
},
})

@ -20,7 +20,7 @@
*
*/
import { parseXML, type DAVResult, type FileStat } from 'webdav'
import { parseXML, type DAVResult, type FileStat, type ResponseDataDetailed } from 'webdav'
// https://github.com/perry-mitchell/webdav-client/issues/339
import { processResponsePayload } from '../../../../node_modules/webdav/dist/node/response.js'
@ -37,11 +37,13 @@ export const DEFAULT_LIMIT = 20
* @param {number} data.ressourceId the ressource ID
* @param {object} [options] optional options for axios
* @param {number} [options.offset] the pagination offset
* @return {object[]} the comments list
* @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({ commentsType, ressourceId }, options: { offset: number }) {
export const getComments = async function({ commentsType, ressourceId }, options: { offset: number, limit?: number, datetime?: Date }) {
const ressourcePath = ['', commentsType, ressourceId].join('/')
const datetime = options.datetime ? `<oc:datetime>${options.datetime.toISOString()}</oc:datetime>` : ''
const response = await client.customRequest(ressourcePath, Object.assign({
method: 'REPORT',
data: `<?xml version="1.0"?>
@ -50,15 +52,16 @@ export const getComments = async function({ commentsType, ressourceId }, options
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: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)
return processResponsePayload(response, stat, true) as ResponseDataDetailed<FileStat[]>
}
// https://github.com/perry-mitchell/webdav-client/blob/8d9694613c978ce7404e26a401c39a41f125f87f/source/operations/directoryContents.ts

@ -0,0 +1,70 @@
<!--
- @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
-
- @author Ferdinand Thiessen <opensource@fthiessen.de>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<Comment v-bind="editorData"
:auto-complete="autoComplete"
:user-data="userData"
:editor="true"
:ressource-id="ressourceId"
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>

@ -0,0 +1,86 @@
<!--
- @copyright Copyright (c) 2023 Ferdinand Thiessen <opensource@fthiessen.de>
-
- @author Ferdinand Thiessen <opensource@fthiessen.de>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<Comment ref="comment"
tag="li"
v-bind="comment.props"
:auto-complete="autoComplete"
:message="commentMessage"
:ressource-id="ressourceId"
:user-data="genMentionsData(comment.props.mentions)"
class="comments-activity"
@delete="reloadCallback()" />
</template>
<script lang="ts">
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,
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>

@ -82,11 +82,8 @@
</template>
<script>
import { generateOcsUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
import { showError } from '@nextcloud/dialogs'
import axios from '@nextcloud/axios'
import { translate as t } from '@nextcloud/l10n'
import VTooltip from 'v-tooltip'
import Vue from 'vue'
import VueObserveVisibility from 'vue-observe-visibility'
@ -101,6 +98,7 @@ import Comment from '../components/Comment.vue'
import { getComments, DEFAULT_LIMIT } from '../services/GetComments.ts'
import cancelableRequest from '../utils/cancelableRequest.js'
import { markCommentsAsRead } from '../services/ReadComments.ts'
import CommentView from '../mixins/CommentView'
Vue.use(VTooltip)
Vue.use(VueObserveVisibility)
@ -109,7 +107,6 @@ export default {
name: 'Comments',
components: {
// Avatar,
Comment,
NcEmptyContent,
NcButton,
@ -118,6 +115,8 @@ export default {
AlertCircleOutlineIcon,
},
mixins: [CommentView],
data() {
return {
error: '',
@ -130,12 +129,6 @@ export default {
cancelRequest: () => {},
editorData: {
actorDisplayName: getCurrentUser().displayName,
actorId: getCurrentUser().uid,
key: 'editor',
},
Comment,
userData: {},
}
@ -151,6 +144,8 @@ export default {
},
methods: {
t,
async onVisibilityChange(isVisible) {
if (isVisible) {
try {
@ -188,28 +183,6 @@ export default {
this.getComments()
},
/**
* Make sure we have all mentions as Array of objects
*
* @param {any[]} mentions the mentions list
* @return {Record<string, object>}
*/
genMentionsData(mentions) {
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
},
/**
* Get the existing shares infos
*/
@ -255,27 +228,6 @@ 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/autocomplete/get'), {
params: {
search,
itemType: 'files',
itemId: this.ressourceId,
sorter: 'commenters|share-recipients',
limit: loadState('comments', 'maxAutoCompleteResults'),
},
})
// Save user data so it can be used by the editor to replace mentions
results.data.ocs.data.forEach(user => { this.userData[user.id] = user })
return callback(Object.values(this.userData))
},
/**
* Add newly created comment to the list
*

Loading…
Cancel
Save