Cleanup old comments tab

Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
This commit is contained in:
John Molakvoæ (skjnldsv) 2020-10-16 22:50:01 +02:00
parent e7f5516b4d
commit afa7376522
No known key found for this signature in database
GPG Key ID: 60C25B8C072916CF
18 changed files with 3 additions and 2432 deletions

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

@ -1,15 +1,9 @@
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'

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

@ -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

@ -25,7 +25,7 @@ import { processResponsePayload } from 'webdav/dist/node/response'
import client from './DavClient'
import { genFileInfo } from '../utils/fileUtils'
export const DEFAULT_LIMIT = 5
export const DEFAULT_LIMIT = 20
/**
* Retrieve the comments list
*

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>

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,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);
});
});
});