Merge pull request #23173 from nextcloud/feat/comments-sidebar-vue

This commit is contained in:
John Molakvoæ 2020-10-20 20:46:21 +02:00 committed by GitHub
commit 766590d204
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
155 changed files with 1850 additions and 2621 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
!function(e){var n={};function t(r){if(n[r])return n[r].exports;var o=n[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,t),o.l=!0,o.exports}t.m=e,t.c=n,t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{enumerable:!0,get:r})},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},t.t=function(e,n){if(1&n&&(e=t(e)),8&n)return e;if(4&n&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(t.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&n&&"string"!=typeof e)for(var o in e)t.d(r,o,function(n){return e[n]}.bind(null,o));return r},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},t.p="/js/",t(t.s=853)}({853:function(e,n){function r(e,n,t,r,o,i,u){try{var c=e[i](u),a=c.value}catch(e){return void t(e)}c.done?n(a):Promise.resolve(a).then(r,o)}var o=null,i=new OCA.Files.Sidebar.Tab({id:"comments",name:t("comments","Comments"),icon:"icon-comment",mount:function(e,n,t){return(i=regeneratorRuntime.mark((function r(){return regeneratorRuntime.wrap((function(r){for(;;)switch(r.prev=r.next){case 0:return o&&o.$destroy(),o=new OCA.Comments.View("files",{parent:t}),r.next=4,o.update(n.id);case 4:o.$mount(e);case 5:case"end":return r.stop()}}),r)})),function(){var e=this,n=arguments;return new Promise((function(t,o){var u=i.apply(e,n);function c(e){r(u,t,o,c,a,"next",e)}function a(e){r(u,t,o,c,a,"throw",e)}c(void 0)}))})();var i},update:function(e){o.update(e.id)},destroy:function(){o.$destroy(),o=null},scrollBottomReached:function(){o.onScrollBottomReached()}});window.addEventListener("DOMContentLoaded",(function(){OCA.Files&&OCA.Files.Sidebar&&OCA.Files.Sidebar.registerTab(i)}))}});
//# sourceMappingURL=comments-tab.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -29,18 +29,30 @@ namespace OCA\Comments\Listener;
use OCA\Comments\AppInfo\Application;
use OCA\Files\Event\LoadSidebar;
use OCP\Comments\ICommentsManager;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Util;
class LoadSidebarScripts implements IEventListener {
/** @var ICommentsManager */
private $commentsManager;
public function __construct(ICommentsManager $commentsManager) {
$this->commentsManager = $commentsManager;
}
public function handle(Event $event): void {
if (!($event instanceof LoadSidebar)) {
return;
}
$this->commentsManager->load();
// TODO: make sure to only include the sidebar script when
// we properly split it between files list and sidebar
Util::addScript(Application::APP_ID, 'comments');
Util::addScript(Application::APP_ID, 'comments-tab');
}
}

View File

@ -1,167 +0,0 @@
/* eslint-disable */
/*
* Copyright (c) 2016
*
* This file is licensed under the Affero General Public License version 3
* or later.
*
* See the COPYING-README file.
*
*/
(function(OC, OCA) {
/**
* @class OCA.Comments.CommentCollection
* @classdesc
*
* Collection of comments assigned to a file
*
*/
var CommentCollection = OC.Backbone.Collection.extend(
/** @lends OCA.Comments.CommentCollection.prototype */ {
sync: OC.Backbone.davSync,
model: OCA.Comments.CommentModel,
/**
* Object type
*
* @type string
*/
_objectType: 'files',
/**
* Object id
*
* @type string
*/
_objectId: null,
/**
* True if there are no more page results left to fetch
*
* @type bool
*/
_endReached: false,
/**
* Number of comments to fetch per page
*
* @type int
*/
_limit: 20,
/**
* Initializes the collection
*
* @param {string} [options.objectType] object type
* @param {string} [options.objectId] object id
*/
initialize: function(models, options) {
options = options || {}
if (options.objectType) {
this._objectType = options.objectType
}
if (options.objectId) {
this._objectId = options.objectId
}
},
url: function() {
return OC.linkToRemote('dav') + '/comments/'
+ encodeURIComponent(this._objectType) + '/'
+ encodeURIComponent(this._objectId) + '/'
},
setObjectId: function(objectId) {
this._objectId = objectId
},
hasMoreResults: function() {
return !this._endReached
},
reset: function() {
this._endReached = false
this._summaryModel = null
return OC.Backbone.Collection.prototype.reset.apply(this, arguments)
},
/**
* Fetch the next set of results
*/
fetchNext: function(options) {
var self = this
if (!this.hasMoreResults()) {
return null
}
var body = '<?xml version="1.0" encoding="utf-8" ?>\n'
+ '<oc:filter-comments xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">\n'
// load one more so we know there is more
+ ' <oc:limit>' + (this._limit + 1) + '</oc:limit>\n'
+ ' <oc:offset>' + this.length + '</oc:offset>\n'
+ '</oc:filter-comments>\n'
options = options || {}
var success = options.success
options = _.extend({
remove: false,
parse: true,
data: body,
davProperties: CommentCollection.prototype.model.prototype.davProperties,
success: function(resp) {
if (resp.length <= self._limit) {
// no new entries, end reached
self._endReached = true
} else {
// remove last entry, for next page load
resp = _.initial(resp)
}
if (!self.set(resp, options)) {
return false
}
if (success) {
success.apply(null, arguments)
}
self.trigger('sync', 'REPORT', self, options)
}
}, options)
return this.sync('REPORT', this, options)
},
/**
* Returns the matching summary model
*
* @returns {OCA.Comments.CommentSummaryModel} summary model
*/
getSummaryModel: function() {
if (!this._summaryModel) {
this._summaryModel = new OCA.Comments.CommentSummaryModel({
id: this._objectId,
objectType: this._objectType
})
}
return this._summaryModel
},
/**
* Updates the read marker for this comment thread
*
* @param {Date} [date] optional date, defaults to now
* @param {Object} [options] backbone options
*/
updateReadMarker: function(date, options) {
options = options || {}
return this.getSummaryModel().save({
readMarker: (date || new Date()).toUTCString()
}, options)
}
})
OCA.Comments.CommentCollection = CommentCollection
})(OC, OCA)

View File

@ -1,93 +0,0 @@
/*
* Copyright (c) 2016
*
* This file is licensed under the Affero General Public License version 3
* or later.
*
* See the COPYING-README file.
*
*/
(function(OC, OCA) {
_.extend(OC.Files.Client, {
PROPERTY_FILEID: '{' + OC.Files.Client.NS_OWNCLOUD + '}id',
PROPERTY_MESSAGE: '{' + OC.Files.Client.NS_OWNCLOUD + '}message',
PROPERTY_ACTORTYPE: '{' + OC.Files.Client.NS_OWNCLOUD + '}actorType',
PROPERTY_ACTORID: '{' + OC.Files.Client.NS_OWNCLOUD + '}actorId',
PROPERTY_ISUNREAD: '{' + OC.Files.Client.NS_OWNCLOUD + '}isUnread',
PROPERTY_OBJECTID: '{' + OC.Files.Client.NS_OWNCLOUD + '}objectId',
PROPERTY_OBJECTTYPE: '{' + OC.Files.Client.NS_OWNCLOUD + '}objectType',
PROPERTY_ACTORDISPLAYNAME: '{' + OC.Files.Client.NS_OWNCLOUD + '}actorDisplayName',
PROPERTY_CREATIONDATETIME: '{' + OC.Files.Client.NS_OWNCLOUD + '}creationDateTime',
PROPERTY_MENTIONS: '{' + OC.Files.Client.NS_OWNCLOUD + '}mentions',
})
/**
* @class OCA.Comments.CommentModel
* @classdesc
*
* Comment
*
*/
const CommentModel = OC.Backbone.Model.extend(
/** @lends OCA.Comments.CommentModel.prototype */ {
sync: OC.Backbone.davSync,
defaults: {
actorType: 'users',
objectType: 'files',
},
davProperties: {
id: OC.Files.Client.PROPERTY_FILEID,
message: OC.Files.Client.PROPERTY_MESSAGE,
actorType: OC.Files.Client.PROPERTY_ACTORTYPE,
actorId: OC.Files.Client.PROPERTY_ACTORID,
actorDisplayName: OC.Files.Client.PROPERTY_ACTORDISPLAYNAME,
creationDateTime: OC.Files.Client.PROPERTY_CREATIONDATETIME,
objectType: OC.Files.Client.PROPERTY_OBJECTTYPE,
objectId: OC.Files.Client.PROPERTY_OBJECTID,
isUnread: OC.Files.Client.PROPERTY_ISUNREAD,
mentions: OC.Files.Client.PROPERTY_MENTIONS,
},
parse(data) {
return {
id: data.id,
message: data.message,
actorType: data.actorType,
actorId: data.actorId,
actorDisplayName: data.actorDisplayName,
creationDateTime: data.creationDateTime,
objectType: data.objectType,
objectId: data.objectId,
isUnread: (data.isUnread === 'true'),
mentions: this._parseMentions(data.mentions),
}
},
_parseMentions(mentions) {
if (_.isUndefined(mentions)) {
return {}
}
const result = {}
for (const i in mentions) {
const mention = mentions[i]
if (_.isUndefined(mention.localName) || mention.localName !== 'mention') {
continue
}
result[i] = {}
for (let child = mention.firstChild; child; child = child.nextSibling) {
if (_.isUndefined(child.localName) || !child.localName.startsWith('mention')) {
continue
}
result[i][child.localName] = child.textContent
}
}
return result
},
})
OCA.Comments.CommentModel = CommentModel
})(OC, OCA)

View File

@ -0,0 +1,32 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import CommentsInstance from './services/CommentsInstance'
// Init Comments
if (window.OCA && !window.OCA.Comments) {
Object.assign(window.OCA, { Comments: {} })
}
// Init Comments App view
Object.assign(window.OCA.Comments, { View: CommentsInstance })
console.debug('OCA.Comments.View initialized')

View File

@ -0,0 +1,58 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Init Comments tab component
let TabInstance = null
const commentTab = new OCA.Files.Sidebar.Tab({
id: 'comments',
name: t('comments', 'Comments'),
icon: 'icon-comment',
async mount(el, fileInfo, context) {
if (TabInstance) {
TabInstance.$destroy()
}
TabInstance = new OCA.Comments.View('files', {
// Better integration with vue parent component
parent: context,
})
// Only mount after we have all the info we need
await TabInstance.update(fileInfo.id)
TabInstance.$mount(el)
},
update(fileInfo) {
TabInstance.update(fileInfo.id)
},
destroy() {
TabInstance.$destroy()
TabInstance = null
},
scrollBottomReached() {
TabInstance.onScrollBottomReached()
},
})
window.addEventListener('DOMContentLoaded', function() {
if (OCA.Files && OCA.Files.Sidebar) {
OCA.Files.Sidebar.registerTab(commentTab)
}
})

View File

@ -1,17 +1,6 @@
import './app'
import './templates'
import './commentmodel'
import './commentcollection'
import './commentsummarymodel'
import './commentstabview'
import './commentsmodifymenu'
import './filesplugin'
import './activitytabviewplugin'
import './vendor/Caret.js/dist/jquery.caret.min'
import './vendor/At.js/dist/js/jquery.atwho.min'
import './style/autocomplete.scss'
import './style/comments.scss'
window.OCA.Comments = OCA.Comments

View File

@ -1,108 +0,0 @@
/*
* Copyright (c) 2018
*
* This file is licensed under the Affero General Public License version 3
* or later.
*
* See the COPYING-README file.
*
*/
(function() {
/**
* Construct a new CommentsModifyMenuinstance
* @constructs CommentsModifyMenu
* @memberof OC.Comments
* @private
*/
const CommentsModifyMenu = OC.Backbone.View.extend({
tagName: 'div',
className: 'commentsModifyMenu popovermenu bubble menu',
_scopes: [
{
name: 'edit',
displayName: t('comments', 'Edit comment'),
iconClass: 'icon-rename',
},
{
name: 'delete',
displayName: t('comments', 'Delete comment'),
iconClass: 'icon-delete',
},
],
initialize() {
},
events: {
'click a.action': '_onClickAction',
},
/**
* Event handler whenever an action has been clicked within the menu
*
* @param {Object} event event object
*/
_onClickAction(event) {
let $target = $(event.currentTarget)
if (!$target.hasClass('menuitem')) {
$target = $target.closest('.menuitem')
}
OC.hideMenus()
this.trigger('select:menu-item-clicked', event, $target.data('action'))
},
/**
* Renders the menu with the currently set items
*/
render() {
this.$el.html(OCA.Comments.Templates.commentsmodifymenu({
items: this._scopes,
}))
},
/**
* Displays the menu
* @param {Event} context the click event
*/
show(context) {
this._context = context
for (const i in this._scopes) {
this._scopes[i].active = false
}
const $el = $(context.target)
const offsetIcon = $el.offset()
const offsetContainer = $el.closest('.authorRow').offset()
// adding some extra top offset to push the menu below the button.
const position = {
top: offsetIcon.top - offsetContainer.top + 48,
left: '',
right: '',
}
position.left = offsetIcon.left - offsetContainer.left
if (position.left > 200) {
// we need to position the menu to the right.
position.left = ''
position.right = this.$el.closest('.comment').find('.date').width()
this.$el.removeClass('menu-left').addClass('menu-right')
} else {
this.$el.removeClass('menu-right').addClass('menu-left')
}
this.$el.css(position)
this.render()
this.$el.removeClass('hidden')
OC.showMenu(null, this.$el)
},
})
OCA.Comments = OCA.Comments || {}
OCA.Comments.CommentsModifyMenu = CommentsModifyMenu
})(OC, OCA)

View File

@ -1,756 +0,0 @@
/* eslint-disable */
/**
* Copyright (c) 2016
*
* This file is licensed under the Affero General Public License version 3
* or later.
*
* See the COPYING-README file.
*
*/
/* global Handlebars */
import escapeHTML from 'escape-html'
(function(OC, OCA) {
/**
* @memberof OCA.Comments
*/
var CommentsTabView = OCA.Files.DetailTabView.extend(
/** @lends OCA.Comments.CommentsTabView.prototype */ {
id: 'commentsTabView',
className: 'tab commentsTabView',
_autoCompleteData: undefined,
_commentsModifyMenu: undefined,
events: {
'submit .newCommentForm': '_onSubmitComment',
'click .showMore': '_onClickShowMore',
'click .cancel': '_onClickCloseComment',
'click .comment': '_onClickComment',
'keyup div.message': '_onTextChange',
'change div.message': '_onTextChange',
'input div.message': '_onTextChange',
'paste div.message': '_onPaste'
},
_commentMaxLength: 1000,
initialize: function() {
OCA.Files.DetailTabView.prototype.initialize.apply(this, arguments)
this.collection = new OCA.Comments.CommentCollection()
this.collection.on('request', this._onRequest, this)
this.collection.on('sync', this._onEndRequest, this)
this.collection.on('add', this._onAddModel, this)
this.collection.on('change:message', this._onChangeModel, this)
this._commentMaxThreshold = this._commentMaxLength * 0.9
// TODO: error handling
_.bindAll(this, '_onTypeComment', '_initAutoComplete', '_onAutoComplete')
},
template: function(params) {
var currentUser = OC.getCurrentUser()
return OCA.Comments.Templates['view'](_.extend({
actorId: currentUser.uid,
actorDisplayName: currentUser.displayName
}, params))
},
editCommentTemplate: function(params) {
var currentUser = OC.getCurrentUser()
return OCA.Comments.Templates['edit_comment'](_.extend({
actorId: currentUser.uid,
actorDisplayName: currentUser.displayName,
newMessagePlaceholder: t('comments', 'New comment …'),
submitText: t('comments', 'Post'),
cancelText: t('comments', 'Cancel'),
tag: 'li'
}, params))
},
commentTemplate: function(params) {
params = _.extend({
editTooltip: t('comments', 'Edit comment'),
isUserAuthor: OC.getCurrentUser().uid === params.actorId,
isLong: this._isLong(params.message)
}, params)
if (params.actorType === 'deleted_users') {
// makes the avatar a X
params.actorId = null
params.actorDisplayName = t('comments', '[Deleted user]')
}
return OCA.Comments.Templates['comment'](params)
},
getLabel: function() {
return t('comments', 'Comments')
},
getIcon: function() {
return 'icon-comment'
},
setFileInfo: function(fileInfo) {
if (fileInfo) {
this.model = fileInfo
this.render()
this._initAutoComplete($('#commentsTabView').find('.newCommentForm .message'))
this.collection.setObjectId(this.model.id)
// reset to first page
this.collection.reset([], { silent: true })
this.nextPage()
} else {
this.model = null
this.render()
this.collection.reset()
}
},
render: function() {
this.$el.html(this.template({
emptyResultLabel: t('comments', 'No comments yet, start the conversation!'),
moreLabel: t('comments', 'More comments …')
}))
this.$el.find('.comments').before(this.editCommentTemplate({ tag: 'div' }))
this.$el.find('.has-tooltip').tooltip()
this.$container = this.$el.find('ul.comments')
this.$el.find('.avatar').avatar(OC.getCurrentUser().uid, 32)
this.delegateEvents()
this.$el.find('.message').on('keydown input change', this._onTypeComment)
autosize(this.$el.find('.newCommentRow .message'))
this.$el.find('.newCommentForm .message').focus()
},
_initAutoComplete: function($target) {
var s = this
var limit = 10
if (!_.isUndefined(OC.appConfig.comments)) {
limit = OC.appConfig.comments.maxAutoCompleteResults
}
$target.atwho({
at: '@',
limit: limit,
callbacks: {
remoteFilter: s._onAutoComplete,
highlighter: function(li) {
// misuse the highlighter callback to instead of
// highlighting loads the avatars.
var $li = $(li)
$li.find('.avatar').avatar(undefined, 32)
return $li
},
sorter: function(q, items) { return items }
},
displayTpl: function(item) {
return '<li>'
+ '<span class="avatar-name-wrapper">'
+ '<span class="avatar" '
+ 'data-username="' + escapeHTML(item.id) + '" ' // for avatars
+ 'data-user="' + escapeHTML(item.id) + '" ' // for contactsmenu
+ 'data-user-display-name="' + escapeHTML(item.label) + '">'
+ '</span>'
+ '<strong>' + escapeHTML(item.label) + '</strong>'
+ '</span></li>'
},
insertTpl: function(item) {
return ''
+ '<span class="avatar-name-wrapper">'
+ '<span class="avatar" '
+ 'data-username="' + escapeHTML(item.id) + '" ' // for avatars
+ 'data-user="' + escapeHTML(item.id) + '" ' // for contactsmenu
+ 'data-user-display-name="' + escapeHTML(item.label) + '">'
+ '</span>'
+ '<strong>' + escapeHTML(item.label) + '</strong>'
+ '</span>'
},
searchKey: 'label'
})
$target.on('inserted.atwho', function(je, $el) {
var editionMode = true
s._postRenderItem(
// we need to pass the parent of the inserted element
// passing the whole comments form would re-apply and request
// avatars from the server
$(je.target).find(
'span[data-username="' + $el.find('[data-username]').data('username') + '"]'
).parent(),
editionMode
)
})
},
_onAutoComplete: function(query, callback) {
var s = this
if (!_.isUndefined(this._autoCompleteRequestTimer)) {
clearTimeout(this._autoCompleteRequestTimer)
}
this._autoCompleteRequestTimer = _.delay(function() {
if (!_.isUndefined(this._autoCompleteRequestCall)) {
this._autoCompleteRequestCall.abort()
}
this._autoCompleteRequestCall = $.ajax({
url: OC.linkToOCS('core', 2) + 'autocomplete/get',
data: {
search: query,
itemType: 'files',
itemId: s.model.get('id'),
sorter: 'commenters|share-recipients',
limit: OC.appConfig.comments.maxAutoCompleteResults
},
beforeSend: function(request) {
request.setRequestHeader('Accept', 'application/json')
},
success: function(result) {
callback(result.ocs.data)
}
})
}, 400)
},
_formatItem: function(commentModel) {
var timestamp = new Date(commentModel.get('creationDateTime')).getTime()
var data = _.extend({
timestamp: timestamp,
date: OC.Util.relativeModifiedDate(timestamp),
altDate: OC.Util.formatDate(timestamp),
formattedMessage: this._formatMessage(commentModel.get('message'), commentModel.get('mentions'))
}, commentModel.attributes)
return data
},
_toggleLoading: function(state) {
this._loading = state
this.$el.find('.loading').toggleClass('hidden', !state)
},
_onRequest: function(type) {
if (type === 'REPORT') {
this._toggleLoading(true)
this.$el.find('.showMore').addClass('hidden')
}
},
_onEndRequest: function(type) {
var fileInfoModel = this.model
this._toggleLoading(false)
this.$el.find('.emptycontent').toggleClass('hidden', !!this.collection.length)
this.$el.find('.showMore').toggleClass('hidden', !this.collection.hasMoreResults())
if (type !== 'REPORT') {
return
}
// find first unread comment
var firstUnreadComment = this.collection.findWhere({ isUnread: true })
if (firstUnreadComment) {
// update read marker
this.collection.updateReadMarker(
null,
{
success: function() {
fileInfoModel.set('commentsUnread', 0)
}
}
)
}
this.$el.find('.newCommentForm .message').focus()
},
/**
* takes care of post-rendering after a new comment was added to the
* collection
*
* @param model
* @param collection
* @param options
* @private
*/
_onAddModel: function(model, collection, options) {
// we need to render it immediately, to ensure that the right
// order of comments is kept on opening comments tab
var $comment = $(this.commentTemplate(this._formatItem(model)))
if (!_.isUndefined(options.at) && collection.length > 1) {
this.$container.find('li').eq(options.at).before($comment)
} else {
this.$container.append($comment)
}
this._postRenderItem($comment)
$('#commentsTabView').find('.newCommentForm div.message').text('').prop('contenteditable', true)
// we need to update the model, because it consists of client data
// only, but the server might add meta data, e.g. about mentions
var oldMentions = model.get('mentions')
var self = this
model.fetch({
success: function(model) {
if (_.isEqual(oldMentions, model.get('mentions'))) {
// don't attempt to render if unnecessary, avoids flickering
return
}
var $updated = $(self.commentTemplate(self._formatItem(model)))
$comment.html($updated.html())
self._postRenderItem($comment)
}
})
},
/**
* takes care of post-rendering after a new comment was edited
*
* @param model
* @private
*/
_onChangeModel: function(model) {
if (model.get('message').trim() === model.previous('message').trim()) {
return
}
var $form = this.$container.find('.comment[data-id="' + model.id + '"] form')
var $row = $form.closest('.comment')
var $target = $row.data('commentEl')
if (_.isUndefined($target)) {
// ignore noise this is only set after editing a comment and hitting post
return
}
var self = this
// we need to update the model, because it consists of client data
// only, but the server might add meta data, e.g. about mentions
model.fetch({
success: function(model) {
$target.removeClass('hidden')
$row.remove()
var $message = $target.find('.message')
$message
.html(self._formatMessage(model.get('message'), model.get('mentions')))
.find('.avatar')
.each(function() { $(this).avatar() })
self._postRenderItem($message)
}
})
},
_postRenderItem: function($el, editionMode) {
$el.find('.has-tooltip').tooltip()
var inlineAvatars = $el.find('.message .avatar')
if ($($el.context).hasClass('message')) {
inlineAvatars = $el.find('.avatar')
}
inlineAvatars.each(function() {
var $this = $(this)
$this.avatar($this.attr('data-username'), 16)
})
$el.find('.authorRow .avatar').each(function() {
var $this = $(this)
$this.avatar($this.attr('data-username'), 32)
})
var username = $el.find('.avatar').data('username')
if (username !== OC.getCurrentUser().uid) {
$el.find('.authorRow .avatar, .authorRow .author').contactsMenu(
username, 0, $el.find('.authorRow'))
}
var $message = $el.find('.message')
if ($message.length === 0) {
// it is the case when writing a comment and mentioning a person
$message = $el
}
if (!editionMode) {
var self = this
// add the dropdown menu to display the edit and delete option
var modifyCommentMenu = new OCA.Comments.CommentsModifyMenu()
$el.find('.authorRow').append(modifyCommentMenu.$el)
$el.find('.more').on('click', _.bind(modifyCommentMenu.show, modifyCommentMenu))
self.listenTo(modifyCommentMenu, 'select:menu-item-clicked', function(ev, action) {
if (action === 'edit') {
self._onClickEditComment(ev)
} else if (action === 'delete') {
self._onClickDeleteComment(ev)
}
})
}
this._postRenderMessage($message, editionMode)
},
_postRenderMessage: function($el, editionMode) {
if (editionMode) {
return
}
$el.find('.avatar-name-wrapper').each(function() {
var $this = $(this)
var $avatar = $this.find('.avatar')
var user = $avatar.data('user')
if (user !== OC.getCurrentUser().uid) {
$this.contactsMenu(user, 0, $this)
}
})
},
/**
* Convert a message to be displayed in HTML,
* converts newlines to <br> tags.
*/
_formatMessage: function(message, mentions, editMode) {
message = escapeHTML(message).replace(/\n/g, '<br/>')
for (var i in mentions) {
if (!mentions.hasOwnProperty(i)) {
return
}
var mention = '@' + mentions[i].mentionId
if (mentions[i].mentionId.indexOf(' ') !== -1) {
mention = _.escape('@"' + mentions[i].mentionId + '"')
}
// escape possible regex characters in the name
mention = mention.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
var regex = new RegExp('(^|\\s)(' + mention + ')\\b', 'g')
if (mentions[i].mentionId.indexOf(' ') !== -1) {
regex = new RegExp('(^|\\s)(' + mention + ')', 'g')
}
var displayName = this._composeHTMLMention(mentions[i].mentionId, mentions[i].mentionDisplayName)
// replace every mention either at the start of the input or after a whitespace
// followed by a non-word character.
message = message.replace(regex,
function(match, p1) {
// to get number of whitespaces (0 vs 1) right
return p1 + displayName
}
)
}
if (editMode !== true) {
message = OCP.Comments.plainToRich(message)
}
return message
},
_composeHTMLMention: function(uid, displayName) {
var avatar = ''
+ '<span class="avatar" '
+ 'data-username="' + _.escape(uid) + '" '
+ 'data-user="' + _.escape(uid) + '" '
+ 'data-user-display-name="' + _.escape(displayName) + '">'
+ '</span>'
var isCurrentUser = (uid === OC.getCurrentUser().uid)
return ''
+ '<span class="atwho-inserted" contenteditable="false">'
+ '<span class="avatar-name-wrapper' + (isCurrentUser ? ' currentUser' : '') + '">'
+ avatar
+ '<strong>' + _.escape(displayName) + '</strong>'
+ '</span>'
+ '</span>'
},
nextPage: function() {
if (this._loading || !this.collection.hasMoreResults()) {
return
}
this.collection.fetchNext()
},
_onClickEditComment: function(ev) {
ev.preventDefault()
var $comment = $(ev.target).closest('.comment')
var commentId = $comment.data('id')
var commentToEdit = this.collection.get(commentId)
var $formRow = $(this.editCommentTemplate(_.extend({
isEditMode: true,
submitText: t('comments', 'Save')
}, commentToEdit.attributes)))
$comment.addClass('hidden').removeClass('collapsed')
// spawn form
$comment.after($formRow)
$formRow.data('commentEl', $comment)
$formRow.find('.message').on('keydown input change', this._onTypeComment)
// copy avatar element from original to avoid flickering
$formRow.find('.avatar:first').replaceWith($comment.find('.avatar:first').clone())
$formRow.find('.has-tooltip').tooltip()
var $message = $formRow.find('.message')
$message
.html(this._formatMessage(commentToEdit.get('message'), commentToEdit.get('mentions'), true))
.find('.avatar')
.each(function() { $(this).avatar() })
var editionMode = true
this._postRenderItem($message, editionMode)
// Enable autosize
autosize($formRow.find('.message'))
// enable autocomplete
this._initAutoComplete($formRow.find('.message'))
return false
},
_onTypeComment: function(ev) {
var $field = $(ev.target)
var len = $field.text().length
var $submitButton = $field.data('submitButtonEl')
if (!$submitButton) {
$submitButton = $field.closest('form').find('.submit')
$field.data('submitButtonEl', $submitButton)
}
$field.tooltip('hide')
if (len > this._commentMaxThreshold) {
$field.attr('data-original-title', t('comments', 'Allowed characters {count} of {max}', { count: len, max: this._commentMaxLength }))
$field.tooltip({ trigger: 'manual' })
$field.tooltip('show')
$field.addClass('error')
}
var limitExceeded = (len > this._commentMaxLength)
$field.toggleClass('error', limitExceeded)
$submitButton.prop('disabled', limitExceeded)
// Submits form with Enter, but Shift+Enter is a new line. If the
// autocomplete popover is being shown Enter does not submit the
// form either; it will be handled by At.js which will add the
// currently selected item to the message.
if (ev.keyCode === 13 && !ev.shiftKey && !$field.atwho('isSelecting')) {
$submitButton.click()
ev.preventDefault()
}
},
_onClickComment: function(ev) {
var $row = $(ev.target)
if (!$row.is('.comment')) {
$row = $row.closest('.comment')
}
$row.removeClass('collapsed')
},
_onClickCloseComment: function(ev) {
ev.preventDefault()
var $row = $(ev.target).closest('.comment')
$row.data('commentEl').removeClass('hidden')
$row.remove()
return false
},
_onClickDeleteComment: function(ev) {
ev.preventDefault()
var $comment = $(ev.target).closest('.comment')
var commentId = $comment.data('id')
var $loading = $comment.find('.deleteLoading')
var $moreIcon = $comment.find('.more')
$comment.addClass('disabled')
$loading.removeClass('hidden')
$moreIcon.addClass('hidden')
$comment.data('commentEl', $comment)
this.collection.get(commentId).destroy({
success: function() {
$comment.data('commentEl').remove()
$comment.remove()
},
error: function() {
$loading.addClass('hidden')
$moreIcon.removeClass('hidden')
$comment.removeClass('disabled')
OC.Notification.showTemporary(t('comments', 'Error occurred while retrieving comment with ID {id}', { id: commentId }))
}
})
return false
},
_onClickShowMore: function(ev) {
ev.preventDefault()
this.nextPage()
},
/**
* takes care of updating comment element states after submit (either new
* comment or edit).
*
* @param {OC.Backbone.Model} model
* @param {jQuery} $form
* @private
*/
_onSubmitSuccess: function(model, $form) {
var $submit = $form.find('.submit')
var $loading = $form.find('.submitLoading')
var $message = $form.find('.message')
$submit.removeClass('hidden')
$loading.addClass('hidden')
$message.prop('contenteditable', true)
$message.text('')
},
_commentBodyHTML2Plain: function($el) {
var $comment = $el.clone()
$comment.find('.avatar-name-wrapper').each(function() {
var $this = $(this)
var $inserted = $this.parent()
var userId = $this.find('.avatar').data('username').toString()
if (userId.indexOf(' ') !== -1) {
$inserted.html('@"' + userId + '"')
} else {
$inserted.html('@' + userId)
}
})
$comment.html(OCP.Comments.richToPlain($comment.html()))
var oldHtml
var html = $comment.html()
do {
// replace works one by one
oldHtml = html
html = oldHtml.replace('<br>', '\n') // preserve line breaks
} while (oldHtml !== html)
$comment.html(html)
return $comment.text()
},
_onSubmitComment: function(e) {
var self = this
var $form = $(e.target)
var commentId = $form.closest('.comment').data('id')
var currentUser = OC.getCurrentUser()
var $submit = $form.find('.submit')
var $loading = $form.find('.submitLoading')
var $commentField = $form.find('.message')
var message = $commentField.text().trim()
e.preventDefault()
if (!message.length || message.length > this._commentMaxLength) {
return
}
$commentField.prop('contenteditable', false)
$submit.addClass('hidden')
$loading.removeClass('hidden')
message = this._commentBodyHTML2Plain($commentField)
if (commentId) {
// edit mode
var comment = this.collection.get(commentId)
comment.save({
message: message
}, {
success: function(model) {
self._onSubmitSuccess(model, $form)
if (model.get('message').trim() === model.previous('message').trim()) {
// model change event doesn't trigger, manually remove the row.
var $row = $form.closest('.comment')
$row.data('commentEl').removeClass('hidden')
$row.remove()
}
},
error: function() {
self._onSubmitError($form, commentId)
}
})
} else {
this.collection.create({
actorId: currentUser.uid,
actorDisplayName: currentUser.displayName,
actorType: 'users',
verb: 'comment',
message: message,
creationDateTime: (new Date()).toUTCString()
}, {
at: 0,
// wait for real creation before adding
wait: true,
success: function(model) {
self._onSubmitSuccess(model, $form)
},
error: function() {
self._onSubmitError($form, undefined)
}
})
}
return false
},
/**
* takes care of updating the UI after an error on submit (either new
* comment or edit).
*
* @param {jQuery} $form
* @param {string|undefined} commentId
* @private
*/
_onSubmitError: function($form, commentId) {
$form.find('.submit').removeClass('hidden')
$form.find('.submitLoading').addClass('hidden')
$form.find('.message').prop('contenteditable', true)
if (!_.isUndefined(commentId)) {
OC.Notification.show(t('comments', 'Error occurred while updating comment with id {id}', { id: commentId }), { type: 'error' })
} else {
OC.Notification.show(t('comments', 'Error occurred while posting comment'), { type: 'error' })
}
},
/**
* ensures the contenteditable div is really empty, when user removed
* all input, so that the placeholder will be shown again
*
* @private
*/
_onTextChange: function() {
var $message = $('#commentsTabView').find('.newCommentForm div.message')
if (!$message.text().trim().length) {
$message.empty()
}
},
/**
* Limit pasting to plain text
*
* @param e
* @private
*/
_onPaste: function(e) {
e.preventDefault()
var text = e.originalEvent.clipboardData.getData('text/plain')
document.execCommand('insertText', false, text)
},
/**
* Returns whether the given message is long and needs
* collapsing
*/
_isLong: function(message) {
return message.length > 250 || (message.match(/\n/g) || []).length > 1
}
})
OCA.Comments.CommentsTabView = CommentsTabView
})(OC, OCA)

View File

@ -1,70 +0,0 @@
/*
* Copyright (c) 2016
*
* This file is licensed under the Affero General Public License version 3
* or later.
*
* See the COPYING-README file.
*
*/
(function(OC, OCA) {
_.extend(OC.Files.Client, {
PROPERTY_READMARKER: '{' + OC.Files.Client.NS_OWNCLOUD + '}readMarker',
})
/**
* @class OCA.Comments.CommentSummaryModel
* @classdesc
*
* Model containing summary information related to comments
* like the read marker.
*
*/
const CommentSummaryModel = OC.Backbone.Model.extend(
/** @lends OCA.Comments.CommentSummaryModel.prototype */ {
sync: OC.Backbone.davSync,
/**
* Object type
*
* @type string
*/
_objectType: 'files',
/**
* Object id
*
* @type string
*/
_objectId: null,
davProperties: {
readMarker: OC.Files.Client.PROPERTY_READMARKER,
},
/**
* Initializes the summary model
*
* @param {any} [attrs] ignored
* @param {Object} [options] destructuring object
* @param {string} [options.objectType] object type
* @param {string} [options.objectId] object id
*/
initialize(attrs, options) {
options = options || {}
if (options.objectType) {
this._objectType = options.objectType
}
},
url() {
return OC.linkToRemote('dav') + '/comments/'
+ encodeURIComponent(this._objectType) + '/'
+ encodeURIComponent(this.id) + '/'
},
})
OCA.Comments.CommentSummaryModel = CommentSummaryModel
})(OC, OCA)

View File

@ -0,0 +1,298 @@
<!--
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div v-show="!deleted"
:class="{'comment--loading': loading}"
class="comment">
<!-- Comment header toolbar -->
<div class="comment__header">
<!-- Author -->
<Avatar class="comment__avatar"
:display-name="actorDisplayName"
:user="actorId"
:size="32" />
<span class="comment__author">{{ actorDisplayName }}</span>
<!-- Comment actions,
show if we have a message id and current user is author -->
<Actions v-if="isOwnComment && id && !loading" class="comment__actions">
<template v-if="!editing">
<ActionButton
:close-after-click="true"
icon="icon-rename"
@click="onEdit">
{{ t('comments', 'Edit comment') }}
</ActionButton>
<ActionSeparator />
<ActionButton
:close-after-click="true"
icon="icon-delete"
@click="onDeleteWithUndo">
{{ t('comments', 'Delete comment') }}
</ActionButton>
</template>
<ActionButton v-else
icon="icon-close"
@click="onEditCancel">
{{ t('comments', 'Cancel edit') }}
</ActionButton>
</Actions>
<!-- Show loading if we're editing or deleting, not on new ones -->
<div v-if="id && loading" class="comment_loading icon-loading-small" />
<!-- Relative time to the comment creation -->
<Moment v-else-if="creationDateTime" class="comment__timestamp" :timestamp="timestamp" />
</div>
<!-- Message editor -->
<div class="comment__message" v-if="editor || editing">
<RichContenteditable v-model="localMessage"
:auto-complete="autoComplete"
:contenteditable="!loading"
@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>
<!-- Message content -->
<!-- The html is escaped and sanitized before rendering -->
<!-- eslint-disable-next-line vue/no-v-html-->
<div v-else class="comment__message" v-html="renderedContent" />
</div>
</template>
<script>
import { getCurrentUser } from '@nextcloud/auth'
import moment from 'moment'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton'
import Actions from '@nextcloud/vue/dist/Components/Actions'
import ActionSeparator from '@nextcloud/vue/dist/Components/ActionSeparator'
import Avatar from '@nextcloud/vue/dist/Components/Avatar'
import RichContenteditable from '@nextcloud/vue/dist/Components/RichContenteditable'
import RichEditorMixin from '@nextcloud/vue/dist/Mixins/richEditor'
import Moment from './Moment'
import CommentMixin from '../mixins/CommentMixin'
export default {
name: 'Comment',
components: {
ActionButton,
Actions,
ActionSeparator,
Avatar,
Moment,
RichContenteditable,
},
mixins: [RichEditorMixin, CommentMixin],
inheritAttrs: false,
props: {
source: {
type: Object,
default: () => ({}),
},
actorDisplayName: {
type: String,
required: true,
},
actorId: {
type: String,
required: true,
},
creationDateTime: {
type: String,
default: null,
},
/**
* Force the editor display
*/
editor: {
type: Boolean,
default: false,
},
/**
* Provide the autocompletion data
*/
autoComplete: {
type: Function,
required: true,
},
},
data() {
return {
// Only change data locally and update the original
// parent data when the request is sent and resolved
localMessage: '',
}
},
computed: {
/**
* Is the current user the author of this comment
* @returns {boolean}
*/
isOwnComment() {
return getCurrentUser().uid === this.actorId
},
/**
* Rendered content as html string
* @returns {string}
*/
renderedContent() {
if (this.isEmptyMessage) {
return ''
}
return this.renderContent(this.localMessage)
},
isEmptyMessage() {
return !this.localMessage || this.localMessage.trim() === ''
},
timestamp() {
// seconds, not milliseconds
return parseInt(moment(this.creationDateTime).format('x'), 10) / 1000
},
},
watch: {
// If the data change, update the local value
message(message) {
this.updateLocalMessage(message)
},
},
beforeMount() {
// Init localMessage
this.updateLocalMessage(this.message)
},
methods: {
/**
* Update local Message on outer change
* @param {string} message the message to set
*/
updateLocalMessage(message) {
this.localMessage = message.toString()
},
/**
* Dispatch message between edit and create
*/
onSubmit() {
if (this.editor) {
this.onNewComment(this.localMessage)
return
}
this.onEditComment(this.localMessage)
},
},
}
</script>
<style lang="scss" scoped>
$comment-padding: 10px;
.comment {
position: relative;
padding: $comment-padding 0 $comment-padding * 1.5;
&__header {
display: flex;
align-items: center;
min-height: 44px;
padding: $comment-padding / 2 0;
}
&__author,
&__actions {
margin-left: $comment-padding !important;
}
&__author {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--color-text-maxcontrast);
}
&_loading,
&__timestamp {
margin-left: auto;
color: var(--color-text-maxcontrast);
}
&__message {
position: relative;
// Avatar size, align with author name
padding-left: 32px + $comment-padding;
}
&__submit {
position: absolute;
right: 0;
bottom: 0;
width: 44px;
height: 44px;
// Align with input border
margin: 1px;
cursor: pointer;
opacity: .7;
border: none;
background-color: transparent !important;
&:disabled {
cursor: not-allowed;
opacity: .5;
}
&:focus,
&:hover {
opacity: 1;
}
}
}
.rich-contenteditable__input {
margin: 0;
padding: $comment-padding;
min-height: 44px;
}
</style>

View File

@ -0,0 +1,31 @@
<!-- TODO: Move to vue components -->
<template>
<span class="live-relative-timestamp" :data-timestamp="timestamp * 1000" :title="title">{{ formatted }}</span>
</template>
<script>
import moment from '@nextcloud/moment'
export default {
name: 'Moment',
props: {
timestamp: {
type: Number,
required: true,
},
format: {
type: String,
default: 'LLL',
},
},
computed: {
title() {
return moment.unix(this.timestamp).format(this.format)
},
formatted() {
return moment.unix(this.timestamp).fromNow()
},
},
}
</script>

View File

@ -45,8 +45,6 @@
return
}
fileList.registerTabView(new OCA.Comments.CommentsTabView('commentsTabView'))
const oldGetWebdavProperties = fileList._getWebdavProperties
fileList._getWebdavProperties = function() {
const props = oldGetWebdavProperties.apply(this, arguments)
@ -104,7 +102,8 @@
actionHandler(fileName, context) {
context.$file.find('.action-comment').tooltip('hide')
// open sidebar in comments section
context.fileList.showDetailsView(fileName, 'comments')
OCA.Files.Sidebar.setActiveTab('comments')
OCA.Files.Sidebar.open('/' + fileName)
},
})

View File

@ -0,0 +1,117 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import NewComment from '../services/NewComment'
import DeleteComment from '../services/DeleteComment'
import EditComment from '../services/EditComment'
import { showError, showUndo, TOAST_UNDO_TIMEOUT } from '@nextcloud/dialogs'
export default {
props: {
id: {
type: Number,
default: null,
},
message: {
// GenFileInfo can convert message as numbers if they doesn't contains text
type: [String, Number],
default: '',
},
ressourceId: {
type: [String, Number],
required: true,
},
},
data() {
return {
deleted: false,
editing: false,
loading: false,
}
},
methods: {
// EDITION
onEdit() {
this.editing = true
},
onEditCancel() {
this.editing = false
// Restore original value
this.updateLocalMessage(this.message)
},
async onEditComment(message) {
this.loading = true
try {
await EditComment(this.commentsType, this.ressourceId, this.id, message)
this.logger.debug('Comment edited', { commentsType: this.commentsType, ressourceId: this.ressourceId, id: this.id, message })
this.$emit('update:message', message)
this.editing = false
} catch (error) {
showError(t('comments', 'An error occurred while trying to edit the comment'))
console.error(error)
} finally {
this.loading = false
}
},
// DELETION
onDeleteWithUndo() {
this.deleted = true
const timeOutDelete = setTimeout(this.onDelete, TOAST_UNDO_TIMEOUT)
showUndo(t('comments', 'Comment deleted'), () => {
clearTimeout(timeOutDelete)
this.deleted = false
})
},
async onDelete() {
try {
await DeleteComment(this.commentsType, this.ressourceId, this.id)
this.logger.debug('Comment deleted', { commentsType: this.commentsType, ressourceId: this.ressourceId, id: this.id })
this.$emit('delete', this.id)
} catch (error) {
showError(t('comments', 'An error occurred while trying to delete the comment'))
console.error(error)
this.deleted = false
}
},
// CREATION
async onNewComment(message) {
this.loading = true
try {
const newComment = await NewComment(this.commentsType, this.ressourceId, message)
this.logger.debug('New comment posted', { commentsType: this.commentsType, ressourceId: this.ressourceId, newComment })
this.$emit('new', newComment)
// Clear old content
this.$emit('update:message', '')
this.localMessage = ''
} catch (error) {
showError(t('comments', 'An error occurred while trying to create the comment'))
console.error(error)
} finally {
this.loading = false
}
},
},
}

View File

@ -0,0 +1,69 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { getLoggerBuilder } from '@nextcloud/logger'
import { translate as t, translatePlural as n } from '@nextcloud/l10n'
import CommentsApp from '../views/Comments'
import Vue from 'vue'
const logger = getLoggerBuilder()
.setApp('comments')
.detectUser()
.build()
// Add translates functions
Vue.mixin({
data() {
return {
logger,
}
},
methods: {
t,
n,
},
})
export default class CommentInstance {
/**
* Initialize a new Comments instance for the desired type
*
* @param {string} commentsType the comments endpoint type
* @param {Object} options the vue options (propsData, parent, el...)
*/
constructor(commentsType = 'files', options) {
// Add comments type as a global mixin
Vue.mixin({
data() {
return {
commentsType,
}
},
})
// Init Comments component
const View = Vue.extend(CommentsApp)
return new View(options)
}
}

View File

@ -0,0 +1,37 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import webdav from 'webdav'
import axios from '@nextcloud/axios'
import { getRootPath } from '../utils/davUtils'
// Add this so the server knows it is an request from the browser
axios.defaults.headers['X-Requested-With'] = 'XMLHttpRequest'
// force our axios
const patcher = webdav.getPatcher()
patcher.patch('request', axios)
// init webdav client
const client = webdav.createClient(getRootPath())
export default client

View File

@ -0,0 +1,37 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import client from './DavClient'
/**
* Delete a comment
*
* @param {string} commentsType the ressource type
* @param {number} ressourceId the ressource ID
* @param {number} commentId the comment iD
*/
export default async function(commentsType, ressourceId, commentId) {
const commentPath = ['', commentsType, ressourceId, commentId].join('/')
// Fetch newly created comment data
await client.deleteFile(commentPath)
}

View File

@ -0,0 +1,49 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import client from './DavClient'
/**
* Edit an existing comment
*
* @param {string} commentsType the ressource type
* @param {number} ressourceId the ressource ID
* @param {number} commentId the comment iD
* @param {string} message the message content
*/
export default async function(commentsType, ressourceId, commentId, message) {
const commentPath = ['', commentsType, ressourceId, commentId].join('/')
return await client.customRequest(commentPath, Object.assign({
method: 'PROPPATCH',
data: `<?xml version="1.0"?>
<d:propertyupdate
xmlns:d="DAV:"
xmlns:oc="http://owncloud.org/ns">
<d:set>
<d:prop>
<oc:message>${message}</oc:message>
</d:prop>
</d:set>
</d:propertyupdate>`,
}))
}

View File

@ -0,0 +1,80 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { parseXML, prepareFileFromProps } from 'webdav/dist/node/interface/dav'
import { processResponsePayload } from 'webdav/dist/node/response'
import client from './DavClient'
import { genFileInfo } from '../utils/fileUtils'
export const DEFAULT_LIMIT = 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.map(genFileInfo))
}
// https://github.com/perry-mitchell/webdav-client/blob/9de2da4a2599e06bd86c2778145b7ade39fe0b3c/source/interface/directoryContents.js#L32
function processMultistatus(result, isDetailed = false) {
// Extract the response items (directory contents)
const {
multistatus: { response: responseItems },
} = result
return responseItems.map(item => {
// Each item should contain a stat object
const {
propstat: { prop: props },
} = item
return prepareFileFromProps(props, props.id.toString(), isDetailed)
})
}

View File

@ -0,0 +1,60 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { genFileInfo } from '../utils/fileUtils'
import { getCurrentUser } from '@nextcloud/auth'
import { getRootPath } from '../utils/davUtils'
import axios from '@nextcloud/axios'
import client from './DavClient'
/**
* Retrieve the comments list
*
* @param {string} commentsType the ressource type
* @param {number} ressourceId the ressource ID
* @param {string} message the message
* @returns {Object} the new comment
*/
export default async function(commentsType, ressourceId, message) {
const ressourcePath = ['', commentsType, ressourceId].join('/')
const response = await axios.post(getRootPath() + ressourcePath, {
actorDisplayName: getCurrentUser().displayName,
actorId: getCurrentUser().uid,
actorType: 'users',
creationDateTime: (new Date()).toUTCString(),
message,
objectType: 'files',
verb: 'comment',
})
// Retrieve comment id from ressource location
const commentId = parseInt(response.headers['content-location'].split('/').pop())
const commentPath = ressourcePath + '/' + commentId
// Fetch newly created comment data
const comment = await client.stat(commentPath, {
details: true,
})
return genFileInfo(comment)
}

View File

@ -1,77 +0,0 @@
/**
* based upon apps/comments/js/vendor/At.js/dist/css/jquery.atwho.css,
* only changed colors and font-weight
*/
.atwho-view {
position:absolute;
top: 0;
left: 0;
display: none;
margin-top: 18px;
background: var(--color-main-background);
color: var(--color-main-text);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
box-shadow: 0 0 5px var(--color-box-shadow);
min-width: 120px;
z-index: 11110 !important;
}
.atwho-view .atwho-header {
padding: 5px;
margin: 5px;
cursor: pointer;
border-bottom: solid 1px var(--color-border);
color: var(--color-main-text);
font-size: 11px;
font-weight: bold;
}
.atwho-view .atwho-header .small {
color: var(--color-main-text);
float: right;
padding-top: 2px;
margin-right: -5px;
font-size: 12px;
font-weight: normal;
}
.atwho-view .atwho-header:hover {
cursor: default;
}
.atwho-view .cur {
background: var(--color-primary);
color: var(--color-primary-text);
}
.atwho-view .cur small {
color: var(--color-primary-text);
}
.atwho-view strong {
color: var(--color-main-text);
font-weight: normal;
}
.atwho-view .cur strong {
color: var(--color-primary-text);
font-weight: normal;
}
.atwho-view ul {
/* width: 100px; */
list-style:none;
padding:0;
margin:auto;
max-height: 200px;
overflow-y: auto;
}
.atwho-view ul li {
display: block;
padding: 5px 10px;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
}
.atwho-view small {
font-size: smaller;
color: var(--color-main-text);
font-weight: normal;
}

View File

@ -1,261 +0,0 @@
/*
* Copyright (c) 2016
*
* This file is licensed under the Affero General Public License version 3
* or later.
*
* See the COPYING-README file.
*
*/
#commentsTabView .emptycontent {
margin-top: 0;
}
#commentsTabView .newCommentForm {
margin-left: 36px;
position: relative;
}
#commentsTabView .newCommentForm .message {
width: 100%;
padding: 10px;
min-height: 44px;
margin: 0;
/* Prevent the text from overlapping with the submit button. */
padding-right: 30px;
}
#commentsTabView .newCommentForm {
.submit,
.submitLoading {
width: 44px;
height: 44px;
margin: 0;
padding: 13px;
background-color: transparent;
border: none;
opacity: .3;
position: absolute;
bottom: 0;
right: 0;
}
}
#commentsTabView .deleteLoading {
padding: 14px;
vertical-align: middle;
}
#commentsTabView .newCommentForm .submit:not(:disabled):hover,
#commentsTabView .newCommentForm .submit:not(:disabled):focus {
opacity: 1;
}
#commentsTabView .newCommentForm div.message {
resize: none;
}
#commentsTabView .newCommentForm div.message:empty:before {
content: attr(data-placeholder);
color: grey;
}
#commentsTabView .comment {
position: relative;
/** padding bottom is little more so that the top and bottom gap look uniform **/
padding: 10px 0 15px;
}
#commentsTabView .comments .comment {
border-top: 1px solid var(--color-border);
}
#commentsTabView .comment .avatar,
.atwho-view-ul * .avatar{
width: 32px;
height: 32px;
line-height: 32px;
margin-right: 5px;
}
#commentsTabView .comment .message .avatar,
.atwho-view-ul * .avatar
{
display: inline-block;
}
#activityTabView li.comment.collapsed .activitymessage,
#commentsTabView .comment.collapsed .message {
white-space: pre-wrap;
}
#activityTabView li.comment.collapsed .activitymessage,
#commentsTabView .comment.collapsed .message {
max-height: 70px;
overflow: hidden;
}
#activityTabView li.comment .message-overlay,
#commentsTabView .comment .message-overlay {
display: none;
}
#activityTabView li.comment.collapsed .message-overlay,
#commentsTabView .comment.collapsed .message-overlay {
display: block;
position: absolute;
z-index: 2;
height: 50px;
pointer-events: none;
left: 0;
right: 0;
bottom: 0;
background: -moz-linear-gradient(rgba(var(--color-main-background), 0), var(--color-main-background));
background: -webkit-linear-gradient(rgba(var(--color-main-background), 0), var(--color-main-background));
background: -o-linear-gradient(rgba(var(--color-main-background), 0), var(--color-main-background));
background: -ms-linear-gradient(rgba(var(--color-main-background), 0), var(--color-main-background));
background: linear-gradient(rgba(var(--color-main-background), 0), var(--color-main-background));
background-repeat: no-repeat;
}
#commentsTabView .hidden {
display: none !important;
}
/** set min-height as 44px to ensure that it fits the button sizes. **/
#commentsTabView .comment .authorRow {
min-height: 44px;
}
#commentsTabView .comment .authorRow .tooltip {
/** because of the padding on the element, the tooltip appear too far up,
adding this brings them closer to the element**/
margin-top: 5px;
}
.atwho-view-ul * .avatar-name-wrapper,
#commentsTabView .comment .authorRow {
position: relative;
display: inline-flex;
align-items: center;
width: 100%;
}
#commentsTabView .comment:not(.newCommentRow) .message .avatar-name-wrapper:not(.currentUser),
#commentsTabView .comment:not(.newCommentRow) .message .avatar-name-wrapper:not(.currentUser) .avatar,
#commentsTabView .comment:not(.newCommentRow) .message .avatar-name-wrapper:not(.currentUser) .avatar img,
#commentsTabView .comment .authorRow .avatar:not(.currentUser),
#commentsTabView .comment .authorRow .author:not(.currentUser) {
cursor: pointer;
}
.atwho-view-ul .avatar-name-wrapper,
.atwho-view-ul .avatar-name-wrapper .avatar,
.atwho-view-ul .avatar-name-wrapper .avatar img {
cursor: pointer;
}
#commentsTabView .comments li .message .atwho-inserted,
#commentsTabView .newCommentForm .atwho-inserted {
.avatar-name-wrapper {
/* Make the wrapper the positioning context of its child contacts
* menu. */
position: relative;
display: inline;
vertical-align: top;
background-color: var(--color-background-dark);
border-radius: 50vh;
padding: 1px 7px 1px 1px;
/* Ensure that the avatar and the user name will be kept together. */
white-space: nowrap;
.avatar {
img {
vertical-align: top;
}
height: 16px;
width: 16px;
vertical-align: middle;
padding: 1px;
margin-top: -3px;
margin-left: 0;
margin-right: 2px;
}
strong {
/* Ensure that the user name is shown in bold, as different browsers
* use different font weights for strong elements. */
font-weight: bold;
}
}
.avatar-name-wrapper.currentUser {
background-color: var(--color-primary);
color: var(--color-primary-text);
}
}
.atwho-view-ul * .avatar-name-wrapper {
white-space: nowrap;
}
#commentsTabView .comment .author,
#commentsTabView .comment .date {
opacity: .5;
}
#commentsTabView .comment .author {
max-width: 210px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
#commentsTabView .comment .date {
margin-left: auto;
/** this is to fix the tooltip being too close due to the margin-top applied
to bring the tooltip closer for the action icons **/
padding: 10px 0px;
}
#commentsTabView .comments > li:not(.newCommentRow) .message {
padding-left: 40px;
word-wrap: break-word;
overflow-wrap: break-word;
}
#commentsTabView .comment .action {
opacity: 0.3;
padding: 14px;
display: block;
}
#commentsTabView .comment .action:hover,
#commentsTabView .comment .action:focus {
opacity: 1;
}
#commentsTabView .newCommentRow .action-container {
margin-left: auto;
}
#commentsTabView .comment.disabled .message {
opacity: 0.3;
}
#commentsTabView .comment.disabled .action {
display: none;
}
#commentsTabView .message.error {
color: #e9322d;
border-color: #e9322d;
box-shadow: 0 0 6px #f8b9b7;
}
.app-files .action-comment {
padding: 16px 14px;
}
#commentsTabView .comment .message .contactsmenu-popover {
left: -6px;
top: 24px;
}

View File

@ -1,15 +0,0 @@
<li class="comment{{#if isUnread}} unread{{/if}}{{#if isLong}} collapsed{{/if}}" data-id="{{id}}">
<div class="authorRow">
<div class="avatar{{#if isUserAuthor}} currentUser{{/if}}" {{#if actorId}}data-username="{{actorId}}"{{/if}}> </div>
<div class="author{{#if isUserAuthor}} currentUser{{/if}}">{{actorDisplayName}}</div>
{{#if isUserAuthor}}
<a href="#" class="action more icon icon-more has-tooltip"></a>
<div class="deleteLoading icon-loading-small hidden"></div>
{{/if}}
<div class="date has-tooltip live-relative-timestamp" data-timestamp="{{timestamp}}" title="{{altDate}}">{{date}}</div>
</div>
<div class="message">{{{formattedMessage}}}</div>
{{#if isLong}}
<div class="message-overlay"></div>
{{/if}}
</li>

View File

@ -1,14 +0,0 @@
<ul>
{{#each items}}
<li>
<a href="#" class="menuitem action {{name}} permanent" data-action="{{name}}">
{{#if iconClass}}
<span class="icon {{iconClass}}"></span>
{{else}}
<span class="no-icon"></span>
{{/if}}
<span>{{displayName}}</span>
</a>
</li>
{{/each}}
</ul>

View File

@ -1,16 +0,0 @@
<{{tag}} class="newCommentRow comment" data-id="{{id}}">
<div class="authorRow">
<div class="avatar currentUser" data-username="{{actorId}}"></div>
<div class="author currentUser">{{actorDisplayName}}</div>
{{#if isEditMode}}
<div class="action-container">
<a href="#" class="action cancel icon icon-close has-tooltip" title="{{cancelText}}"></a>
</div>
{{/if}}
</div>
<form class="newCommentForm">
<div contentEditable="true" class="message" data-placeholder="{{newMessagePlaceholder}}">{{message}}</div>
<input class="submit icon-confirm has-tooltip" type="submit" value="" title="{{submitText}}"/>
<div class="submitLoading icon-loading-small hidden"></div>
</form>
</{{tag}}>

View File

@ -1,6 +0,0 @@
<ul class="comments">
</ul>
<div class="emptycontent hidden"><div class="icon-comment"></div>
<p>{{emptyResultLabel}}</p></div>
<input type="button" class="showMore hidden" value="{{moreLabel}}" name="show-more" id="show-more" />
<div class="loading hidden" style="height: 50px"></div>

View File

@ -0,0 +1,62 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import axios from '@nextcloud/axios'
/**
* Create a cancel token
* @returns {CancelTokenSource}
*/
const createCancelToken = () => axios.CancelToken.source()
/**
* Creates a cancelable axios 'request object'.
*
* @param {function} request the axios promise request
* @returns {Object}
*/
const cancelableRequest = function(request) {
/**
* Generate an axios cancel token
*/
const cancelToken = createCancelToken()
/**
* Execute the request
*
* @param {string} url the url to send the request to
* @param {Object} [options] optional config for the request
*/
const fetch = async function(url, options) {
return request(
url,
Object.assign({ cancelToken: cancelToken.token }, options)
)
}
return {
request: fetch,
cancel: cancelToken.cancel,
}
}
export default cancelableRequest

View File

@ -0,0 +1,29 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { generateRemoteUrl } from '@nextcloud/router'
const getRootPath = function() {
return generateRemoteUrl('dav/comments')
}
export { getRootPath }

View File

@ -0,0 +1,122 @@
/**
* @copyright Copyright (c) 2019 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import camelcase from 'camelcase'
import { isNumber } from './numberUtil'
/**
* Get an url encoded path
*
* @param {String} path the full path
* @returns {string} url encoded file path
*/
const encodeFilePath = function(path) {
const pathSections = (path.startsWith('/') ? path : `/${path}`).split('/')
let relativePath = ''
pathSections.forEach((section) => {
if (section !== '') {
relativePath += '/' + encodeURIComponent(section)
}
})
return relativePath
}
/**
* Extract dir and name from file path
*
* @param {String} path the full path
* @returns {String[]} [dirPath, fileName]
*/
const extractFilePaths = function(path) {
const pathSections = path.split('/')
const fileName = pathSections[pathSections.length - 1]
const dirPath = pathSections.slice(0, pathSections.length - 1).join('/')
return [dirPath, fileName]
}
/**
* Sorting comparison function
*
* @param {Object} fileInfo1 file 1 fileinfo
* @param {Object} fileInfo2 file 2 fileinfo
* @param {string} key key to sort with
* @param {boolean} [asc=true] sort ascending?
* @returns {number}
*/
const sortCompare = function(fileInfo1, fileInfo2, key, asc = true) {
if (fileInfo1.isFavorite && !fileInfo2.isFavorite) {
return -1
} else if (!fileInfo1.isFavorite && fileInfo2.isFavorite) {
return 1
}
// if this is a number, let's sort by integer
if (isNumber(fileInfo1[key]) && isNumber(fileInfo2[key])) {
return Number(fileInfo1[key]) - Number(fileInfo2[key])
}
// else we sort by string, so let's sort directories first
if (fileInfo1.type === 'directory' && fileInfo2.type !== 'directory') {
return -1
} else if (fileInfo1.type !== 'directory' && fileInfo2.type === 'directory') {
return 1
}
// finally sort by name
return asc
? fileInfo1[key].localeCompare(fileInfo2[key], OC.getLanguage())
: -fileInfo1[key].localeCompare(fileInfo2[key], OC.getLanguage())
}
/**
* Generate a fileinfo object based on the full dav properties
* It will flatten everything and put all keys to camelCase
*
* @param {Object} obj the object
* @returns {Object}
*/
const genFileInfo = function(obj) {
const fileInfo = {}
Object.keys(obj).forEach(key => {
const data = obj[key]
// flatten object if any
if (!!data && typeof data === 'object' && !Array.isArray(data)) {
Object.assign(fileInfo, genFileInfo(data))
} else {
// format key and add it to the fileInfo
if (data === 'false') {
fileInfo[camelcase(key)] = false
} else if (data === 'true') {
fileInfo[camelcase(key)] = true
} else {
fileInfo[camelcase(key)] = isNumber(data)
? Number(data)
: data
}
}
})
return fileInfo
}
export { encodeFilePath, extractFilePaths, sortCompare, genFileInfo }

View File

@ -0,0 +1,30 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
const isNumber = function(num) {
if (!num) {
return false
}
return Number(num).toString() === num.toString()
}
export { isNumber }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,263 @@
<!--
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div class="comments" :class="{ 'icon-loading': isFirstLoading }">
<!-- Editor -->
<Comment v-bind="editorData"
:auto-complete="autoComplete"
:editor="true"
:ressource-id="ressourceId"
class="comments__writer"
@new="onNewComment" />
<template v-if="!isFirstLoading">
<EmptyContent v-if="!hasComments && done" icon="icon-comment">
{{ t('comments', 'No comments yet, start the conversation!') }}
</EmptyContent>
<!-- Comments -->
<Comment v-for="comment in comments"
v-else
:key="comment.id"
v-bind="comment"
:auto-complete="autoComplete"
:ressource-id="ressourceId"
:message.sync="comment.message"
class="comments__list"
@delete="onDelete" />
<!-- Loading more message -->
<div v-if="loading && !isFirstLoading" class="comments__info icon-loading" />
<div v-else-if="hasComments && done" class="comments__info">
{{ t('comments', 'No more messages') }}
</div>
<!-- Error message -->
<EmptyContent v-else-if="error" class="comments__error" icon="icon-error">
{{ error }}
<template #desc>
<button icon="icon-history" @click="getComments">
{{ t('comments', 'Retry') }}
</button>
</template>
</EmptyContent>
</template>
</div>
</template>
<script>
import { generateOcsUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import VTooltip from 'v-tooltip'
import Vue from 'vue'
import EmptyContent from '@nextcloud/vue/dist/Components/EmptyContent'
import Comment from '../components/Comment'
import getComments, { DEFAULT_LIMIT } from '../services/GetComments'
import cancelableRequest from '../utils/cancelableRequest'
Vue.use(VTooltip)
export default {
name: 'Comments',
components: {
// Avatar,
Comment,
EmptyContent,
},
data() {
return {
error: '',
loading: false,
done: false,
ressourceId: null,
offset: 0,
comments: [],
cancelRequest: () => {},
editorData: {
actorDisplayName: getCurrentUser().displayName,
actorId: getCurrentUser().uid,
key: 'editor',
},
Comment,
}
},
computed: {
hasComments() {
return this.comments.length > 0
},
isFirstLoading() {
return this.loading && this.offset === 0
},
},
methods: {
/**
* Update current ressourceId and fetch new data
* @param {Number} ressourceId the current ressourceId (fileId...)
*/
async update(ressourceId) {
this.ressourceId = ressourceId
this.resetState()
this.getComments()
},
/**
* Ran when the bottom of the tab is reached
*/
onScrollBottomReached() {
/**
* Do not fetch more if we:
* - are showing an error
* - already fetched everything
* - are currently loading
*/
if (this.error || this.done || this.loading) {
return
}
this.getComments()
},
/**
* Get the existing shares infos
*/
async getComments() {
// Cancel any ongoing request
this.cancelRequest('cancel')
try {
this.loading = true
this.error = ''
// Init cancellable request
const { request, cancel } = cancelableRequest(getComments)
this.cancelRequest = cancel
// Fetch comments
const comments = await request({
commentsType: this.commentsType,
ressourceId: this.ressourceId,
}, { offset: this.offset })
this.logger.debug(`Processed ${comments.length} comments`, { comments })
// We received less than the requested amount,
// we're done fetching comments
if (comments.length < DEFAULT_LIMIT) {
this.done = true
}
// Insert results
this.comments.push(...comments)
// Increase offset for next fetch
this.offset += DEFAULT_LIMIT
} catch (error) {
if (error.message === 'cancel') {
return
}
this.error = t('comments', 'Unable to load the comments list')
console.error('Error loading the comments list', error)
} finally {
this.loading = false
}
},
/**
* Autocomplete @mentions
* @param {string} search the query
* @param {Function} callback the callback to process the results with
*/
async autoComplete(search, callback) {
const results = await axios.get(generateOcsUrl('core', 2) + 'autocomplete/get', {
params: {
search,
itemType: 'files',
itemId: this.ressourceId,
sorter: 'commenters|share-recipients',
limit: OC.appConfig?.comments?.maxAutoCompleteResults || 25,
},
})
return callback(results.data.ocs.data)
},
/**
* Add newly created comment to the list
* @param {Object} comment the new comment
*/
onNewComment(comment) {
this.comments.unshift(comment)
},
/**
* Remove deleted comment from the list
* @param {number} id the deleted comment
*/
onDelete(id) {
const index = this.comments.findIndex(comment => comment.id === id)
if (index > -1) {
this.comments.splice(index, 1)
} else {
console.error('Could not find the deleted comment in the list', id)
}
},
/**
* Reset the current view to its default state
*/
resetState() {
this.error = ''
this.loading = false
this.done = false
this.offset = 0
this.comments = []
},
},
}
</script>
<style lang="scss" scoped>
.comments {
// Do not add emptycontent top margin
&__error{
margin-top: 0;
}
&__info {
height: 60px;
color: var(--color-text-maxcontrast);
text-align: center;
line-height: 60px;
}
}
</style>

View File

@ -1,148 +0,0 @@
/*
* Copyright (c) 2016
*
* This file is licensed under the Affero General Public License comment 3
* or later.
*
* See the COPYING-README file.
*
*/
describe('OCA.Comments.CommentCollection', function() {
var CommentCollection = OCA.Comments.CommentCollection;
var collection, syncStub;
var comment1, comment2, comment3;
beforeEach(function() {
syncStub = sinon.stub(CommentCollection.prototype, 'sync');
collection = new CommentCollection();
collection.setObjectId(5);
comment1 = {
id: 1,
actorType: 'users',
actorId: 'user1',
actorDisplayName: 'User One',
objectType: 'files',
objectId: 5,
message: 'First',
creationDateTime: Date.UTC(2016, 1, 3, 10, 5, 0)
};
comment2 = {
id: 2,
actorType: 'users',
actorId: 'user2',
actorDisplayName: 'User Two',
objectType: 'files',
objectId: 5,
message: 'Second\nNewline',
creationDateTime: Date.UTC(2016, 1, 3, 10, 0, 0)
};
comment3 = {
id: 3,
actorType: 'users',
actorId: 'user3',
actorDisplayName: 'User Three',
objectType: 'files',
objectId: 5,
message: 'Third',
creationDateTime: Date.UTC(2016, 1, 3, 5, 0, 0)
};
});
afterEach(function() {
syncStub.restore();
});
it('fetches the next page', function() {
collection._limit = 2;
collection.fetchNext();
expect(syncStub.calledOnce).toEqual(true);
expect(syncStub.lastCall.args[0]).toEqual('REPORT');
var options = syncStub.lastCall.args[2];
expect(options.remove).toEqual(false);
var parser = new DOMParser();
var doc = parser.parseFromString(options.data, "application/xml");
expect(doc.getElementsByTagNameNS('http://owncloud.org/ns', 'limit')[0].textContent).toEqual('3');
expect(doc.getElementsByTagNameNS('http://owncloud.org/ns', 'offset')[0].textContent).toEqual('0');
syncStub.yieldTo('success', [comment1, comment2, comment3]);
expect(collection.length).toEqual(2);
expect(collection.hasMoreResults()).toEqual(true);
collection.fetchNext();
expect(syncStub.calledTwice).toEqual(true);
options = syncStub.lastCall.args[2];
doc = parser.parseFromString(options.data, "application/xml");
expect(doc.getElementsByTagNameNS('http://owncloud.org/ns', 'limit')[0].textContent).toEqual('3');
expect(doc.getElementsByTagNameNS('http://owncloud.org/ns', 'offset')[0].textContent).toEqual('2');
syncStub.yieldTo('success', [comment3]);
expect(collection.length).toEqual(3);
expect(collection.hasMoreResults()).toEqual(false);
collection.fetchNext();
// no further requests
expect(syncStub.calledTwice).toEqual(true);
});
it('resets page counted when calling reset', function() {
collection.fetchNext();
syncStub.yieldTo('success', [comment1]);
expect(collection.hasMoreResults()).toEqual(false);
collection.reset();
expect(collection.hasMoreResults()).toEqual(true);
});
describe('resetting read marker', function() {
var updateStub;
var clock;
beforeEach(function() {
updateStub = sinon.stub(OCA.Comments.CommentSummaryModel.prototype, 'save');
clock = sinon.useFakeTimers(Date.UTC(2016, 1, 3, 10, 5, 9));
});
afterEach(function() {
updateStub.restore();
clock.restore();
});
it('resets read marker to the default date', function() {
var successStub = sinon.stub();
collection.updateReadMarker(null, {
success: successStub
});
expect(updateStub.calledOnce).toEqual(true);
expect(updateStub.lastCall.args[0]).toEqual({
readMarker: new Date(Date.UTC(2016, 1, 3, 10, 5, 9)).toUTCString()
});
updateStub.yieldTo('success');
expect(successStub.calledOnce).toEqual(true);
});
it('resets read marker to the given date', function() {
var successStub = sinon.stub();
collection.updateReadMarker(new Date(Date.UTC(2016, 1, 2, 3, 4, 5)), {
success: successStub
});
expect(updateStub.calledOnce).toEqual(true);
expect(updateStub.lastCall.args[0]).toEqual({
readMarker: new Date(Date.UTC(2016, 1, 2, 3, 4, 5)).toUTCString()
});
updateStub.yieldTo('success');
expect(successStub.calledOnce).toEqual(true);
});
});
});

View File

@ -1,688 +0,0 @@
/**
* ownCloud
*
* @author Vincent Petry
* @copyright 2016 Vincent Petry <pvince81@owncloud.com>
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
* License as published by the Free Software Foundation; either
* comment 3 of the License, or any later comment.
*
* This library 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 library. If not, see <http://www.gnu.org/licenses/>.
*
*/
describe('OCA.Comments.CommentsTabView tests', function() {
var view, fileInfoModel;
var fetchStub;
var avatarStub;
var testComments;
var clock;
/**
* Creates a dummy message with the given length
*
* @param {int} len length
* @return {string} message
*/
function createMessageWithLength(len) {
var bigMessage = '';
for (var i = 0; i < len; i++) {
bigMessage += 'a';
}
return bigMessage;
}
beforeEach(function() {
clock = sinon.useFakeTimers(Date.UTC(2016, 1, 3, 10, 5, 9));
fetchStub = sinon.stub(OCA.Comments.CommentCollection.prototype, 'fetchNext');
avatarStub = sinon.stub($.fn, 'avatar');
view = new OCA.Comments.CommentsTabView();
fileInfoModel = new OCA.Files.FileInfoModel({
id: 5,
name: 'One.txt',
mimetype: 'text/plain',
permissions: 31,
path: '/subdir',
size: 123456789,
etag: 'abcdefg',
mtime: Date.UTC(2016, 1, 0, 0, 0, 0)
});
view.render();
var comment1 = new OCA.Comments.CommentModel({
id: 1,
actorType: 'users',
actorId: 'user1',
actorDisplayName: 'User One',
objectType: 'files',
objectId: 5,
message: 'First',
creationDateTime: new Date(Date.UTC(2016, 1, 3, 10, 5, 0)).toUTCString()
});
var comment2 = new OCA.Comments.CommentModel({
id: 2,
actorType: 'users',
actorId: 'user2',
actorDisplayName: 'User Two',
objectType: 'files',
objectId: 5,
message: 'Second\nNewline',
creationDateTime: new Date(Date.UTC(2016, 1, 3, 10, 0, 0)).toUTCString()
});
var comment3 = new OCA.Comments.CommentModel({
id: 3,
actorId: 'anotheruser',
actorDisplayName: 'Another User',
actorType: 'users',
verb: 'comment',
message: 'Hail to thee, @macbeth. Yours faithfully, @banquo',
creationDateTime: new Date(Date.UTC(2016, 1, 3, 10, 5, 9)).toUTCString(),
mentions: {
0: {
mentionDisplayName: "Thane of Cawdor",
mentionId: "macbeth",
mentionTye: "user"
},
1: {
mentionDisplayName: "Lord Banquo",
mentionId: "banquo",
mentionTye: "user"
}
}
});
testComments = [comment1, comment2, comment3];
});
afterEach(function() {
view.remove();
view = undefined;
fetchStub.restore();
avatarStub.restore();
clock.restore();
});
describe('rendering', function() {
it('reloads matching comments when setting file info model', function() {
view.setFileInfo(fileInfoModel);
expect(fetchStub.calledOnce).toEqual(true);
});
it('renders loading icon while fetching comments', function() {
view.setFileInfo(fileInfoModel);
view.collection.trigger('request');
expect(view.$el.find('.loading').length).toEqual(1);
expect(view.$el.find('.comments li').length).toEqual(0);
});
it('renders comments', function() {
view.setFileInfo(fileInfoModel);
view.collection.set(testComments);
var $comments = view.$el.find('.comments>li');
expect($comments.length).toEqual(3);
var $item = $comments.eq(0);
expect($item.find('.author').text()).toEqual('User One');
expect($item.find('.date').text()).toEqual('seconds ago');
expect($item.find('.message').text()).toEqual('First');
$item = $comments.eq(1);
expect($item.find('.author').text()).toEqual('User Two');
expect($item.find('.date').text()).toEqual('5 minutes ago');
expect($item.find('.message').html()).toEqual('Second<br>Newline');
});
it('renders comments from deleted user differently', function() {
testComments[0].set('actorType', 'deleted_users', {silent: true});
view.collection.set(testComments);
var $item = view.$el.find('.comment[data-id=1]');
expect($item.find('.author').text()).toEqual('[Deleted user]');
expect($item.find('.avatar').attr('data-username')).not.toBeDefined();
});
it('renders mentioned user id to avatar and displayname', function() {
view.collection.set(testComments);
var $comment = view.$el.find('.comment[data-id=3] .message');
expect($comment.length).toEqual(1);
expect($comment.find('.avatar[data-user=macbeth]').length).toEqual(1);
expect($comment.find('strong:first').text()).toEqual('Thane of Cawdor');
expect($comment.find('.avatar[data-user=macbeth] ~ .contactsmenu-popover').length).toEqual(1);
expect($comment.find('.avatar[data-user=banquo]').length).toEqual(1);
expect($comment.find('.avatar[data-user=banquo] ~ strong').text()).toEqual('Lord Banquo');
expect($comment.find('.avatar[data-user=banquo] ~ .contactsmenu-popover').length).toEqual(1);
});
});
describe('more comments', function() {
var hasMoreResultsStub;
beforeEach(function() {
view.collection.set(testComments);
hasMoreResultsStub = sinon.stub(OCA.Comments.CommentCollection.prototype, 'hasMoreResults');
});
afterEach(function() {
hasMoreResultsStub.restore();
});
it('shows "More comments" button when more comments are available', function() {
hasMoreResultsStub.returns(true);
view.collection.trigger('sync');
expect(view.$el.find('.showMore').hasClass('hidden')).toEqual(false);
});
it('does not show "More comments" button when more comments are available', function() {
hasMoreResultsStub.returns(false);
view.collection.trigger('sync');
expect(view.$el.find('.showMore').hasClass('hidden')).toEqual(true);
});
it('fetches and appends the next page when clicking the "More" button', function() {
hasMoreResultsStub.returns(true);
expect(fetchStub.notCalled).toEqual(true);
view.$el.find('.showMore').trigger('click');
expect(fetchStub.calledOnce).toEqual(true);
});
it('appends comment to the list when added to collection', function() {
var comment4 = new OCA.Comments.CommentModel({
id: 4,
actorType: 'users',
actorId: 'user3',
actorDisplayName: 'User Three',
objectType: 'files',
objectId: 5,
message: 'Third',
creationDateTime: new Date(Date.UTC(2016, 1, 3, 5, 0, 0)).toUTCString()
});
view.collection.add(comment4);
expect(view.$el.find('.comments>li').length).toEqual(4);
var $item = view.$el.find('.comments>li').eq(3);
expect($item.find('.author').text()).toEqual('User Three');
expect($item.find('.date').text()).toEqual('5 hours ago');
expect($item.find('.message').html()).toEqual('Third');
});
});
describe('posting comments', function() {
var createStub;
var currentUserStub;
var $newCommentForm;
beforeEach(function() {
view.collection.set(testComments);
createStub = sinon.stub(OCA.Comments.CommentCollection.prototype, 'create');
currentUserStub = sinon.stub(OC, 'getCurrentUser');
currentUserStub.returns({
uid: 'testuser',
displayName: 'Test User'
});
$newCommentForm = view.$el.find('.newCommentForm');
// Required for the absolute selector used to find the new comment
// after a successful creation in _onSubmitSuccess.
$('#testArea').append(view.$el);
});
afterEach(function() {
createStub.restore();
currentUserStub.restore();
});
it('creates a new comment when clicking post button', function() {
$newCommentForm.find('.message').text('New message');
$newCommentForm.submit();
expect(createStub.calledOnce).toEqual(true);
expect(createStub.lastCall.args[0]).toEqual({
actorId: 'testuser',
actorDisplayName: 'Test User',
actorType: 'users',
verb: 'comment',
message: 'New message',
creationDateTime: new Date(Date.UTC(2016, 1, 3, 10, 5, 9)).toUTCString()
});
});
it('creates a new comment when typing enter', function() {
$newCommentForm.find('.message').text('New message');
var keydownEvent = new $.Event('keydown', {keyCode: 13});
$newCommentForm.find('.message').trigger(keydownEvent);
expect(createStub.calledOnce).toEqual(true);
expect(createStub.lastCall.args[0]).toEqual({
actorId: 'testuser',
actorDisplayName: 'Test User',
actorType: 'users',
verb: 'comment',
message: 'New message',
creationDateTime: new Date(Date.UTC(2016, 1, 3, 10, 5, 9)).toUTCString()
});
expect(keydownEvent.isDefaultPrevented()).toEqual(true);
});
it('creates a new mention when typing enter in the autocomplete popover', function() {
var autoCompleteStub = sinon.stub(view, '_onAutoComplete');
autoCompleteStub.callsArgWith(1, [{"id":"userId", "label":"User Name", "source":"users"}]);
// Force the autocomplete to be initialized
view._initAutoComplete($newCommentForm.find('.message'));
// PhantomJS does not seem to handle typing in a contenteditable, so
// some tricks are needed to show the autocomplete popover.
//
// Instead of sending key events to type "@u" the characters are
// programatically set in the input field.
$newCommentForm.find('.message').text('Mention to @u');
// When focusing on the input field the caret is not guaranteed to
// be at the end; instead of calling "focus()" on the input field
// the caret is explicitly set at the end of the input field, that
// is, after "@u".
var range = document.createRange();
range.selectNodeContents($newCommentForm.find('.message')[0]);
range.collapse(false);
var selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
// As PhantomJS does not handle typing in a contenteditable the key
// typed here is in practice ignored by At.js, but despite that it
// will cause the popover to be shown.
$newCommentForm.find('.message').trigger(new $.Event('keydown', {keyCode: 's'}));
$newCommentForm.find('.message').trigger(new $.Event('keyup', {keyCode: 's'}));
expect(autoCompleteStub.calledOnce).toEqual(true);
var keydownEvent = new $.Event('keydown', {keyCode: 13});
$newCommentForm.find('.message').trigger(keydownEvent);
expect(createStub.calledOnce).toEqual(false);
expect($newCommentForm.find('.message').html()).toContain('Mention to <span');
expect($newCommentForm.find('.message').html()).toContain('<span class="avatar"');
expect($newCommentForm.find('.message').html()).toContain('<strong>User Name</strong>');
expect($newCommentForm.find('.message').text()).not.toContain('@');
// In this case the default behaviour is prevented by the
// "onKeydown" event handler of At.js.
expect(keydownEvent.isDefaultPrevented()).toEqual(true);
});
it('creates a new line when typing shift+enter', function() {
$newCommentForm.find('.message').text('New message');
var keydownEvent = new $.Event('keydown', {keyCode: 13, shiftKey: true});
$newCommentForm.find('.message').trigger(keydownEvent);
expect(createStub.calledOnce).toEqual(false);
// PhantomJS does not seem to handle typing in a contenteditable, so
// instead of looking for a new line the best that can be done is
// checking that the default behaviour would have been executed.
expect($newCommentForm.find('.message').text()).toContain('New message');
expect(keydownEvent.isDefaultPrevented()).toEqual(false);
});
it('creates a new comment with mentions when clicking post button', function() {
$newCommentForm.find('.message').text('New message @anotheruser');
$newCommentForm.submit();
var createStubExpectedData = {
actorId: 'testuser',
actorDisplayName: 'Test User',
actorType: 'users',
verb: 'comment',
message: 'New message @anotheruser',
creationDateTime: new Date(Date.UTC(2016, 1, 3, 10, 5, 9)).toUTCString()
};
expect(createStub.calledOnce).toEqual(true);
expect(createStub.lastCall.args[0]).toEqual(createStubExpectedData);
var model = new OCA.Comments.CommentModel(_.extend({id: 4}, createStubExpectedData));
var fetchStub = sinon.stub(model, 'fetch');
// simulate the fact that create adds the model to the collection
view.collection.add(model, {at: 0});
createStub.yieldTo('success', model);
expect(fetchStub.calledOnce).toEqual(true);
// simulate the fact that fetch sets the attribute
model.set('mentions', {
0: {
mentionDisplayName: "Another User",
mentionId: "anotheruser",
mentionTye: "user"
}
});
fetchStub.yieldTo('success', model);
// comment was added to the list
var $comment = view.$el.find('.comment[data-id=4]');
expect($comment.length).toEqual(1);
var $message = $comment.find('.message');
expect($message.html()).toContain('New message');
expect($message.find('.avatar').length).toEqual(1);
expect($message.find('.avatar[data-user=anotheruser]').length).toEqual(1);
expect($message.find('.avatar[data-user=anotheruser] ~ strong').text()).toEqual('Another User');
expect($message.find('.avatar[data-user=anotheruser] ~ .contactsmenu-popover').length).toEqual(1);
});
it('does not create a comment if the field is empty', function() {
$newCommentForm.find('.message').val(' ');
$newCommentForm.submit();
expect(createStub.notCalled).toEqual(true);
});
it('does not create a comment if the field length is too large', function() {
var bigMessage = '';
for (var i = 0; i < view._commentMaxLength * 2; i++) {
bigMessage += 'a';
}
$newCommentForm.find('.message').val(bigMessage);
$newCommentForm.submit();
expect(createStub.notCalled).toEqual(true);
});
describe('limit indicator', function() {
var tooltipStub;
var $message;
var $submitButton;
beforeEach(function() {
tooltipStub = sinon.stub($.fn, 'tooltip');
$message = $newCommentForm.find('.message');
$submitButton = $newCommentForm.find('.submit');
});
afterEach(function() {
tooltipStub.restore();
});
it('does not displays tooltip when limit is far away', function() {
$message.val(createMessageWithLength(3));
$message.trigger('change');
expect(tooltipStub.calledWith('show')).toEqual(false);
expect($submitButton.prop('disabled')).toEqual(false);
expect($message.hasClass('error')).toEqual(false);
});
it('displays tooltip when limit is almost reached', function() {
$message.text(createMessageWithLength(view._commentMaxLength - 2));
$message.trigger('change');
expect(tooltipStub.calledWith('show')).toEqual(true);
expect($submitButton.prop('disabled')).toEqual(false);
expect($message.hasClass('error')).toEqual(false);
});
it('displays tooltip and disabled button when limit is exceeded', function() {
$message.text(createMessageWithLength(view._commentMaxLength + 2));
$message.trigger('change');
expect(tooltipStub.calledWith('show')).toEqual(true);
expect($submitButton.prop('disabled')).toEqual(true);
expect($message.hasClass('error')).toEqual(true);
});
});
});
describe('editing comments', function() {
var saveStub;
var fetchStub;
var currentUserStub;
beforeEach(function() {
saveStub = sinon.stub(OCA.Comments.CommentModel.prototype, 'save');
fetchStub = sinon.stub(OCA.Comments.CommentModel.prototype, 'fetch');
currentUserStub = sinon.stub(OC, 'getCurrentUser');
currentUserStub.returns({
uid: 'testuser',
displayName: 'Test User'
});
view.collection.add({
id: 1,
actorId: 'testuser',
actorDisplayName: 'Test User',
actorType: 'users',
verb: 'comment',
message: 'New message',
creationDateTime: new Date(Date.UTC(2016, 1, 3, 10, 5, 9)).toUTCString()
});
view.collection.add({
id: 2,
actorId: 'anotheruser',
actorDisplayName: 'Another User',
actorType: 'users',
verb: 'comment',
message: 'New message from another user',
creationDateTime: new Date(Date.UTC(2016, 1, 3, 10, 5, 9)).toUTCString(),
});
view.collection.add({
id: 3,
actorId: 'testuser',
actorDisplayName: 'Test User',
actorType: 'users',
verb: 'comment',
message: 'Hail to thee, @macbeth. Yours faithfully, @banquo',
creationDateTime: new Date(Date.UTC(2016, 1, 3, 10, 5, 9)).toUTCString(),
mentions: {
0: {
mentionDisplayName: "Thane of Cawdor",
mentionId: "macbeth",
mentionTye: "user"
},
1: {
mentionDisplayName: "Lord Banquo",
mentionId: "banquo",
mentionTye: "user"
}
}
});
});
afterEach(function() {
saveStub.restore();
fetchStub.restore();
currentUserStub.restore();
});
it('shows edit link for owner comments', function() {
var $comment = view.$el.find('.comment[data-id=1]');
expect($comment.length).toEqual(1);
$comment.find('.action.more').trigger('click');
expect($comment.find('.action.edit').length).toEqual(1);
});
it('does not show edit link for other user\'s comments', function() {
var $comment = view.$el.find('.comment[data-id=2]');
expect($comment.length).toEqual(1);
$comment.find('.action.more').trigger('click');
expect($comment.find('.action.edit').length).toEqual(0);
});
it('shows edit form when clicking edit', function() {
var $comment = view.$el.find('.comment[data-id=1]');
$comment.find('.action.more').trigger('click');
$comment.find('.action.edit').trigger('click');
expect($comment.hasClass('hidden')).toEqual(true);
var $formRow = view.$el.find('.newCommentRow.comment[data-id=1]');
expect($formRow.length).toEqual(1);
});
it('saves message and updates comment item when clicking save', function() {
var $comment = view.$el.find('.comment[data-id=1]');
$comment.find('.action.more').trigger('click');
$comment.find('.action.edit').trigger('click');
var $formRow = view.$el.find('.newCommentRow.comment[data-id=1]');
expect($formRow.length).toEqual(1);
$formRow.find('div.message').text('modified message');
$formRow.find('form').submit();
expect(saveStub.calledOnce).toEqual(true);
expect(saveStub.lastCall.args[0]).toEqual({
message: 'modified message'
});
var model = view.collection.get(1);
// simulate the fact that save sets the attribute
model.set('message', 'modified\nmessage');
saveStub.yieldTo('success', model);
view.collection.get(model);
expect(fetchStub.called).toEqual(true);
fetchStub.yieldTo('success', model);
// original comment element is visible again
expect($comment.hasClass('hidden')).toEqual(false);
// and its message was updated
expect($comment.find('.message').html()).toEqual('modified<br>message');
// form row is gone
$formRow = view.$el.find('.newCommentRow.comment[data-id=1]');
expect($formRow.length).toEqual(0);
});
it('saves message and updates comment item with mentions when clicking save', function() {
var $comment = view.$el.find('.comment[data-id=3]');
$comment.find('.action.more').trigger('click');
$comment.find('.action.edit').trigger('click');
var $formRow = view.$el.find('.newCommentRow.comment[data-id=3]');
expect($formRow.length).toEqual(1);
$formRow.find('div.message').text('modified\nmessage @anotheruser');
$formRow.find('form').submit();
expect(saveStub.calledOnce).toEqual(true);
expect(saveStub.lastCall.args[0]).toEqual({
message: 'modified\nmessage @anotheruser'
});
var model = view.collection.get(3);
// simulate the fact that save sets the attribute
model.set('message', 'modified\nmessage @anotheruser');
saveStub.yieldTo('success', model);
expect(fetchStub.called).toEqual(true);
// simulate the fact that fetch sets the attribute
model.set('mentions', {
0: {
mentionDisplayName: "Another User",
mentionId: "anotheruser",
mentionTye: "user"
}
});
fetchStub.yieldTo('success', model);
// original comment element is visible again
expect($comment.hasClass('hidden')).toEqual(false);
// and its message was updated
var $message = $comment.find('.message');
expect($message.html()).toContain('modified<br>message');
expect($message.find('.avatar').length).toEqual(1);
expect($message.find('.avatar[data-user=anotheruser]').length).toEqual(1);
expect($message.find('.avatar[data-user=anotheruser] ~ strong').text()).toEqual('Another User');
expect($message.find('.avatar[data-user=anotheruser] ~ .contactsmenu-popover').length).toEqual(1);
// form row is gone
$formRow = view.$el.find('.newCommentRow.comment[data-id=3]');
expect($formRow.length).toEqual(0);
});
it('restores original comment when cancelling', function() {
var $comment = view.$el.find('.comment[data-id=1]');
$comment.find('.action.more').trigger('click');
$comment.find('.action.edit').trigger('click');
var $formRow = view.$el.find('.newCommentRow.comment[data-id=1]');
expect($formRow.length).toEqual(1);
$formRow.find('textarea').val('modified\nmessage');
$formRow.find('.cancel').trigger('click');
expect(saveStub.notCalled).toEqual(true);
// original comment element is visible again
expect($comment.hasClass('hidden')).toEqual(false);
// and its message was not updated
expect($comment.find('.message').html()).toEqual('New message');
// form row is gone
$formRow = view.$el.find('.newCommentRow.comment[data-id=1]');
expect($formRow.length).toEqual(0);
});
it('destroys model when clicking delete', function() {
var destroyStub = sinon.stub(OCA.Comments.CommentModel.prototype, 'destroy');
var $comment = view.$el.find('.comment[data-id=1]');
$comment.find('.action.more').trigger('click');
$comment.find('.action.delete').trigger('click');
expect(destroyStub.calledOnce).toEqual(true);
expect(destroyStub.thisValues[0].id).toEqual(1);
destroyStub.yieldTo('success');
// original comment element is gone
$comment = view.$el.find('.comment[data-id=1]');
expect($comment.length).toEqual(0);
destroyStub.restore();
});
it('does not submit comment if the field is empty', function() {
var $comment = view.$el.find('.comment[data-id=1]');
$comment.find('.action.edit').trigger('click');
$comment.find('.message').val(' ');
$comment.find('form').submit();
expect(saveStub.notCalled).toEqual(true);
});
it('does not submit comment if the field length is too large', function() {
var $comment = view.$el.find('.comment[data-id=1]');
$comment.find('.action.edit').trigger('click');
$comment.find('.message').val(createMessageWithLength(view._commentMaxLength * 2));
$comment.find('form').submit();
expect(saveStub.notCalled).toEqual(true);
});
});
describe('read marker', function() {
var updateMarkerStub;
beforeEach(function() {
updateMarkerStub = sinon.stub(OCA.Comments.CommentCollection.prototype, 'updateReadMarker');
});
afterEach(function() {
updateMarkerStub.restore();
});
it('resets the read marker after REPORT', function() {
testComments[0].set('isUnread', true, {silent: true});
testComments[1].set('isUnread', true, {silent: true});
view.collection.set(testComments);
view.collection.trigger('sync', 'REPORT');
expect(updateMarkerStub.calledOnce).toEqual(true);
expect(updateMarkerStub.lastCall.args[0]).toBeFalsy();
});
it('does not reset the read marker if there was no unread comments', function() {
view.collection.set(testComments);
view.collection.trigger('sync', 'REPORT');
expect(updateMarkerStub.notCalled).toEqual(true);
});
it('does not reset the read marker when posting comments', function() {
testComments[0].set('isUnread', true, {silent: true});
testComments[1].set('isUnread', true, {silent: true});
view.collection.set(testComments);
view.collection.trigger('sync', 'POST');
expect(updateMarkerStub.notCalled).toEqual(true);
});
});
});

View File

@ -65,16 +65,18 @@ describe('OCA.Comments.FilesPlugin tests', function() {
expect($tr.attr('data-comments-unread')).toEqual('3');
});
it('clicking icon opens sidebar', function() {
var sidebarStub = sinon.stub(fileList, 'showDetailsView');
var sidebarTabStub = sinon.stub(OCA.Files.Sidebar, 'setActiveTab');
var sidebarStub = sinon.stub(OCA.Files.Sidebar, 'open');
var $action, $tr;
fileList.setFiles(testFiles);
$tr = fileList.findFileEl('One.txt');
$action = $tr.find('.action-comment');
$action.click();
expect(sidebarTabStub.calledOnce).toEqual(true);
expect(sidebarTabStub.lastCall.args[0]).toEqual('comments');
expect(sidebarStub.calledOnce).toEqual(true);
expect(sidebarStub.lastCall.args[0]).toEqual('One.txt');
expect(sidebarStub.lastCall.args[1]).toEqual('comments');
expect(sidebarStub.lastCall.args[0]).toEqual('/One.txt');
});
});
describe('elementToFile', function() {

View File

@ -1,14 +1,18 @@
const path = require('path')
module.exports = {
entry: path.join(__dirname, 'src', 'comments.js'),
entry: {
comments: path.join(__dirname, 'src', 'comments.js'),
'comments-app': path.join(__dirname, 'src', 'comments-app.js'),
'comments-tab': path.join(__dirname, 'src', 'comments-tab.js'),
},
output: {
path: path.resolve(__dirname, './js'),
publicPath: '/js/',
filename: 'comments.js',
jsonpFunction: 'webpackJsonpComments'
filename: '[name].js',
jsonpFunction: 'webpackJsonpComments',
},
externals: {
jquery: 'jQuery'
}
jquery: 'jQuery',
},
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -25,7 +25,8 @@
:id="id"
ref="tab"
:name="name"
:icon="icon">
:icon="icon"
@bottomReached="onScrollBottomReached">
<!-- Fallback loading -->
<EmptyContent v-if="loading" icon="icon-loading" />
@ -83,6 +84,10 @@ export default {
type: Function,
required: true,
},
onScrollBottomReached: {
type: Function,
default: () => {},
},
},
data() {
@ -120,6 +125,5 @@ export default {
// unmount the tab
await this.onDestroy()
},
}
</script>

View File

@ -29,6 +29,7 @@ export default class Tab {
#update
#destroy
#enabled
#scrollBottomReached
/**
* Create a new tab instance
@ -41,11 +42,15 @@ export default class Tab {
* @param {Function} options.update function to update the tab
* @param {Function} options.destroy function to destroy the tab
* @param {Function} [options.enabled] define conditions whether this tab is active. Must returns a boolean
* @param {Function} [options.scrollBottomReached] executed when the tab is scrolled to the bottom
*/
constructor({ id, name, icon, mount, update, destroy, enabled } = {}) {
constructor({ id, name, icon, mount, update, destroy, enabled, scrollBottomReached } = {}) {
if (enabled === undefined) {
enabled = () => true
}
if (scrollBottomReached === undefined) {
scrollBottomReached = () => {}
}
// Sanity checks
if (typeof id !== 'string' || id.trim() === '') {
@ -69,6 +74,9 @@ export default class Tab {
if (typeof enabled !== 'function') {
throw new Error('The enabled argument should be a function')
}
if (typeof scrollBottomReached !== 'function') {
throw new Error('The scrollBottomReached argument should be a function')
}
this.#id = id
this.#name = name
@ -77,6 +85,7 @@ export default class Tab {
this.#update = update
this.#destroy = destroy
this.#enabled = enabled
this.#scrollBottomReached = scrollBottomReached
}
@ -108,4 +117,8 @@ export default class Tab {
return this.#enabled
}
get scrollBottomReached() {
return this.#scrollBottomReached
}
}

View File

@ -69,6 +69,7 @@
:on-mount="tab.mount"
:on-update="tab.update"
:on-destroy="tab.destroy"
:on-scroll-bottom-reached="tab.scrollBottomReached"
:file-info="fileInfo" />
</template>
</AppSidebar>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1,2 @@
!function(e){var n={};function t(r){if(n[r])return n[r].exports;var o=n[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,t),o.l=!0,o.exports}t.m=e,t.c=n,t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{enumerable:!0,get:r})},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},t.t=function(e,n){if(1&n&&(e=t(e)),8&n)return e;if(4&n&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(t.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&n&&"string"!=typeof e)for(var o in e)t.d(r,o,function(n){return e[n]}.bind(null,o));return r},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},t.p="/js/",t(t.s=153)}({153:function(e,n,r){r.p=OC.linkTo("files_sharing","js/dist/"),r.nc=btoa(OC.requestToken),window.OCP.Collaboration.registerType("file",{action:function(){return new Promise((function(e,n){OC.dialogs.filepicker(t("files_sharing","Link to a file"),(function(t){OC.Files.getClient().getFileInfo(t).then((function(n,t){e(t.id)})).fail((function(){n(new Error("Cannot get fileinfo"))}))}),!1,null,!1,OC.dialogs.FILEPICKER_TYPE_CHOOSE,"",{allowDirectoryChooser:!0})}))},typeString:t("files_sharing","Link to a file"),typeIconClass:"icon-files-dark"})}});
!function(e){var n={};function t(r){if(n[r])return n[r].exports;var o=n[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,t),o.l=!0,o.exports}t.m=e,t.c=n,t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{enumerable:!0,get:r})},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},t.t=function(e,n){if(1&n&&(e=t(e)),8&n)return e;if(4&n&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(t.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&n&&"string"!=typeof e)for(var o in e)t.d(r,o,function(n){return e[n]}.bind(null,o));return r},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},t.p="/js/",t(t.s=185)}({185:function(e,n,r){r.p=OC.linkTo("files_sharing","js/dist/"),r.nc=btoa(OC.requestToken),window.OCP.Collaboration.registerType("file",{action:function(){return new Promise((function(e,n){OC.dialogs.filepicker(t("files_sharing","Link to a file"),(function(t){OC.Files.getClient().getFileInfo(t).then((function(n,t){e(t.id)})).fail((function(){n(new Error("Cannot get fileinfo"))}))}),!1,null,!1,OC.dialogs.FILEPICKER_TYPE_CHOOSE,"",{allowDirectoryChooser:!0})}))},typeString:t("files_sharing","Link to a file"),typeIconClass:"icon-files-dark"})}});
//# sourceMappingURL=collaboration.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,2 +1,2 @@
!function(e){var t={};function r(n){if(t[n])return t[n].exports;var o=t[n]={i:n,l:!1,exports:{}};return e[n].call(o.exports,o,o.exports,r),o.l=!0,o.exports}r.m=e,r.c=t,r.d=function(e,t,n){r.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,"a",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p="/js/",r(r.s=278)}({278:function(e,t){Object.assign(OC,{Share:{SHARE_TYPE_USER:0,SHARE_TYPE_GROUP:1,SHARE_TYPE_LINK:3,SHARE_TYPE_EMAIL:4,SHARE_TYPE_REMOTE:6,SHARE_TYPE_CIRCLE:7,SHARE_TYPE_GUEST:8,SHARE_TYPE_REMOTE_GROUP:9,SHARE_TYPE_ROOM:10}})}});
!function(e){var t={};function r(n){if(t[n])return t[n].exports;var o=t[n]={i:n,l:!1,exports:{}};return e[n].call(o.exports,o,o.exports,r),o.l=!0,o.exports}r.m=e,r.c=t,r.d=function(e,t,n){r.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:n})},r.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},r.t=function(e,t){if(1&t&&(e=r(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var n=Object.create(null);if(r.r(n),Object.defineProperty(n,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)r.d(n,o,function(t){return e[t]}.bind(null,o));return n},r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,"a",t),t},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},r.p="/js/",r(r.s=358)}({358:function(e,t){Object.assign(OC,{Share:{SHARE_TYPE_USER:0,SHARE_TYPE_GROUP:1,SHARE_TYPE_LINK:3,SHARE_TYPE_EMAIL:4,SHARE_TYPE_REMOTE:6,SHARE_TYPE_CIRCLE:7,SHARE_TYPE_GUEST:8,SHARE_TYPE_REMOTE_GROUP:9,SHARE_TYPE_ROOM:10}})}});
//# sourceMappingURL=main.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More