diff --git a/apps/comments/appinfo/app.php b/apps/comments/appinfo/app.php index c6f36567c5..a1eb4f6899 100644 --- a/apps/comments/appinfo/app.php +++ b/apps/comments/appinfo/app.php @@ -27,6 +27,7 @@ $eventDispatcher->addListener( \OCP\Util::addScript('comments', 'app'); \OCP\Util::addScript('comments', 'commentmodel'); \OCP\Util::addScript('comments', 'commentcollection'); + \OCP\Util::addScript('comments', 'commentsummarymodel'); \OCP\Util::addScript('comments', 'commentstabview'); \OCP\Util::addScript('comments', 'filesplugin'); \OCP\Util::addStyle('comments', 'comments'); diff --git a/apps/comments/css/comments.css b/apps/comments/css/comments.css index c1624dcc57..5e247aaeb7 100644 --- a/apps/comments/css/comments.css +++ b/apps/comments/css/comments.css @@ -49,3 +49,7 @@ position: absolute; right: 0; } + +.app-files .action-comment>img { + margin-right: 5px; +} diff --git a/apps/comments/js/commentcollection.js b/apps/comments/js/commentcollection.js index d10e5e0086..a15039cf48 100644 --- a/apps/comments/js/commentcollection.js +++ b/apps/comments/js/commentcollection.js @@ -10,8 +10,6 @@ (function(OC, OCA) { - var NS_OWNCLOUD = 'http://owncloud.org/ns'; - /** * @class OCA.Comments.CommentCollection * @classdesc @@ -26,12 +24,40 @@ 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) { @@ -58,6 +84,7 @@ reset: function() { this._endReached = false; + this._summaryModel = null; return OC.Backbone.Collection.prototype.reset.apply(this, arguments); }, @@ -81,6 +108,7 @@ var success = options.success; options = _.extend({ remove: false, + parse: true, data: body, davProperties: CommentCollection.prototype.model.prototype.davProperties, success: function(resp) { @@ -102,6 +130,35 @@ }, options); return this.sync('REPORT', this, options); + }, + + /** + * Returns the matching summary model + * + * @return {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); } }); diff --git a/apps/comments/js/commentmodel.js b/apps/comments/js/commentmodel.js index b945f71fdd..ba04fd61de 100644 --- a/apps/comments/js/commentmodel.js +++ b/apps/comments/js/commentmodel.js @@ -34,11 +34,12 @@ 'actorDisplayName': '{' + NS_OWNCLOUD + '}actorDisplayName', 'creationDateTime': '{' + NS_OWNCLOUD + '}creationDateTime', 'objectType': '{' + NS_OWNCLOUD + '}objectType', - 'objectId': '{' + NS_OWNCLOUD + '}objectId' + 'objectId': '{' + NS_OWNCLOUD + '}objectId', + 'isUnread': '{' + NS_OWNCLOUD + '}isUnread' }, parse: function(data) { - // TODO: parse non-string values + data.isUnread = (data.isUnread === 'true'); return data; } }); diff --git a/apps/comments/js/commentstabview.js b/apps/comments/js/commentstabview.js index 463ac2d76e..188d8c5943 100644 --- a/apps/comments/js/commentstabview.js +++ b/apps/comments/js/commentstabview.js @@ -31,7 +31,7 @@ ''; var COMMENT_TEMPLATE = - '
  • ' + + '
  • ' + '
    ' + ' {{#if avatarEnabled}}' + '
    ' + @@ -97,12 +97,14 @@ setFileInfo: function(fileInfo) { if (fileInfo) { + this.model = fileInfo; this.render(); this.collection.setObjectId(fileInfo.id); // reset to first page this.collection.reset([], {silent: true}); this.nextPage(); } else { + this.model = null; this.render(); this.collection.reset(); } @@ -139,10 +141,29 @@ this.$el.find('.showMore').addClass('hidden'); }, - _onEndRequest: function() { + _onEndRequest: function(type) { + var fileInfoModel = this.model; this._toggleLoading(false); this.$el.find('.empty').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); + } + } + ); + } }, _onAddModel: function(model, collection, options) { @@ -210,7 +231,7 @@ actorType: 'users', verb: 'comment', message: $textArea.val(), - creationDateTime: (new Date()).getTime() + creationDateTime: (new Date()).toUTCString() }, { at: 0, success: function() { diff --git a/apps/comments/js/commentsummarymodel.js b/apps/comments/js/commentsummarymodel.js new file mode 100644 index 0000000000..d405315ca1 --- /dev/null +++ b/apps/comments/js/commentsummarymodel.js @@ -0,0 +1,65 @@ +/* + * 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) { + var NS_OWNCLOUD = 'http://owncloud.org/ns'; + /** + * @class OCA.Comments.CommentSummaryModel + * @classdesc + * + * Model containing summary information related to comments + * like the read marker. + * + */ + var 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': '{' + NS_OWNCLOUD + '}readMarker' + }, + + /** + * Initializes the summary model + * + * @param {string} [options.objectType] object type + * @param {string} [options.objectId] object id + */ + initialize: function(attrs, options) { + options = options || {}; + if (options.objectType) { + this._objectType = options.objectType; + } + }, + + url: function() { + return OC.linkToRemote('dav') + '/comments/' + + encodeURIComponent(this._objectType) + '/' + + encodeURIComponent(this.id) + '/'; + } + }); + + OCA.Comments.CommentSummaryModel = CommentSummaryModel; +})(OC, OCA); + diff --git a/apps/comments/js/filesplugin.js b/apps/comments/js/filesplugin.js index c8d91e0ede..bf6bb05146 100644 --- a/apps/comments/js/filesplugin.js +++ b/apps/comments/js/filesplugin.js @@ -8,7 +8,15 @@ * */ +/* global Handlebars */ + (function() { + var TEMPLATE_COMMENTS_UNREAD = + '' + + '' + + '{{count}}' + + ''; + OCA.Comments = _.extend({}, OCA.Comments); if (!OCA.Comments) { /** @@ -26,12 +34,88 @@ 'favorites' ], + _formatCommentCount: function(count) { + if (!this._commentsUnreadTemplate) { + this._commentsUnreadTemplate = Handlebars.compile(TEMPLATE_COMMENTS_UNREAD); + } + return this._commentsUnreadTemplate({ + count: count, + countMessage: t('comments', '{count} unread comments', {count: count}), + iconUrl: OC.imagePath('core', 'actions/comment') + }); + }, + attach: function(fileList) { + var self = this; if (this.allowedLists.indexOf(fileList.id) < 0) { return; } fileList.registerTabView(new OCA.Comments.CommentsTabView('commentsTabView')); + + var NS_OC = 'http://owncloud.org/ns'; + + var oldGetWebdavProperties = fileList._getWebdavProperties; + fileList._getWebdavProperties = function() { + var props = oldGetWebdavProperties.apply(this, arguments); + props.push('{' + NS_OC + '}comments-unread'); + return props; + }; + + fileList.filesClient.addFileInfoParser(function(response) { + var data = {}; + var props = response.propStat[0].properties; + var commentsUnread = props['{' + NS_OC + '}comments-unread']; + if (!_.isUndefined(commentsUnread) && commentsUnread !== '') { + data.commentsUnread = parseInt(commentsUnread, 10); + } + return data; + }); + + fileList.$el.addClass('has-comments'); + var oldCreateRow = fileList._createRow; + fileList._createRow = function(fileData) { + var $tr = oldCreateRow.apply(this, arguments); + if (fileData.commentsUnread) { + $tr.attr('data-comments-unread', fileData.commentsUnread); + } + return $tr; + }; + + // register "comment" action for reading comments + fileList.fileActions.registerAction({ + name: 'Comment', + displayName: t('comments', 'Comment'), + mime: 'all', + permissions: OC.PERMISSION_READ, + type: OCA.Files.FileActions.TYPE_INLINE, + render: function(actionSpec, isDefault, context) { + var $file = context.$file; + var unreadComments = $file.data('comments-unread'); + if (unreadComments) { + var $actionLink = $(self._formatCommentCount(unreadComments)); + context.$file.find('a.name>span.fileactions').append($actionLink); + return $actionLink; + } + return ''; + }, + actionHandler: function(fileName, context) { + context.$file.find('.action-comment').tooltip('hide'); + // open sidebar in comments section + context.fileList.showDetailsView(fileName, 'commentsTabView'); + } + }); + + // add attribute to "elementToFile" + var oldElementToFile = fileList.elementToFile; + fileList.elementToFile = function($el) { + var fileInfo = oldElementToFile.apply(this, arguments); + var commentsUnread = $el.data('comments-unread'); + if (commentsUnread) { + fileInfo.commentsUnread = commentsUnread; + } + return fileInfo; + }; } }; diff --git a/apps/comments/tests/js/commentscollectionSpec.js b/apps/comments/tests/js/commentscollectionSpec.js index 0dc68cc167..2f41a272f6 100644 --- a/apps/comments/tests/js/commentscollectionSpec.js +++ b/apps/comments/tests/js/commentscollectionSpec.js @@ -100,5 +100,49 @@ describe('OCA.Comments.CommentCollection', function() { 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); + }); + }); }); diff --git a/apps/comments/tests/js/commentstabviewSpec.js b/apps/comments/tests/js/commentstabviewSpec.js index 0fb5eec065..432fa5ddc4 100644 --- a/apps/comments/tests/js/commentstabviewSpec.js +++ b/apps/comments/tests/js/commentstabviewSpec.js @@ -48,7 +48,7 @@ describe('OCA.Comments.CommentsTabView tests', function() { objectType: 'files', objectId: 5, message: 'First', - creationDateTime: Date.UTC(2016, 1, 3, 10, 5, 0) + creationDateTime: new Date(Date.UTC(2016, 1, 3, 10, 5, 0)).toUTCString() }); var comment2 = new OCA.Comments.CommentModel({ id: 2, @@ -58,7 +58,7 @@ describe('OCA.Comments.CommentsTabView tests', function() { objectType: 'files', objectId: 5, message: 'Second\nNewline', - creationDateTime: Date.UTC(2016, 1, 3, 10, 0, 0) + creationDateTime: new Date(Date.UTC(2016, 1, 3, 10, 0, 0)).toUTCString() }); testComments = [comment1, comment2]; @@ -142,7 +142,7 @@ describe('OCA.Comments.CommentsTabView tests', function() { objectType: 'files', objectId: 5, message: 'Third', - creationDateTime: Date.UTC(2016, 1, 3, 5, 0, 0) + creationDateTime: new Date(Date.UTC(2016, 1, 3, 5, 0, 0)).toUTCString() }); view.collection.add(comment3); @@ -184,7 +184,7 @@ describe('OCA.Comments.CommentsTabView tests', function() { actorType: 'users', verb: 'comment', message: 'New message', - creationDateTime: Date.UTC(2016, 1, 3, 10, 5, 9) + creationDateTime: new Date(Date.UTC(2016, 1, 3, 10, 5, 9)).toUTCString() }); }); it('does not create a comment if the field is empty', function() { @@ -195,4 +195,38 @@ describe('OCA.Comments.CommentsTabView tests', function() { }); }); + 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); + }); + }); }); diff --git a/apps/comments/tests/js/filespluginSpec.js b/apps/comments/tests/js/filespluginSpec.js new file mode 100644 index 0000000000..78becc5af0 --- /dev/null +++ b/apps/comments/tests/js/filespluginSpec.js @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2016 Vincent Petry + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ + +describe('OCA.Comments.FilesPlugin tests', function() { + var fileList; + var testFiles; + + beforeEach(function() { + var $content = $('
    '); + $('#testArea').append($content); + // dummy file list + var $div = $( + '
    ' + + '' + + '' + + '' + + '
    ' + + '
    '); + $('#content').append($div); + + fileList = new OCA.Files.FileList($div); + OCA.Comments.FilesPlugin.attach(fileList); + + testFiles = [{ + id: 1, + type: 'file', + name: 'One.txt', + path: '/subdir', + mimetype: 'text/plain', + size: 12, + permissions: OC.PERMISSION_ALL, + etag: 'abc', + shareOwner: 'User One', + isShareMountPoint: false, + commentsUnread: 3 + }]; + }); + afterEach(function() { + fileList.destroy(); + fileList = null; + }); + + describe('Comment icon', function() { + it('does not render icon when no unread comments available', function() { + testFiles[0].commentsUnread = 0; + fileList.setFiles(testFiles); + var $tr = fileList.findFileEl('One.txt'); + expect($tr.find('.action-comment').length).toEqual(0); + }); + it('renders comment icon and extra data', function() { + var $action, $tr; + fileList.setFiles(testFiles); + $tr = fileList.findFileEl('One.txt'); + $action = $tr.find('.action-comment'); + expect($action.length).toEqual(1); + expect($action.hasClass('permanent')).toEqual(true); + + expect($tr.attr('data-comments-unread')).toEqual('3'); + }); + it('clicking icon opens sidebar', function() { + var sidebarStub = sinon.stub(fileList, 'showDetailsView'); + var $action, $tr; + fileList.setFiles(testFiles); + $tr = fileList.findFileEl('One.txt'); + $action = $tr.find('.action-comment'); + $action.click(); + + expect(sidebarStub.calledOnce).toEqual(true); + expect(sidebarStub.lastCall.args[0]).toEqual('One.txt'); + expect(sidebarStub.lastCall.args[1]).toEqual('commentsTabView'); + }); + }); + describe('elementToFile', function() { + it('returns comment count', function() { + fileList.setFiles(testFiles); + var $tr = fileList.findFileEl('One.txt'); + var data = fileList.elementToFile($tr); + expect(data.commentsUnread).toEqual(3); + }); + it('does not set comment count when not set', function() { + delete testFiles[0].commentsUnread; + fileList.setFiles(testFiles); + var $tr = fileList.findFileEl('One.txt'); + var data = fileList.elementToFile($tr); + expect(data.commentsUnread).not.toBeDefined(); + }); + it('does not set comment count when zero', function() { + testFiles[0].commentsUnread = 0; + fileList.setFiles(testFiles); + var $tr = fileList.findFileEl('One.txt'); + var data = fileList.elementToFile($tr); + expect(data.commentsUnread).not.toBeDefined(); + }); + }); +}); diff --git a/core/img/actions/comment.png b/core/img/actions/comment.png new file mode 100644 index 0000000000..7ca20eba36 Binary files /dev/null and b/core/img/actions/comment.png differ diff --git a/core/img/actions/comment.svg b/core/img/actions/comment.svg new file mode 100644 index 0000000000..a8ab95e615 --- /dev/null +++ b/core/img/actions/comment.svg @@ -0,0 +1,4 @@ + + + + diff --git a/tests/karma.config.js b/tests/karma.config.js index 4a7a9ad236..2b569fb758 100644 --- a/tests/karma.config.js +++ b/tests/karma.config.js @@ -89,8 +89,9 @@ module.exports = function(config) { 'apps/comments/js/app.js', 'apps/comments/js/commentmodel.js', 'apps/comments/js/commentcollection.js', + 'apps/comments/js/commentsummarymodel.js', 'apps/comments/js/commentstabview.js', - 'apps/comments/js/filesplugin' + 'apps/comments/js/filesplugin.js' ], testFiles: ['apps/comments/tests/js/**/*.js'] },