diff --git a/appinfo/app.php b/appinfo/app.php index 4f8c51333..73ee9a086 100644 --- a/appinfo/app.php +++ b/appinfo/app.php @@ -28,5 +28,7 @@ $app = new \OCA\Deck\AppInfo\Application(); $app->registerNavigationEntry(); $app->registerNotifications(); +$app->registerCommentsEntity(); +/** Load activity style global so it is availabile in the activity app as well */ \OC_Util::addStyle('deck', 'activity'); diff --git a/appinfo/info.xml b/appinfo/info.xml index e6994c1f3..2b5363cf7 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -14,7 +14,7 @@ - 🚀 Get your project organized - 0.5.0-dev3 + 0.5.0-dev4 agpl Julius Härtl Deck diff --git a/css/autocomplete.scss b/css/autocomplete.scss new file mode 100644 index 000000000..0837b3878 --- /dev/null +++ b/css/autocomplete.scss @@ -0,0 +1,77 @@ +/** + * 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; +} diff --git a/css/icons.scss b/css/icons.scss index 1e8b25048..820bbc5df 100644 --- a/css/icons.scss +++ b/css/icons.scss @@ -37,6 +37,10 @@ background-image: url('../../../core/img/places/home.svg'); } +.icon-description { + background-image: url('../img/description.svg'); +} + .icon-badge { background-image: url('../img/calendar-dark.svg'); } diff --git a/css/style.scss b/css/style.scss index b62818f36..2b8f9caac 100644 --- a/css/style.scss +++ b/css/style.scss @@ -41,6 +41,7 @@ $compact-board-last-item-margin: 5px 10px 10px; @import 'icons'; @import 'animations'; @import 'compact-mode'; +@import 'autocomplete'; /** * General styles @@ -454,9 +455,10 @@ input.input-inline { opacity: 1; } - .icon-filetype-text { + .icon-description { margin: 10px; margin-left: 0px; + opacity: 0.5; } .due { @@ -492,7 +494,7 @@ input.input-inline { } } - .card-tasks, .card-files { + .card-tasks, .card-files, .card-comments { border-radius: 3px; margin: 4px 4px 4px 0px; padding: 0 2px; @@ -937,6 +939,63 @@ input.input-inline { } } +.activity-icon { + opacity: 1 !important; + .avatardiv-container { + top: -4px; + left: -7px; + margin-right: 5px; + img { + max-width: 24px; + max-height: 24px; + } + } +} + +.activitysubject.commentAuthor { + margin-left: 26px; + margin-right: 0; + margin-top: 10px; +} +.activityTabView { + .activity { + margin-bottom: 20px; + } + .activitytime { + margin: 0 !important; + } +} +.activitysubject .app-popover-menu-utils { + display: inline-block; + a { + font-weight: normal; + } + button { + opacity: .5; + padding: 7px; + margin-left: 10px; + } +} + +#commentsTabView { + .newCommentRow .avatardiv-container { + left: -7px; + } + .comment { + position: relative; + padding: 0 0 15px; + + .avatardiv { + width: 24px; + height: 24px; + line-height: 24px; + } + } + .newCommentForm { + margin-left: 26px; + } +} + .card-attachments { .error { padding-left: 38px; @@ -1250,6 +1309,13 @@ input.input-inline { clear: both; overflow: initial; margin-bottom: 0; + .icon { + display: inline-block; + background-size: contain; + margin: -3px; + margin-right: 5px; + opacity: 0.5; + } } .tabsContainer { diff --git a/img/description.svg b/img/description.svg new file mode 100644 index 000000000..a31c5a53d --- /dev/null +++ b/img/description.svg @@ -0,0 +1 @@ + diff --git a/js/app/App.js b/js/app/App.js index f47a34fc3..332a318ec 100644 --- a/js/app/App.js +++ b/js/app/App.js @@ -49,6 +49,8 @@ import md from 'angular-markdown-it'; import nganimate from 'angular-animate'; import 'angular-file-upload'; import ngInfiniteScroll from 'ng-infinite-scroll'; +import '../legacy/jquery.atwho.min'; +import '../legacy/jquery.caret.min'; var app = angular.module('Deck', [ ngsanitize, diff --git a/js/app/Run.js b/js/app/Run.js index 60523305c..c5cd45606 100644 --- a/js/app/Run.js +++ b/js/app/Run.js @@ -4,20 +4,20 @@ * @author Julius Härtl * * @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 . - * + * */ import app from './App.js'; diff --git a/js/controller/ActivityController.js b/js/controller/ActivityController.js index 77ebeaef6..542be9195 100644 --- a/js/controller/ActivityController.js +++ b/js/controller/ActivityController.js @@ -20,27 +20,238 @@ * */ -/* global OC OCA */ +/* global OC OCA OCP t escapeHTML */ + +import CommentCollection from '../legacy/commentcollection'; +import CommentModel from '../legacy/commentmodel'; class ActivityController { - constructor ($scope, CardService, ActivityService) { + constructor ($scope, CardService, ActivityService, BoardService) { 'ngInject'; this.cardservice = CardService; + this.boardservice = BoardService; this.activityservice = ActivityService; this.$scope = $scope; this.type = ''; this.loading = false; + this.status = { + commentCreateLoading: false + }; + this.$scope.newComment = ''; + + this.currentUser = OC.getCurrentUser(); const self = this; this.$scope.$watch(function () { return self.element.id; }, function (params) { if (self.getData(self.element.id).length === 0) { + self.activityservice.loadComments(self.element.id); self.loading = true; self.fetchUntilResults(); } self.activityservice.fetchNewerActivities(self.type, self.element.id).then(function () {}); + self.cardservice.getCurrent().commentsUnread = 0; }, true); + + let $target = $('.newCommentForm .message'); + this.applyAtWho($target); + + this.activityservice.subscribe(this.$scope, function() { + self.$scope.$apply(); + }); + } + + applyAtWho($target) { + const self = this; + if (!$target) { + return; + } + $target.atwho({ + at: '@', + callbacks: { + remoteFilter: function(query, callback) { + let uids = self.boardservice.getUsers(); + uids = uids.filter((x) => x.uid.toLowerCase().includes(query.toLowerCase()) || x.displayname.toLowerCase().includes(query.toLowerCase())); + callback(uids); + }, + 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 '
  • ' + + '' + + '' + + '' + + '' + escapeHTML(item.displayname) + '' + + '
  • '; + }, + insertTpl: function (item) { + return '' + + '' + + '' + + '' + + '' + escapeHTML(item.displayname) + '' + + ''; + }, + searchKey: 'displayname' + }); + $target.on('inserted.atwho', function (je, $el) { + $(je.target).find( + 'span[data-username="' + $el.find('[data-username]').data('username') + '"]' + ).avatar(); + }); + $target.on('shown.atwho', function (je) { + $target.find('.avatar').avatar(); + }); + } + + commentBodyToPlain(content) { + let $comment = $('
    ').html(content); + $comment.find('.avatar-name-wrapper').each(function () { + var $this = $(this); + var $inserted = $this.parent(); + $inserted.html('@' + $this.find('.avatar').data('username')); + }); + $comment.html(OCP.Comments.richToPlain($comment.html())); + $comment.html($comment.html().replace(//gi, '\n')); + return $comment.text(); + } + + static _composeHTMLMention(uid, displayName) { + var avatar = '' + + '' + + ''; + + var isCurrentUser = (uid === OC.getCurrentUser().uid); + + return '' + + '' + + '' + + avatar + + '' + escapeHTML(displayName) + '' + + '' + + ''; + } + + formatMessage(activity) { + let message = activity.message; + let mentions = activity.commentModel.get('mentions'); + const editMode = false; + message = escapeHTML(message).replace(/\n/g, '
    '); + + for(var i in mentions) { + if(!mentions.hasOwnProperty(i)) { + return; + } + var mention = '@' + mentions[i].mentionId; + // escape possible regex characters in the name + mention = mention.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + + const displayName = ActivityController._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(new RegExp('(^|\\s)(' + mention + ')\\b', 'g'), + 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; + } + + postComment() { + const self = this; + this.status.commentCreateLoading = true; + + let content = this.commentBodyToPlain(self.$scope.newComment); + if (content.length < 1) { + self.status.commentCreateLoading = false; + OC.Notification.showTemporary(t('deck', 'Please provide a content for your comment.')); + return; + } + var model = this.activityservice.commentCollection.create({ + actorId: OC.getCurrentUser().uid, + actorDisplayName: OC.getCurrentUser().displayName, + actorType: 'users', + verb: 'comment', + message: content, + creationDateTime: (new Date()).toUTCString() + }, { + at: 0, + // wait for real creation before adding + wait: true, + success: function() { + self.$scope.newComment = ''; + self.activityservice.fetchNewerActivities(self.type, self.element.id).then(function () {}); + self.status.commentCreateLoading = false; + }, + error: function() { + self.status.commentCreateLoading = false; + OC.Notification.showTemporary(t('deck', 'Posting the comment failed.')); + } + }); + } + + updateComment(item) { + item.commentEdit = this.formatMessage(item); + let $target = $('.newCommentForm .message'); + this.applyAtWho($target); + /** Workaround to trigger avatar rendering after the view has been updated */ + window.setTimeout(function () { + $target.find('.avatar').avatar(undefined, 16); + }, 0); + } + + editComment(item) { + const self = this; + let content = this.commentBodyToPlain(item.commentEdit); + if (content.length < 1) { + OC.Notification.showTemporary(t('deck', 'Please provide a content for your comment.')); + return; + } + /** We need to save the model and afterwards run a fetch to update the mentions + * and call apply to propagate the changes to angular + */ + item.commentModel.on('sync', function() { + item.commentModel.off('sync'); + item.commentModel.fetch({ + success: function() { + self.$scope.$apply(); + } + }); + }); + item.commentModel.save({ + message: content, + }); + item.message = content; + item.commentEdit = undefined; + } + + deleteComment(item) { + item.commentModel.destroy(); + item.deleted = true; + item.commentModel = undefined; + item.message = t('deck', 'The comment has been deleted'); } getData(id) { @@ -59,7 +270,7 @@ class ActivityController { let promise = self.activityservice.fetchMoreActivities(self.type, self.element.id); promise.then(function (data) { let dataLengthAfter = self.getData(self.element.id).length; - if (data !== null && (dataLengthAfter <= dataLengthBefore || dataLengthAfter < 5)) { + if (data !== null && (dataLengthAfter <= dataLengthBefore || dataLengthAfter < self.activityservice.RESULT_PER_PAGE)) { _executeFetch(); } else { self.loading = false; @@ -73,6 +284,15 @@ class ActivityController { _executeFetch(); } + getComments() { + return this.activityservice.comments; + } + + getActivityStream() { + let activities = this.activityservice.getData(this.type, this.element.id); + return activities; + } + page() { if (!this.activityservice.since[this.type][this.element.id].finished) { this.loading = true; @@ -86,6 +306,9 @@ class ActivityController { return this.activityservice.runningNewer; } + t(text) { + return t('deck', text); + } } let activityComponent = { diff --git a/js/controller/BoardController.js b/js/controller/BoardController.js index 2e3653ad0..d4951fbd2 100644 --- a/js/controller/BoardController.js +++ b/js/controller/BoardController.js @@ -482,4 +482,8 @@ app.controller('BoardController', function ($rootScope, $scope, $stateParams, St } return card.attachmentCount; }; + + $scope.unreadCommentCount = function(card) { + return card.commentsUnread; + }; }); diff --git a/js/directive/contenteditable.js b/js/directive/contenteditable.js new file mode 100644 index 000000000..abf91d8c1 --- /dev/null +++ b/js/directive/contenteditable.js @@ -0,0 +1,59 @@ +/* + * @copyright Copyright (c) 2018 Julius Härtl + * + * @author Julius Härtl + * + * @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 . + * + */ + +import app from '../app/App'; + +app.directive('ngContenteditable', function($compile) { + return { + require: 'ngModel', + restrict: 'A', + scope: { + submit: '&ngSubmit' + }, + link: function(scope, element, attrs, ngModel) { + + //read the text typed in the div (syncing model with the view) + function read() { + ngModel.$setViewValue(element.html()); + } + + //render the data now in your model into your view + //$render is invoked when the modelvalue differs from the viewvalue + //see documentation: https://docs.angularjs.org/api/ng/type/ngModel.NgModelController# + ngModel.$render = function() { + element.html(ngModel.$viewValue || ''); + }; + + //do this whenever someone starts typing + element.bind('blur keyup change', function(event) { + scope.$apply(read); + }); + + element.bind('keydown', function(event) { + if(event.which === 13 && event.shiftKey) { + scope.submit(); + } + }); + + } + }; +}); diff --git a/js/legacy/commentcollection.js b/js/legacy/commentcollection.js new file mode 100644 index 000000000..6c7427abf --- /dev/null +++ b/js/legacy/commentcollection.js @@ -0,0 +1,161 @@ +/** + * @licence + */ + +import CommentModel from './commentmodel.js'; +import CommentSummaryModel from './commentsummarymodel.js'; + +/** + * @class CommentCollection + * @classdesc + * + * Collection of comments assigned to a file + * + */ +var CommentCollection = OC.Backbone.Collection.extend( + /** @lends OCA.AnnouncementCenter.Comments.CommentCollection.prototype */ { + + sync: OC.Backbone.davSync, + + model: CommentModel, + + /** + * Object type + * + * @type string + */ + _objectType: 'deckCard', + + /** + * 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 : 5, + + /** + * 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 = '\n' + + '\n' + + // load one more so we know there is more + ' ' + (this._limit + 1) + '\n' + + ' ' + this.length + '\n' + + '\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 + * + * @return {OCA.AnnouncementCenter.Comments.CommentSummaryModel} summary model + */ + getSummaryModel: function() { + if (!this._summaryModel) { + this._summaryModel = new 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); + } +}); + +export default CommentCollection; + diff --git a/js/legacy/commentmodel.js b/js/legacy/commentmodel.js new file mode 100644 index 000000000..1cf5c6bc3 --- /dev/null +++ b/js/legacy/commentmodel.js @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2016 + * + * This file is licensed under the Affero General Public License version 3 + * or later. + * + * See the COPYING-README file. + * + */ + +var NS_OWNCLOUD = 'http://owncloud.org/ns'; +/** + * @class CommentModel + * @classdesc + * + * Comment + * + */ +var CommentModel = OC.Backbone.Model.extend( + /** @lends OCA.Comments.CommentModel.prototype */ { + sync: OC.Backbone.davSync, + + /** + * Object type + * + * @type string + */ + _objectType: 'deckCard', + + /** + * Object id + * + * @type string + */ + _objectId: null, + + initialize: function(model, options) { + options = options || {}; + if (options.objectType) { + this._objectType = options.objectType; + } + if (options.objectId) { + this._objectId = options.objectId; + } + }, + + defaults: { + actorType: 'users', + objectType: 'deckCard' + }, + + davProperties: { + 'id': '{' + NS_OWNCLOUD + '}id', + 'message': '{' + NS_OWNCLOUD + '}message', + 'actorType': '{' + NS_OWNCLOUD + '}actorType', + 'actorId': '{' + NS_OWNCLOUD + '}actorId', + 'actorDisplayName': '{' + NS_OWNCLOUD + '}actorDisplayName', + 'creationDateTime': '{' + NS_OWNCLOUD + '}creationDateTime', + 'objectType': '{' + NS_OWNCLOUD + '}objectType', + 'objectId': '{' + NS_OWNCLOUD + '}objectId', + 'isUnread': '{' + NS_OWNCLOUD + '}isUnread', + 'mentions': '{' + NS_OWNCLOUD + '}mentions' + }, + + parse: function(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: function(mentions) { + if(_.isUndefined(mentions)) { + return {}; + } + var result = {}; + for(var i in mentions) { + var mention = mentions[i]; + if(_.isUndefined(mention.localName) || mention.localName !== 'mention') { + continue; + } + result[i] = {}; + for (var child = mention.firstChild; child; child = child.nextSibling) { + if(_.isUndefined(child.localName) || !child.localName.startsWith('mention')) { + continue; + } + result[i][child.localName] = child.textContent; + } + } + return result; + }, + + url: function() { + let baseUrl; + if (typeof this.collection === 'undefined') { + baseUrl = OC.linkToRemote('dav') + '/comments/' + + encodeURIComponent(this.get('objectType')) + '/' + + encodeURIComponent(this.get('objectId')) + '/'; + } else { + baseUrl = this.collection.url(); + } + if (typeof this.get('id') !== 'undefined') { + return baseUrl + this.get('id'); + } else { + return baseUrl; + } + } +}); + +export default CommentModel; + diff --git a/js/legacy/commentsummarymodel.js b/js/legacy/commentsummarymodel.js new file mode 100644 index 000000000..ca80d329c --- /dev/null +++ b/js/legacy/commentsummarymodel.js @@ -0,0 +1,54 @@ + +var NS_OWNCLOUD = 'http://owncloud.org/ns'; +/** + * @class OCA.AnnouncementCenter.Comments.CommentSummaryModel + * @classdesc + * + * Model containing summary information related to comments + * like the read marker. + * + */ +var CommentSummaryModel = OC.Backbone.Model.extend( + /** @lends OCA.AnnouncementCenter.Comments.CommentSummaryModel.prototype */ { + sync: OC.Backbone.davSync, + + /** + * Object type + * + * @type string + */ + _objectType: 'deckCard', + + /** + * 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) + '/'; + } +}); + +export default CommentSummaryModel; + diff --git a/js/legacy/jquery.atwho.min.js b/js/legacy/jquery.atwho.min.js new file mode 100644 index 000000000..d1e60152b --- /dev/null +++ b/js/legacy/jquery.atwho.min.js @@ -0,0 +1 @@ +!function(t,e){"function"==typeof define&&define.amd?define(["jquery"],function(t){return e(t)}):"object"==typeof exports?module.exports=e(require("jquery")):e(jQuery)}(this,function(t){var e,i;i={ESC:27,TAB:9,ENTER:13,CTRL:17,A:65,P:80,N:78,LEFT:37,UP:38,RIGHT:39,DOWN:40,BACKSPACE:8,SPACE:32},e={beforeSave:function(t){return r.arrayToDefaultHash(t)},matcher:function(t,e,i,n){var r,o,s,a,h;return t=t.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g,"\\$&"),i&&(t="(?:^|\\s)"+t),r=decodeURI("%C3%80"),o=decodeURI("%C3%BF"),h=n?" ":"",a=new RegExp(t+"([A-Za-z"+r+"-"+o+"0-9_"+h+"'.+-]*)$|"+t+"([^\\x00-\\xff]*)$","gi"),s=a.exec(e),s?s[2]||s[1]:null},filter:function(t,e,i){var n,r,o,s;for(n=[],r=0,s=e.length;s>r;r++)o=e[r],~new String(o[i]).toLowerCase().indexOf(t.toLowerCase())&&n.push(o);return n},remoteFilter:null,sorter:function(t,e,i){var n,r,o,s;if(!t)return e;for(n=[],r=0,s=e.length;s>r;r++)o=e[r],o.atwho_order=new String(o[i]).toLowerCase().indexOf(t.toLowerCase()),o.atwho_order>-1&&n.push(o);return n.sort(function(t,e){return t.atwho_order-e.atwho_order})},tplEval:function(t,e){var i,n,r;r=t;try{return"string"!=typeof t&&(r=t(e)),r.replace(/\$\{([^\}]*)\}/g,function(t,i,n){return e[i]})}catch(n){return i=n,""}},highlighter:function(t,e){var i;return e?(i=new RegExp(">\\s*([^<]*?)("+e.replace("+","\\+")+")([^<]*)\\s*<","ig"),t.replace(i,function(t,e,i,n){return"> "+e+""+i+""+n+" <"})):t},beforeInsert:function(t,e,i){return t},beforeReposition:function(t){return t},afterMatchFailed:function(t,e){}};var n;n=function(){function e(e){this.currentFlag=null,this.controllers={},this.aliasMaps={},this.$inputor=t(e),this.setupRootElement(),this.listen()}return e.prototype.createContainer=function(e){var i;return null!=(i=this.$el)&&i.remove(),t(e.body).append(this.$el=t("
    "))},e.prototype.setupRootElement=function(e,i){var n,r;if(null==i&&(i=!1),e)this.window=e.contentWindow,this.document=e.contentDocument||this.window.document,this.iframe=e;else{this.document=this.$inputor[0].ownerDocument,this.window=this.document.defaultView||this.document.parentWindow;try{this.iframe=this.window.frameElement}catch(r){if(n=r,this.iframe=null,t.fn.atwho.debug)throw new Error("iframe auto-discovery is failed.\nPlease use `setIframe` to set the target iframe manually.\n"+n)}}return this.createContainer((this.iframeAsRoot=i)?this.document:document)},e.prototype.controller=function(t){var e,i,n,r;if(this.aliasMaps[t])i=this.controllers[this.aliasMaps[t]];else{r=this.controllers;for(n in r)if(e=r[n],n===t){i=e;break}}return i?i:this.controllers[this.currentFlag]},e.prototype.setContextFor=function(t){return this.currentFlag=t,this},e.prototype.reg=function(t,e){var i,n;return n=(i=this.controllers)[t]||(i[t]=this.$inputor.is("[contentEditable]")?new l(this,t):new s(this,t)),e.alias&&(this.aliasMaps[e.alias]=t),n.init(e),this},e.prototype.listen=function(){return this.$inputor.on("compositionstart",function(t){return function(e){var i;return null!=(i=t.controller())&&i.view.hide(),t.isComposing=!0,null}}(this)).on("compositionend",function(t){return function(e){return t.isComposing=!1,setTimeout(function(e){return t.dispatch(e)}),null}}(this)).on("keyup.atwhoInner",function(t){return function(e){return t.onKeyup(e)}}(this)).on("keydown.atwhoInner",function(t){return function(e){return t.onKeydown(e)}}(this)).on("blur.atwhoInner",function(t){return function(e){var i;return(i=t.controller())?(i.expectedQueryCBId=null,i.view.hide(e,i.getOpt("displayTimeout"))):void 0}}(this)).on("click.atwhoInner",function(t){return function(e){return t.dispatch(e)}}(this)).on("scroll.atwhoInner",function(t){return function(){var e;return e=t.$inputor.scrollTop(),function(i){var n,r;return n=i.target.scrollTop,e!==n&&null!=(r=t.controller())&&r.view.hide(i),e=n,!0}}}(this)())},e.prototype.shutdown=function(){var t,e,i;i=this.controllers;for(t in i)e=i[t],e.destroy(),delete this.controllers[t];return this.$inputor.off(".atwhoInner"),this.$el.remove()},e.prototype.dispatch=function(t){var e,i,n,r;n=this.controllers,r=[];for(e in n)i=n[e],r.push(i.lookUp(t));return r},e.prototype.onKeyup=function(e){var n;switch(e.keyCode){case i.ESC:e.preventDefault(),null!=(n=this.controller())&&n.view.hide();break;case i.DOWN:case i.UP:case i.CTRL:case i.ENTER:t.noop();break;case i.P:case i.N:e.ctrlKey||this.dispatch(e);break;default:this.dispatch(e)}},e.prototype.onKeydown=function(e){var n,r;if(r=null!=(n=this.controller())?n.view:void 0,r&&r.visible())switch(e.keyCode){case i.ESC:e.preventDefault(),r.hide(e);break;case i.UP:e.preventDefault(),r.prev();break;case i.DOWN:e.preventDefault(),r.next();break;case i.P:if(!e.ctrlKey)return;e.preventDefault(),r.prev();break;case i.N:if(!e.ctrlKey)return;e.preventDefault(),r.next();break;case i.TAB:case i.ENTER:case i.SPACE:if(!r.visible())return;if(!this.controller().getOpt("spaceSelectsMatch")&&e.keyCode===i.SPACE)return;if(!this.controller().getOpt("tabSelectsMatch")&&e.keyCode===i.TAB)return;r.highlighted()?(e.preventDefault(),r.choose(e)):r.hide(e);break;default:t.noop()}},e}();var r,o=[].slice;r=function(){function i(e,i){this.app=e,this.at=i,this.$inputor=this.app.$inputor,this.id=this.$inputor[0].id||this.uid(),this.expectedQueryCBId=null,this.setting=null,this.query=null,this.pos=0,this.range=null,0===(this.$el=t("#atwho-ground-"+this.id,this.app.$el)).length&&this.app.$el.append(this.$el=t("
    ")),this.model=new u(this),this.view=new c(this)}return i.prototype.uid=function(){return(Math.random().toString(16)+"000000000").substr(2,8)+(new Date).getTime()},i.prototype.init=function(e){return this.setting=t.extend({},this.setting||t.fn.atwho["default"],e),this.view.init(),this.model.reload(this.setting.data)},i.prototype.destroy=function(){return this.trigger("beforeDestroy"),this.model.destroy(),this.view.destroy(),this.$el.remove()},i.prototype.callDefault=function(){var i,n,r,s;s=arguments[0],i=2<=arguments.length?o.call(arguments,1):[];try{return e[s].apply(this,i)}catch(r){return n=r,t.error(n+" Or maybe At.js doesn't have function "+s)}},i.prototype.trigger=function(t,e){var i,n;return null==e&&(e=[]),e.push(this),i=this.getOpt("alias"),n=i?t+"-"+i+".atwho":t+".atwho",this.$inputor.trigger(n,e)},i.prototype.callbacks=function(t){return this.getOpt("callbacks")[t]||e[t]},i.prototype.getOpt=function(t,e){var i,n;try{return this.setting[t]}catch(n){return i=n,null}},i.prototype.insertContentFor=function(e){var i,n;return n=this.getOpt("insertTpl"),i=t.extend({},e.data("item-data"),{"atwho-at":this.at}),this.callbacks("tplEval").call(this,n,i,"onInsert")},i.prototype.renderView=function(t){var e;return e=this.getOpt("searchKey"),t=this.callbacks("sorter").call(this,this.query.text,t.slice(0,1001),e),this.view.render(t.slice(0,this.getOpt("limit")))},i.arrayToDefaultHash=function(e){var i,n,r,o;if(!t.isArray(e))return e;for(o=[],i=0,r=e.length;r>i;i++)n=e[i],t.isPlainObject(n)?o.push(n):o.push({name:n});return o},i.prototype.lookUp=function(t){var e,i;if((!t||"click"!==t.type||this.getOpt("lookUpOnClick"))&&(!this.getOpt("suspendOnComposing")||!this.app.isComposing))return(e=this.catchQuery(t))?(this.app.setContextFor(this.at),(i=this.getOpt("delay"))?this._delayLookUp(e,i):this._lookUp(e),e):(this.expectedQueryCBId=null,e)},i.prototype._delayLookUp=function(t,e){var i,n;return i=Date.now?Date.now():(new Date).getTime(),this.previousCallTime||(this.previousCallTime=i),n=e-(i-this.previousCallTime),n>0&&e>n?(this.previousCallTime=i,this._stopDelayedCall(),this.delayedCallTimeout=setTimeout(function(e){return function(){return e.previousCallTime=0,e.delayedCallTimeout=null,e._lookUp(t)}}(this),e)):(this._stopDelayedCall(),this.previousCallTime!==i&&(this.previousCallTime=0),this._lookUp(t))},i.prototype._stopDelayedCall=function(){return this.delayedCallTimeout?(clearTimeout(this.delayedCallTimeout),this.delayedCallTimeout=null):void 0},i.prototype._generateQueryCBId=function(){return{}},i.prototype._lookUp=function(e){var i;return i=function(t,e){return t===this.expectedQueryCBId?e&&e.length>0?this.renderView(this.constructor.arrayToDefaultHash(e)):this.view.hide():void 0},this.expectedQueryCBId=this._generateQueryCBId(),this.model.query(e.text,t.proxy(i,this,this.expectedQueryCBId))},i}();var s,a=function(t,e){function i(){this.constructor=t}for(var n in e)h.call(e,n)&&(t[n]=e[n]);return i.prototype=e.prototype,t.prototype=new i,t.__super__=e.prototype,t},h={}.hasOwnProperty;s=function(e){function i(){return i.__super__.constructor.apply(this,arguments)}return a(i,e),i.prototype.catchQuery=function(){var t,e,i,n,r,o,s;return e=this.$inputor.val(),t=this.$inputor.caret("pos",{iframe:this.app.iframe}),s=e.slice(0,t),r=this.callbacks("matcher").call(this,this.at,s,this.getOpt("startWithSpace"),this.getOpt("acceptSpaceBar")),n="string"==typeof r,n&&r.length0?t.getRangeAt(0):void 0},n.prototype._setRange=function(e,i,n){return null==n&&(n=this._getRange()),n&&i?(i=t(i)[0],"after"===e?(n.setEndAfter(i),n.setStartAfter(i)):(n.setEndBefore(i),n.setStartBefore(i)),n.collapse(!1),this._clearRange(n)):void 0},n.prototype._clearRange=function(t){var e;return null==t&&(t=this._getRange()),e=this.app.window.getSelection(),null==this.ctrl_a_pressed?(e.removeAllRanges(),e.addRange(t)):void 0},n.prototype._movingEvent=function(t){var e;return"click"===t.type||(e=t.which)===i.RIGHT||e===i.LEFT||e===i.UP||e===i.DOWN},n.prototype._unwrap=function(e){var i;return e=t(e).unwrap().get(0),(i=e.nextSibling)&&i.nodeValue&&(e.nodeValue+=i.nodeValue,t(i).remove()),e},n.prototype.catchQuery=function(e){var n,r,o,s,a,h,l,u,c,p,f,d;if((d=this._getRange())&&d.collapsed){if(e.which===i.ENTER)return(r=t(d.startContainer).closest(".atwho-query")).contents().unwrap(),r.is(":empty")&&r.remove(),(r=t(".atwho-query",this.app.document)).text(r.text()).contents().last().unwrap(),void this._clearRange();if(/firefox/i.test(navigator.userAgent)){if(t(d.startContainer).is(this.$inputor))return void this._clearRange();e.which===i.BACKSPACE&&d.startContainer.nodeType===document.ELEMENT_NODE&&(c=d.startOffset-1)>=0?(o=d.cloneRange(),o.setStart(d.startContainer,c),t(o.cloneContents()).contents().last().is(".atwho-inserted")&&(a=t(d.startContainer).contents().get(c),this._setRange("after",t(a).contents().last()))):e.which===i.LEFT&&d.startContainer.nodeType===document.TEXT_NODE&&(n=t(d.startContainer.previousSibling),n.is(".atwho-inserted")&&0===d.startOffset&&this._setRange("after",n.contents().last()))}if(t(d.startContainer).closest(".atwho-inserted").addClass("atwho-query").siblings().removeClass("atwho-query"),(r=t(".atwho-query",this.app.document)).length>0&&r.is(":empty")&&0===r.text().length&&r.remove(),this._movingEvent(e)||r.removeClass("atwho-inserted"),r.length>0)switch(e.which){case i.LEFT:return this._setRange("before",r.get(0),d),void r.removeClass("atwho-query");case i.RIGHT:return this._setRange("after",r.get(0).nextSibling,d),void r.removeClass("atwho-query")}if(r.length>0&&(f=r.attr("data-atwho-at-query"))&&(r.empty().html(f).attr("data-atwho-at-query",null),this._setRange("after",r.get(0),d)),o=d.cloneRange(),o.setStart(d.startContainer,0),u=this.callbacks("matcher").call(this,this.at,o.toString(),this.getOpt("startWithSpace"),this.getOpt("acceptSpaceBar")),h="string"==typeof u,0===r.length&&h&&(s=d.startOffset-this.at.length-u.length)>=0&&(d.setStart(d.startContainer,s),r=t("",this.app.document).attr(this.getOpt("editableAtwhoQueryAttrs")).addClass("atwho-query"),d.surroundContents(r.get(0)),l=r.contents().last().get(0),l&&(/firefox/i.test(navigator.userAgent)?(d.setStart(l,l.length),d.setEnd(l,l.length),this._clearRange(d)):this._setRange("after",l,d))),!(h&&u.length=0&&(this._movingEvent(e)&&r.hasClass("atwho-inserted")?r.removeClass("atwho-query"):!1!==this.callbacks("afterMatchFailed").call(this,this.at,r)&&this._setRange("after",this._unwrap(r.text(r.text()).contents().first()))),null)}},n.prototype.rect=function(){var e,i,n;return n=this.query.el.offset(),n&&this.query.el[0].getClientRects().length?(this.app.iframe&&!this.app.iframeAsRoot&&(i=(e=t(this.app.iframe)).offset(),n.left+=i.left-this.$inputor.scrollLeft(),n.top+=i.top-this.$inputor.scrollTop()),n.bottom=n.top+this.query.el.height(),n):void 0},n.prototype.insert=function(t,e){var i,n,r,o,s;return this.$inputor.is(":focus")||this.$inputor.focus(),n=this.getOpt("functionOverrides"),n.insert?n.insert.call(this,t,e):(o=""===(o=this.getOpt("suffix"))?o:o||" ",i=e.data("item-data"),this.query.el.removeClass("atwho-query").addClass("atwho-inserted").html(t).attr("data-atwho-at-query",""+i["atwho-at"]+this.query.text).attr("contenteditable","false"),(r=this._getRange())&&(this.query.el.length&&r.setEndAfter(this.query.el[0]),r.collapse(!1),r.insertNode(s=this.app.document.createTextNode(""+o)),this._setRange("after",s,r)),this.$inputor.is(":focus")||this.$inputor.focus(),this.$inputor.change())},n}(r);var u;u=function(){function e(t){this.context=t,this.at=this.context.at,this.storage=this.context.$inputor}return e.prototype.destroy=function(){return this.storage.data(this.at,null)},e.prototype.saved=function(){return this.fetch()>0},e.prototype.query=function(t,e){var i,n,r;return n=this.fetch(),r=this.context.getOpt("searchKey"),n=this.context.callbacks("filter").call(this.context,t,n,r)||[],i=this.context.callbacks("remoteFilter"),n.length>0||!i&&0===n.length?e(n):i.call(this.context,t,e)},e.prototype.fetch=function(){return this.storage.data(this.at)||[]},e.prototype.save=function(t){return this.storage.data(this.at,this.context.callbacks("beforeSave").call(this.context,t||[]))},e.prototype.load=function(t){return!this.saved()&&t?this._load(t):void 0},e.prototype.reload=function(t){return this._load(t)},e.prototype._load=function(e){return"string"==typeof e?t.ajax(e,{dataType:"json"}).done(function(t){return function(e){return t.save(e)}}(this)):this.save(e)},e}();var c;c=function(){function e(e){this.context=e,this.$el=t("
      "),this.$elUl=this.$el.children(),this.timeoutID=null,this.context.$el.append(this.$el),this.bindEvent()}return e.prototype.init=function(){var t,e;return e=this.context.getOpt("alias")||this.context.at.charCodeAt(0),t=this.context.getOpt("headerTpl"),t&&1===this.$el.children().length&&this.$el.prepend(t),this.$el.attr({id:"at-view-"+e})},e.prototype.destroy=function(){return this.$el.remove()},e.prototype.bindEvent=function(){var e,i,n;return e=this.$el.find("ul"),i=0,n=0,e.on("mousemove.atwho-view","li",function(r){return function(r){var o;if((i!==r.clientX||n!==r.clientY)&&(i=r.clientX,n=r.clientY,o=t(r.currentTarget),!o.hasClass("cur")))return e.find(".cur").removeClass("cur"),o.addClass("cur")}}(this)).on("click.atwho-view","li",function(i){return function(n){return e.find(".cur").removeClass("cur"),t(n.currentTarget).addClass("cur"),i.choose(n),n.preventDefault()}}(this))},e.prototype.visible=function(){return t.expr.filters.visible(this.$el[0])},e.prototype.highlighted=function(){return this.$el.find(".cur").length>0},e.prototype.choose=function(t){var e,i;return(e=this.$el.find(".cur")).length&&(i=this.context.insertContentFor(e),this.context._stopDelayedCall(),this.context.insert(this.context.callbacks("beforeInsert").call(this.context,i,e,t),e),this.context.trigger("inserted",[e,t]),this.hide(t)),this.context.getOpt("hideWithoutSuffix")?this.stopShowing=!0:void 0},e.prototype.reposition=function(e){var i,n,r,o;return i=this.context.app.iframeAsRoot?this.context.app.window:window,e.bottom+this.$el.height()-t(i).scrollTop()>t(i).height()&&(e.bottom=e.top-this.$el.height()),e.left>(r=t(i).width()-this.$el.width()-5)&&(e.left=r),n={left:e.left,top:e.bottom},null!=(o=this.context.callbacks("beforeReposition"))&&o.call(this.context,n),this.$el.offset(n),this.context.trigger("reposition",[n])},e.prototype.next=function(){var t,e,i,n;return t=this.$el.find(".cur").removeClass("cur"),e=t.next(),e.length||(e=this.$el.find("li:first")),e.addClass("cur"),i=e[0],n=i.offsetTop+i.offsetHeight+(i.nextSibling?i.nextSibling.offsetHeight:0),this.scrollTop(Math.max(0,n-this.$el.height()))},e.prototype.prev=function(){var t,e,i,n;return t=this.$el.find(".cur").removeClass("cur"),i=t.prev(),i.length||(i=this.$el.find("li:last")),i.addClass("cur"),n=i[0],e=n.offsetTop+n.offsetHeight+(n.nextSibling?n.nextSibling.offsetHeight:0),this.scrollTop(Math.max(0,e-this.$el.height()))},e.prototype.scrollTop=function(t){var e;return e=this.context.getOpt("scrollDuration"),e?this.$elUl.animate({scrollTop:t},e):this.$elUl.scrollTop(t)},e.prototype.show=function(){var t;return this.stopShowing?void(this.stopShowing=!1):(this.visible()||(this.$el.show(),this.$el.scrollTop(0),this.context.trigger("shown")),(t=this.context.rect())?this.reposition(t):void 0)},e.prototype.hide=function(t,e){var i;if(this.visible())return isNaN(e)?(this.$el.hide(),this.context.trigger("hidden",[t])):(i=function(t){return function(){return t.hide()}}(this),clearTimeout(this.timeoutID),this.timeoutID=setTimeout(i,e))},e.prototype.render=function(e){var i,n,r,o,s,a,h;if(!(t.isArray(e)&&e.length>0))return void this.hide();for(this.$el.find("ul").empty(),n=this.$el.find("ul"),h=this.context.getOpt("displayTpl"),r=0,s=e.length;s>r;r++)o=e[r],o=t.extend({},o,{"atwho-at":this.context.at}),a=this.context.callbacks("tplEval").call(this.context,h,o,"onDisplay"),i=t(this.context.callbacks("highlighter").call(this.context,a,this.context.query.text)),i.data("item-data",o),n.append(i);return this.show(),this.context.getOpt("highlightFirst")?n.find("li:first").addClass("cur"):void 0},e}();var p;p={load:function(t,e){var i;return(i=this.controller(t))?i.model.load(e):void 0},isSelecting:function(){var t;return!!(null!=(t=this.controller())?t.view.visible():void 0)},hide:function(){var t;return null!=(t=this.controller())?t.view.hide():void 0},reposition:function(){var t;return(t=this.controller())?t.view.reposition(t.rect()):void 0},setIframe:function(t,e){return this.setupRootElement(t,e),null},run:function(){return this.dispatch()},destroy:function(){return this.shutdown(),this.$inputor.data("atwho",null)}},t.fn.atwho=function(e){var i,r;return i=arguments,r=null,this.filter('textarea, input, [contenteditable=""], [contenteditable=true]').each(function(){var o,s;return(s=(o=t(this)).data("atwho"))||o.data("atwho",s=new n(this)),"object"!=typeof e&&e?p[e]&&s?r=p[e].apply(s,Array.prototype.slice.call(i,1)):t.error("Method "+e+" does not exist on jQuery.atwho"):s.reg(e.at,e)}),null!=r?r:this},t.fn.atwho["default"]={at:void 0,alias:void 0,data:null,displayTpl:"
    • ${name}
    • ",insertTpl:"${atwho-at}${name}",headerTpl:null,callbacks:e,functionOverrides:{},searchKey:"name",suffix:void 0,hideWithoutSuffix:!1,startWithSpace:!0,acceptSpaceBar:!1,highlightFirst:!0,limit:5,maxLen:20,minLen:0,displayTimeout:300,delay:null,spaceSelectsMatch:!1,tabSelectsMatch:!0,editableAtwhoQueryAttrs:{},scrollDuration:150,suspendOnComposing:!0,lookUpOnClick:!0},t.fn.atwho.debug=!1}); \ No newline at end of file diff --git a/js/legacy/jquery.caret.min.js b/js/legacy/jquery.caret.min.js new file mode 100644 index 000000000..183c47b8e --- /dev/null +++ b/js/legacy/jquery.caret.min.js @@ -0,0 +1,561 @@ +/* + * @copyright Copyright (c) 2018 Julius Härtl + * + * @author Julius Härtl + * + * @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 . + * + */ +(function($, undefined) { + + var _input = document.createElement('input'); + + var _support = { + setSelectionRange: ('setSelectionRange' in _input) || ('selectionStart' in _input), + createTextRange: ('createTextRange' in _input) || ('selection' in document) + }; + + var _rNewlineIE = /\r\n/g, + _rCarriageReturn = /\r/g; + + var _getValue = function(input) { + if (typeof(input.value) !== 'undefined') { + return input.value; + } + return $(input).text(); + }; + + var _setValue = function(input, value) { + if (typeof(input.value) !== 'undefined') { + input.value = value; + } else { + $(input).text(value); + } + }; + + var _getIndex = function(input, pos) { + var norm = _getValue(input).replace(_rCarriageReturn, ''); + var len = norm.length; + + if (typeof(pos) === 'undefined') { + pos = len; + } + + pos = Math.floor(pos); + + // Negative index counts backward from the end of the input/textarea's value + if (pos < 0) { + pos = len + pos; + } + + // Enforce boundaries + if (pos < 0) { pos = 0; } + if (pos > len) { pos = len; } + + return pos; + }; + + var _hasAttr = function(input, attrName) { + return input.hasAttribute ? input.hasAttribute(attrName) : (typeof(input[attrName]) !== 'undefined'); + }; + + /** + * @class + * @constructor + */ + var Range = function(start, end, length, text) { + this.start = start || 0; + this.end = end || 0; + this.length = length || 0; + this.text = text || ''; + }; + + Range.prototype.toString = function() { + return JSON.stringify(this, null, ' '); + }; + + var _getCaretW3 = function(input) { + return input.selectionStart; + }; + + /** + * @see http://stackoverflow.com/q/6943000/467582 + */ + var _getCaretIE = function(input) { + var caret, range, textInputRange, rawValue, len, endRange; + + // Yeah, you have to focus twice for IE 7 and 8. *cries* + input.focus(); + input.focus(); + + range = document.selection.createRange(); + + if (range && range.parentElement() === input) { + rawValue = _getValue(input); + + len = rawValue.length; + + // Create a working TextRange that lives only in the input + textInputRange = input.createTextRange(); + textInputRange.moveToBookmark(range.getBookmark()); + + // Check if the start and end of the selection are at the very end + // of the input, since moveStart/moveEnd doesn't return what we want + // in those cases + endRange = input.createTextRange(); + endRange.collapse(false); + + if (textInputRange.compareEndPoints("StartToEnd", endRange) > -1) { + caret = rawValue.replace(_rNewlineIE, '\n').length; + } else { + caret = -textInputRange.moveStart("character", -len); + } + + return caret; + } + + // NOTE: This occurs when you highlight part of a textarea and then click in the middle of the highlighted portion in IE 6-10. + // There doesn't appear to be anything we can do about it. +// alert("Your browser is incredibly stupid. I don't know what else to say."); +// alert(range + '\n\n' + range.parentElement().tagName + '#' + range.parentElement().id); + + return 0; + }; + + /** + * Gets the position of the caret in the given input. + * @param {HTMLInputElement|HTMLTextAreaElement} input input or textarea element + * @returns {Number} + * @see http://stackoverflow.com/questions/263743/how-to-get-cursor-position-in-textarea/263796#263796 + */ + var _getCaret = function(input) { + if (!input) { + return undefined; + } + + // Mozilla, et al. + if (_support.setSelectionRange) { + return _getCaretW3(input); + } + // IE + else if (_support.createTextRange) { + return _getCaretIE(input); + } + + return undefined; + }; + + var _setCaretW3 = function(input, pos) { + input.setSelectionRange(pos, pos); + }; + + var _setCaretIE = function(input, pos) { + var range = input.createTextRange(); + range.move('character', pos); + range.select(); + }; + + /** + * Sets the position of the caret in the given input. + * @param {HTMLInputElement|HTMLTextAreaElement} input input or textarea element + * @param {Number} pos + * @see http://parentnode.org/javascript/working-with-the-cursor-position/ + */ + var _setCaret = function(input, pos) { + input.focus(); + + pos = _getIndex(input, pos); + + // Mozilla, et al. + if (_support.setSelectionRange) { + _setCaretW3(input, pos); + } + // IE + else if (_support.createTextRange) { + _setCaretIE(input, pos); + } + }; + + /** + * Inserts the specified text at the current caret position in the given input. + * @param {HTMLInputElement|HTMLTextAreaElement} input input or textarea element + * @param {String} text + * @see http://parentnode.org/javascript/working-with-the-cursor-position/ + */ + var _insertAtCaret = function(input, text) { + var curPos = _getCaret(input); + + var oldValueNorm = _getValue(input).replace(_rCarriageReturn, ''); + + var newLength = +(curPos + text.length + (oldValueNorm.length - curPos)); + var maxLength = +input.getAttribute('maxlength'); + + if(_hasAttr(input, 'maxlength') && newLength > maxLength) { + var delta = text.length - (newLength - maxLength); + text = text.substr(0, delta); + } + + _setValue(input, oldValueNorm.substr(0, curPos) + text + oldValueNorm.substr(curPos)); + + _setCaret(input, curPos + text.length); + }; + + var _getInputRangeW3 = function(input) { + var range = new Range(); + + range.start = input.selectionStart; + range.end = input.selectionEnd; + + var min = Math.min(range.start, range.end); + var max = Math.max(range.start, range.end); + + range.length = max - min; + range.text = _getValue(input).substring(min, max); + + return range; + }; + + /** @see http://stackoverflow.com/a/3648244/467582 */ + var _getInputRangeIE = function(input) { + var range = new Range(); + + input.focus(); + + var selection = document.selection.createRange(); + + if (selection && selection.parentElement() === input) { + var len, normalizedValue, textInputRange, endRange, start = 0, end = 0; + var rawValue = _getValue(input); + + len = rawValue.length; + normalizedValue = rawValue.replace(/\r\n/g, "\n"); + + // Create a working TextRange that lives only in the input + textInputRange = input.createTextRange(); + textInputRange.moveToBookmark(selection.getBookmark()); + + // Check if the start and end of the selection are at the very end + // of the input, since moveStart/moveEnd doesn't return what we want + // in those cases + endRange = input.createTextRange(); + endRange.collapse(false); + + if (textInputRange.compareEndPoints("StartToEnd", endRange) > -1) { + start = end = len; + } else { + start = -textInputRange.moveStart("character", -len); + start += normalizedValue.slice(0, start).split("\n").length - 1; + + if (textInputRange.compareEndPoints("EndToEnd", endRange) > -1) { + end = len; + } else { + end = -textInputRange.moveEnd("character", -len); + end += normalizedValue.slice(0, end).split("\n").length - 1; + } + } + + /// normalize newlines + start -= (rawValue.substring(0, start).split('\r\n').length - 1); + end -= (rawValue.substring(0, end).split('\r\n').length - 1); + /// normalize newlines + + range.start = start; + range.end = end; + range.length = range.end - range.start; + range.text = normalizedValue.substr(range.start, range.length); + } + + return range; + }; + + /** + * Gets the selected text range of the given input. + * @param {HTMLInputElement|HTMLTextAreaElement} input input or textarea element + * @returns {Range} + * @see http://stackoverflow.com/a/263796/467582 + * @see http://stackoverflow.com/a/2966703/467582 + */ + var _getInputRange = function(input) { + if (!input) { + return undefined; + } + + // Mozilla, et al. + if (_support.setSelectionRange) { + return _getInputRangeW3(input); + } + // IE + else if (_support.createTextRange) { + return _getInputRangeIE(input); + } + + return undefined; + }; + + var _setInputRangeW3 = function(input, startPos, endPos) { + input.setSelectionRange(startPos, endPos); + }; + + var _setInputRangeIE = function(input, startPos, endPos) { + var tr = input.createTextRange(); + tr.moveEnd('textedit', -1); + tr.moveStart('character', startPos); + tr.moveEnd('character', endPos - startPos); + tr.select(); + }; + + /** + * Sets the selected text range of (i.e., highlights text in) the given input. + * @param {HTMLInputElement|HTMLTextAreaElement} input input or textarea element + * @param {Number} startPos Zero-based index + * @param {Number} endPos Zero-based index + * @see http://parentnode.org/javascript/working-with-the-cursor-position/ + * @see http://stackoverflow.com/a/2966703/467582 + */ + var _setInputRange = function(input, startPos, endPos) { + startPos = _getIndex(input, startPos); + endPos = _getIndex(input, endPos); + + // Mozilla, et al. + if (_support.setSelectionRange) { + _setInputRangeW3(input, startPos, endPos); + } + // IE + else if (_support.createTextRange) { + _setInputRangeIE(input, startPos, endPos); + } + }; + + /** + * Replaces the currently selected text with the given string. + * @param {HTMLInputElement|HTMLTextAreaElement} input input or textarea element + * @param {String} text New text that will replace the currently selected text. + * @see http://parentnode.org/javascript/working-with-the-cursor-position/ + */ + var _replaceInputRange = function(input, text) { + var $input = $(input); + + var oldValue = $input.val(); + var selection = _getInputRange(input); + + var newLength = +(selection.start + text.length + (oldValue.length - selection.end)); + var maxLength = +$input.attr('maxlength'); + + if($input.is('[maxlength]') && newLength > maxLength) { + var delta = text.length - (newLength - maxLength); + text = text.substr(0, delta); + } + + // Now that we know what the user selected, we can replace it + var startText = oldValue.substr(0, selection.start); + var endText = oldValue.substr(selection.end); + + $input.val(startText + text + endText); + + // Reset the selection + var startPos = selection.start; + var endPos = startPos + text.length; + + _setInputRange(input, selection.length ? startPos : endPos, endPos); + }; + + var _selectAllW3 = function(elem) { + var selection = window.getSelection(); + var range = document.createRange(); + range.selectNodeContents(elem); + selection.removeAllRanges(); + selection.addRange(range); + }; + + var _selectAllIE = function(elem) { + var range = document.body.createTextRange(); + range.moveToElementText(elem); + range.select(); + }; + + /** + * Select all text in the given element. + * @param {HTMLElement} elem Any block or inline element other than a form element. + */ + var _selectAll = function(elem) { + var $elem = $(elem); + if ($elem.is('input, textarea') || elem.select) { + $elem.select(); + return; + } + + // Mozilla, et al. + if (_support.setSelectionRange) { + _selectAllW3(elem); + } + // IE + else if (_support.createTextRange) { + _selectAllIE(elem); + } + }; + + var _deselectAll = function() { + if (document.selection) { + document.selection.empty(); + } + else if (window.getSelection) { + window.getSelection().removeAllRanges(); + } + }; + + $.extend($.fn, { + + /** + * Gets or sets the position of the caret or inserts text at the current caret position in an input or textarea element. + * @returns {Number|jQuery} The current caret position if invoked as a getter (with no arguments) + * or this jQuery object if invoked as a setter or inserter. + * @see http://web.archive.org/web/20080704185920/http://parentnode.org/javascript/working-with-the-cursor-position/ + * @since 1.0.0 + * @example + *
      +		 *    // Get position
      +		 *    var pos = $('input:first').caret();
      +		 * 
      + * @example + *
      +		 *    // Set position
      +		 *    $('input:first').caret(15);
      +		 *    $('input:first').caret(-3);
      +		 * 
      + * @example + *
      +		 *    // Insert text at current position
      +		 *    $('input:first').caret('Some text');
      +		 * 
      + */ + caret: function() { + var $inputs = this.filter('input, textarea'); + + // getCaret() + if (arguments.length === 0) { + var input = $inputs.get(0); + return _getCaret(input); + } + // setCaret(position) + else if (typeof arguments[0] === 'number') { + var pos = arguments[0]; + $inputs.each(function(_i, input) { + _setCaret(input, pos); + }); + } + // insertAtCaret(text) + else { + var text = arguments[0]; + $inputs.each(function(_i, input) { + _insertAtCaret(input, text); + }); + } + + return this; + }, + + /** + * Gets or sets the selection range or replaces the currently selected text in an input or textarea element. + * @returns {Range|jQuery} The current selection range if invoked as a getter (with no arguments) + * or this jQuery object if invoked as a setter or replacer. + * @see http://stackoverflow.com/a/2966703/467582 + * @since 1.0.0 + * @example + *
      +		 *    // Get selection range
      +		 *    var range = $('input:first').range();
      +		 * 
      + * @example + *
      +		 *    // Set selection range
      +		 *    $('input:first').range(15);
      +		 *    $('input:first').range(15, 20);
      +		 *    $('input:first').range(-3);
      +		 *    $('input:first').range(-8, -3);
      +		 * 
      + * @example + *
      +		 *    // Replace the currently selected text
      +		 *    $('input:first').range('Replacement text');
      +		 * 
      + */ + range: function() { + var $inputs = this.filter('input, textarea'); + + // getRange() = { start: pos, end: pos } + if (arguments.length === 0) { + var input = $inputs.get(0); + return _getInputRange(input); + } + // setRange(startPos, endPos) + else if (typeof arguments[0] === 'number') { + var startPos = arguments[0]; + var endPos = arguments[1]; + $inputs.each(function(_i, input) { + _setInputRange(input, startPos, endPos); + }); + } + // replaceRange(text) + else { + var text = arguments[0]; + $inputs.each(function(_i, input) { + _replaceInputRange(input, text); + }); + } + + return this; + }, + + /** + * Selects all text in each element of this jQuery object. + * @returns {jQuery} This jQuery object + * @see http://stackoverflow.com/a/11128179/467582 + * @since 1.5.0 + * @example + *
      +		 *     // Select the contents of span elements when clicked
      +		 *     $('span').on('click', function() { $(this).highlight(); });
      +		 * 
      + */ + selectAll: function() { + return this.each(function(_i, elem) { + _selectAll(elem); + }); + } + + }); + + $.extend($, { + /** + * Deselects all text on the page. + * @returns {jQuery} The jQuery function + * @since 1.5.0 + * @example + *
      +		 *     // Select some text
      +		 *     $('span').selectAll();
      +		 *
      +		 *     // Deselect the text
      +		 *     $.deselectAll();
      +		 * 
      + */ + deselectAll: function() { + _deselectAll(); + return this; + } + }); + +}(window.jQuery || window.Zepto || window.$)); diff --git a/js/service/ActivityService.js b/js/service/ActivityService.js index 514057edd..903009ed2 100644 --- a/js/service/ActivityService.js +++ b/js/service/ActivityService.js @@ -21,6 +21,8 @@ */ import app from '../app/App.js'; +import CommentCollection from '../legacy/commentcollection'; +import CommentModel from '../legacy/commentmodel'; const DECK_ACTIVITY_TYPE_BOARD = 'deck_board'; const DECK_ACTIVITY_TYPE_CARD = 'deck_card'; @@ -28,15 +30,44 @@ const DECK_ACTIVITY_TYPE_CARD = 'deck_card'; /* global OC oc_requesttoken */ class ActivityService { + static get RESULT_PER_PAGE() { return 50; } + constructor ($rootScope, $filter, $http, $q) { this.running = false; this.runningNewer = false; this.$filter = $filter; this.$http = $http; this.$q = $q; + this.$rootScope = $rootScope; this.data = {}; this.data[DECK_ACTIVITY_TYPE_BOARD] = {}; this.data[DECK_ACTIVITY_TYPE_CARD] = {}; + this.toEnhanceWithComments = []; + this.commentCollection = new CommentCollection(); + this.commentCollection._limit = ActivityService.RESULT_PER_PAGE; + this.commentCollection.on('request', function() { + }, this); + this.commentCollection.on('sync', function(a) { + for (let index in this.toEnhanceWithComments) { + if (this.toEnhanceWithComments.hasOwnProperty(index)) { + let item = this.toEnhanceWithComments[index]; + item.commentModel = this.commentCollection.get(item.subject_rich[1].comment); + if (typeof item.commentModel !== 'undefined') { + this.toEnhanceWithComments = this.toEnhanceWithComments.filter((entry) => entry.activity_id !== item.activity_id); + } + } + } + var firstUnread = this.commentCollection.findWhere({isUnread: true}); + if (typeof firstUnread !== 'undefined') { + this.commentCollection.updateReadMarker(); + } + this.notify(); + }, this); + this.commentCollection.on('add', function(model, collection, options) { + // 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(); + }, this); this.since = { deck_card: { @@ -47,12 +78,25 @@ class ActivityService { }; } + /** + * We need a event here to properly update scope once the external data from + * the comments backbone js code has changed + */ + subscribe(scope, callback) { + let handler = this.$rootScope.$on('notify-comment-update', callback); + scope.$on('$destroy', handler); + } + + notify() { + this.$rootScope.$emit('notify-comment-update'); + } + static getUrl(type, id, since) { if (type === DECK_ACTIVITY_TYPE_CARD) { - return OC.linkToOCS('apps/activity/api/v2/activity', 2) + 'filter?format=json&object_type=deck_card&object_id=' + id + '&limit=50&since=' + since; + return OC.linkToOCS('apps/activity/api/v2/activity', 2) + 'filter?format=json&object_type=deck_card&object_id=' + id + '&limit=' + this.RESULT_PER_PAGE + '&since=' + since; } if (type === DECK_ACTIVITY_TYPE_BOARD) { - return OC.linkToOCS('apps/activity/api/v2/activity', 2) + 'deck?format=json&limit=50&since=' + since; + return OC.linkToOCS('apps/activity/api/v2/activity', 2) + 'deck?format=json&limit=' + this.RESULT_PER_PAGE + '&since=' + since; } } @@ -86,13 +130,18 @@ class ActivityService { self.running = false; }); } - fetchMoreActivities(type, id) { + + fetchMoreActivities(type, id, success) { + const self = this; this.checkData(type, id); if (this.running === true) { return this.runningPromise; } if (!this.since[type][id].finished) { this.runningPromise = this.fetchCardActivities(type, id, this.since[type][id].oldest); + this.runningPromise.then(function() { + self.commentCollection.fetchNext(); + }); return this.runningPromise; } return Promise.reject(); @@ -112,6 +161,7 @@ class ActivityService { } addItem(type, id, item) { + const self = this; const existingEntry = this.data[type][id].findIndex((entry) => { return entry.activity_id === item.activity_id; }); if (existingEntry !== -1) { return; @@ -123,6 +173,15 @@ class ActivityService { return; } item.timestamp = new Date(item.datetime).getTime(); + item.type = 'activity'; + if (item.subject_rich[1].comment) { + item.type = 'comment'; + item.commentModel = this.commentCollection.get(item.subject_rich[1].comment); + if (typeof item.commentModel === 'undefined') { + this.toEnhanceWithComments.push(item); + } + } + this.data[type][id].push(item); } @@ -179,6 +238,11 @@ class ActivityService { return this.data[type][id]; } + loadComments(id) { + this.commentCollection.reset(); + this.commentCollection.setObjectId(id); + } + } app.service('ActivityService', ActivityService); diff --git a/lib/Activity/ActivityManager.php b/lib/Activity/ActivityManager.php index 5f3e1b03d..f8b622fe2 100644 --- a/lib/Activity/ActivityManager.php +++ b/lib/Activity/ActivityManager.php @@ -41,6 +41,7 @@ use OCP\Activity\IManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Db\MultipleObjectsReturnedException; +use OCP\Comments\IComment; use OCP\IL10N; use OCP\IUser; @@ -98,6 +99,8 @@ class ActivityManager { const SUBJECT_LABEL_ASSIGN = 'label_assign'; const SUBJECT_LABEL_UNASSING = 'label_unassign'; + const SUBJECT_CARD_COMMENT_CREATE = 'card_comment_create'; + public function __construct( IManager $manager, PermissionService $permissionsService, @@ -227,6 +230,9 @@ public function getActivityFormat($subjectIdentifier, $subjectParams = [], $ownA case self::SUBJECT_ATTACHMENT_RESTORE: $subject = $ownActivity ? $this->l10n->t('You have restored the attachment {attachment} to {card}') : $this->l10n->t('{user} has restored the attachment {attachment} to {card}'); break; + case self::SUBJECT_CARD_COMMENT_CREATE: + $subject = $ownActivity ? $this->l10n->t('You have commented on {card}') : $this->l10n->t('{user} has commented on {card}'); + break; default: break; } @@ -315,6 +321,13 @@ private function createEvent($objectType, $entity, $subject, $additionalParams = // Not defined as there is no activity for // case self::SUBJECT_BOARD_UPDATE_COLOR break; + case self::SUBJECT_CARD_COMMENT_CREATE: + /** @var IComment $entity */ + $subjectParams = [ + 'comment' => $entity->getMessage() + ]; + $message = $entity->getMessage(); + break; case self::SUBJECT_STACK_CREATE: case self::SUBJECT_STACK_UPDATE: @@ -409,6 +422,9 @@ private function sendToUsers(IEvent $event) { */ private function findObjectForEntity($objectType, $entity) { $className = \get_class($entity); + if ($entity instanceof IComment) { + $className = IComment::class; + } $objectId = null; if ($objectType === self::DECK_OBJECT_CARD) { switch ($className) { @@ -420,6 +436,9 @@ private function findObjectForEntity($objectType, $entity) { case AssignedUsers::class: $objectId = $entity->getCardId(); break; + case IComment::class: + $objectId = $entity->getObjectId(); + break; default: throw new InvalidArgumentException('No entity relation present for '. $className . ' to ' . $objectType); } diff --git a/lib/Activity/CommentEventHandler.php b/lib/Activity/CommentEventHandler.php new file mode 100644 index 000000000..460bbab3a --- /dev/null +++ b/lib/Activity/CommentEventHandler.php @@ -0,0 +1,85 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +namespace OCA\Deck\Activity; + +use OCA\Deck\Notification\NotificationHelper; +use OCP\Comments\CommentsEvent; +use \OCP\Comments\ICommentsEventHandler; + +class CommentEventHandler implements ICommentsEventHandler { + + /** @var ActivityManager */ + private $activityManager; + + /** @var NotificationHelper */ + private $notificationHelper; + + public function __construct(ActivityManager $activityManager, NotificationHelper $notificationHelper) { + $this->notificationHelper = $notificationHelper; + $this->activityManager = $activityManager; + } + + /** + * @param CommentsEvent $event + */ + public function handle(CommentsEvent $event) { + if($event->getComment()->getObjectType() !== 'deckCard') { + return; + } + + $eventType = $event->getEvent(); + if( $eventType === CommentsEvent::EVENT_ADD + ) { + $this->notificationHandler($event); + $this->activityHandler($event); + return; + } + + $applicableEvents = [ + CommentsEvent::EVENT_PRE_UPDATE, + CommentsEvent::EVENT_UPDATE, + CommentsEvent::EVENT_DELETE, + ]; + if(in_array($eventType, $applicableEvents)) { + $this->notificationHandler($event); + return; + } + } + + /** + * @param CommentsEvent $event + */ + private function activityHandler(CommentsEvent $event) { + $comment = $event->getComment(); + $this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $comment, ActivityManager::SUBJECT_CARD_COMMENT_CREATE, ['comment' => $comment->getId()]); + + } + + /** + * @param CommentsEvent $event + */ + private function notificationHandler(CommentsEvent $event) { + $this->notificationHelper->sendMention($event->getComment()); + } +} diff --git a/lib/Activity/DeckProvider.php b/lib/Activity/DeckProvider.php index e8ff3e070..b266b1d23 100644 --- a/lib/Activity/DeckProvider.php +++ b/lib/Activity/DeckProvider.php @@ -28,6 +28,9 @@ use OCA\Deck\Db\Acl; use OCP\Activity\IEvent; use OCP\Activity\IProvider; +use OCP\Comments\IComment; +use OCP\Comments\ICommentsManager; +use OCP\Comments\NotFoundException; use OCP\IURLGenerator; use OCP\IUserManager; @@ -41,11 +44,14 @@ class DeckProvider implements IProvider { private $activityManager; /** @var IUserManager */ private $userManager; + /** @var ICommentsManager */ + private $commentsManager; - public function __construct(IURLGenerator $urlGenerator, ActivityManager $activityManager, IUserManager $userManager, $userId) { + public function __construct(IURLGenerator $urlGenerator, ActivityManager $activityManager, IUserManager $userManager, ICommentsManager $commentsManager, $userId) { $this->userId = $userId; $this->urlGenerator = $urlGenerator; $this->activityManager = $activityManager; + $this->commentsManager = $commentsManager; $this->userManager = $userManager; } @@ -106,7 +112,7 @@ public function parse($language, IEvent $event, IEvent $previousEvent = null) { 'type' => 'user', 'id' => $author, 'name' => $user !== null ? $user->getDisplayName() : $author - ] + ], ]; $params = $this->parseParamForBoard('board', $subjectParams, $params); @@ -117,6 +123,7 @@ public function parse($language, IEvent $event, IEvent $previousEvent = null) { $params = $this->parseParamForAssignedUser($subjectParams, $params); $params = $this->parseParamForAcl($subjectParams, $params); $params = $this->parseParamForChanges($subjectParams, $params, $event); + $params = $this->parseParamForComment($subjectParams, $params, $event); try { $subject = $this->activityManager->getActivityFormat($subjectIdentifier, $subjectParams, $ownActivity); @@ -147,6 +154,9 @@ private function getIcon(IEvent $event) { if (strpos($event->getSubject(), 'attachment_') !== false) { $event->setIcon($this->urlGenerator->imagePath('core', 'places/files.svg')); } + if (strpos($event->getSubject(), 'comment_') !== false) { + $event->setIcon($this->urlGenerator->imagePath('core', 'actions/comment.svg')); + } return $event; } @@ -227,6 +237,19 @@ private function parseParamForAcl($subjectParams, $params) { return $params; } + private function parseParamForComment($subjectParams, $params, IEvent $event) { + if (array_key_exists('comment', $subjectParams)) { + /** @var IComment $comment */ + try { + $comment = $this->commentsManager->get((int)$subjectParams['comment']); + $event->setParsedMessage($comment->getMessage()); + } catch (NotFoundException $e) { + } + $params['comment'] = $subjectParams['comment']; + } + return $params; + } + /** * Add diff to message if the subject parameter 'diff' is set, otherwise * the changed values are added to before/after diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 528c1e416..62fbb19a7 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -23,12 +23,15 @@ namespace OCA\Deck\AppInfo; +use OCA\Deck\Activity\CommentEventHandler; use OCA\Deck\Db\Acl; use OCA\Deck\Db\AclMapper; use OCA\Deck\Db\AssignedUsersMapper; +use OCA\Deck\Db\CardMapper; use OCA\Deck\Notification\Notifier; use OCP\AppFramework\App; use OCA\Deck\Middleware\SharingMiddleware; +use OCP\Comments\CommentsEntityEvent; use OCP\IGroup; use OCP\IUser; use OCP\IUserManager; @@ -120,6 +123,27 @@ public function registerNotifications() { }, function() { return ['id' => 'deck', 'name' => 'Deck']; }); + } + public function registerCommentsEntity() { + $this->getContainer()->getServer()->getEventDispatcher()->addListener(CommentsEntityEvent::EVENT_ENTITY, function(CommentsEntityEvent $event) { + $event->addEntityCollection('deckCard', function($name) { + /** @var CardMapper */ + $service = $this->getContainer()->query(CardMapper::class); + try { + $service->find((int) $name); + } catch (\InvalidArgumentException $e) { + return false; + } + return true; + }); + }); + $this->registerCommentsEventHandler(); + } + + protected function registerCommentsEventHandler() { + $this->getContainer()->getServer()->getCommentsManager()->registerEventHandler(function () { + return $this->getContainer()->query(CommentEventHandler::class); + }); } } diff --git a/lib/Db/Card.php b/lib/Db/Card.php index eb5e93911..91b580ada 100644 --- a/lib/Db/Card.php +++ b/lib/Db/Card.php @@ -43,6 +43,7 @@ class Card extends RelationalEntity { protected $duedate; protected $notified = false; protected $deletedAt = 0; + protected $commentsUnread = 0; private $databaseType = 'sqlite'; @@ -65,6 +66,7 @@ public function __construct() { $this->addRelation('attachments'); $this->addRelation('attachmentCount'); $this->addRelation('participants'); + $this->addRelation('commentsUnread'); $this->addResolvable('owner'); } diff --git a/lib/Notification/NotificationHelper.php b/lib/Notification/NotificationHelper.php index e4a6f21ee..2526624d8 100644 --- a/lib/Notification/NotificationHelper.php +++ b/lib/Notification/NotificationHelper.php @@ -29,6 +29,7 @@ use OCA\Deck\Db\BoardMapper; use OCA\Deck\Db\CardMapper; use OCA\Deck\Service\PermissionService; +use OCP\Comments\IComment; use OCP\IGroupManager; use OCP\IUser; use OCP\Notification\IManager; @@ -134,6 +135,22 @@ public function sendBoardShared($boardId, $acl) { } } + public function sendMention(IComment $comment) { + foreach ($comment->getMentions() as $mention) { + $card = $this->cardMapper->find($comment->getObjectId()); + $boardId = $this->cardMapper->findBoardId($card->getId()); + $notification = $this->notificationManager->createNotification(); + $notification + ->setApp('deck') + ->setUser((string) $mention['id']) + ->setDateTime(new DateTime()) + ->setObject('card', (string) $card->getId()) + ->setSubject('card-comment-mentioned', [$card->getTitle(), $boardId, $this->currentUser]) + ->setMessage($comment->getMessage()); + $this->notificationManager->notify($notification); + } + } + /** * @param $boardId * @return Board @@ -160,4 +177,4 @@ private function generateBoardShared($board, $userId) { return $notification; } -} \ No newline at end of file +} diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index fd54fe88b..52f75bd4c 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -105,6 +105,31 @@ public function prepare(INotification $notification, $languageCode) { ); $notification->setLink($this->url->linkToRouteAbsolute('deck.page.index') . '#!/board/' . $boardId . '//card/' . $cardId . ''); break; + case 'card-comment-mentioned': + $cardId = $notification->getObjectId(); + $boardId = $this->cardMapper->findBoardId($cardId); + $initiator = $this->userManager->get($params[2]); + if ($initiator !== null) { + $dn = $initiator->getDisplayName(); + } else { + $dn = $params[2]; + } + $notification->setParsedSubject( + (string) $l->t('%s has mentioned in a comment on "%s".', [$dn, $params[0]]) + ); + $notification->setRichSubject( + (string) $l->t('{user} has mentioned in a comment on "%s".', [$params[0]]), + [ + 'user' => [ + 'type' => 'user', + 'id' => $params[2], + 'name' => $dn, + ] + ] + ); + $notification->setParsedMessage($notification->getMessage()); + $notification->setLink($this->url->linkToRouteAbsolute('deck.page.index') . '#!/board/' . $boardId . '//card/' . $cardId . ''); + break; case 'board-shared': $boardId = $notification->getObjectId(); $initiator = $this->userManager->get($params[1]); @@ -131,4 +156,4 @@ public function prepare(INotification $notification, $languageCode) { } return $notification; } -} \ No newline at end of file +} diff --git a/lib/Service/CardService.php b/lib/Service/CardService.php index 721ebe165..0cf6cfa60 100644 --- a/lib/Service/CardService.php +++ b/lib/Service/CardService.php @@ -37,6 +37,8 @@ use OCA\Deck\NotFoundException; use OCA\Deck\StatusException; use OCA\Deck\BadRequestException; +use OCP\Comments\ICommentsManager; +use OCP\IUserManager; class CardService { @@ -51,6 +53,7 @@ class CardService { private $attachmentService; private $currentUser; private $activityManager; + private $commentsManager; public function __construct( CardMapper $cardMapper, @@ -63,6 +66,8 @@ public function __construct( AssignedUsersMapper $assignedUsersMapper, AttachmentService $attachmentService, ActivityManager $activityManager, + ICommentsManager $commentsManager, + IUserManager $userManager, $userId ) { $this->cardMapper = $cardMapper; @@ -75,6 +80,8 @@ public function __construct( $this->assignedUsersMapper = $assignedUsersMapper; $this->attachmentService = $attachmentService; $this->activityManager = $activityManager; + $this->commentsManager = $commentsManager; + $this->userManager = $userManager; $this->currentUser = $userId; } @@ -83,6 +90,10 @@ public function enrich($card) { $card->setAssignedUsers($this->assignedUsersMapper->find($cardId)); $card->setLabels($this->labelMapper->findAssignedLabelsForCard($cardId)); $card->setAttachmentCount($this->attachmentService->count($cardId)); + $user = $this->userManager->get($this->currentUser); + $lastRead = $this->commentsManager->getReadMark('deckCard', (string)$card->getId(), $user); + $count = $this->commentsManager->getNumberOfCommentsForObject('deckCard', (string)$card->getId(), $lastRead); + $card->setCommentsUnread($count); } public function fetchDeleted($boardId) { @@ -114,6 +125,7 @@ public function find($cardId) { $attachments = $this->attachmentService->findAll($cardId, true); $card->setAssignedUsers($assignedUsers); $card->setAttachments($attachments); + $this->enrich($card); return $card; } diff --git a/lib/Service/StackService.php b/lib/Service/StackService.php index 0947986c4..0ee599133 100644 --- a/lib/Service/StackService.php +++ b/lib/Service/StackService.php @@ -34,6 +34,7 @@ use OCA\Deck\Db\StackMapper; use OCA\Deck\StatusException; use OCA\Deck\BadRequestException; +use OCP\Comments\ICommentsManager; class StackService { diff --git a/templates/main.php b/templates/main.php index 7221882e9..cc6046b6c 100644 --- a/templates/main.php +++ b/templates/main.php @@ -25,6 +25,8 @@ Util::addScript('activity', 'richObjectStringParser'); Util::addStyle('activity', 'style'); +Util::addStyle('comments', 'comments'); +Util::addScript('oc-backbone-webdav'); Util::addStyle('deck', '../js/build/vendor'); Util::addScript('deck', 'build/vendor'); diff --git a/templates/part.board.mainView.php b/templates/part.board.mainView.php index fb91b9a01..72f2da470 100644 --- a/templates/part.board.mainView.php +++ b/templates/part.board.mainView.php @@ -94,7 +94,7 @@ class="input-inline"
      - + {{ cardservice.get(c.id).duedate | relativeDateFilterString }} @@ -107,6 +107,10 @@ class="input-inline" {{ attachmentCount(cardservice.get(c.id)) }}
      +
      + + {{ unreadCommentCount(cardservice.get(c.id)) }} +
      diff --git a/templates/part.card.activity.html b/templates/part.card.activity.html index 1c8a7198f..d4c1fafc2 100644 --- a/templates/part.card.activity.html +++ b/templates/part.card.activity.html @@ -1,15 +1,47 @@ +
      +
      +
      +
      +
      {{ $ctrl.currentUser.displayName }}
      +
      +
      +
      + +
      +
      +
      • -
      • +
      • - + +
        -
        {{ activity.timestamp/1000 | relativeDateFilter }} -
        +
        +
        + {{ activity.subject_rich[1].user.name }} +
        + + +
        +
        +
        +
        +
        +
        + +
      +
      diff --git a/templates/part.card.php b/templates/part.card.php index 2e690139f..325af78a7 100644 --- a/templates/part.card.php +++ b/templates/part.card.php @@ -90,9 +90,9 @@
      diff --git a/tests/unit/Activity/CommentEventHandlerTest.php b/tests/unit/Activity/CommentEventHandlerTest.php new file mode 100644 index 000000000..1e149f528 --- /dev/null +++ b/tests/unit/Activity/CommentEventHandlerTest.php @@ -0,0 +1,111 @@ + + * + * @author Julius Härtl + * + * @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 . + * + */ + +namespace OCA\Deck\Activity; + +use OCA\Deck\Db\AclMapper; +use OCA\Deck\Db\AssignedUsers; +use OCA\Deck\Db\Attachment; +use OCA\Deck\Db\AttachmentMapper; +use OCA\Deck\Db\Board; +use OCA\Deck\Db\BoardMapper; +use OCA\Deck\Db\Card; +use OCA\Deck\Db\CardMapper; +use OCA\Deck\Db\Label; +use OCA\Deck\Db\Stack; +use OCA\Deck\Db\StackMapper; +use OCA\Deck\Notification\NotificationHelper; +use OCA\Deck\Service\PermissionService; +use OCP\Activity\IEvent; +use OCP\Activity\IManager; +use OCP\Comments\CommentsEvent; +use OCP\Comments\IComment; +use OCP\IL10N; +use OCP\IUser; +use PHPUnit\Framework\TestCase; +use PHPUnit_Framework_MockObject_MockObject as MockObject; + +class CommentEventHandlerTest extends TestCase { + + /** @var CommentEventHandler */ + private $commentEventHandler; + /** @var ActivityManager */ + private $activityManager; + /** @var NotificationHelper */ + private $notificationHelper; + + public function setUp() { + $this->activityManager = $this->createMock(ActivityManager::class); + $this->notificationHelper = $this->createMock(NotificationHelper::class); + $this->commentEventHandler = new CommentEventHandler( + $this->activityManager, + $this->notificationHelper + ); + } + + public function testHandle() { + $comment = $this->createMock(IComment::class); + $comment->expects($this->any())->method('getId')->willReturn(1); + $comment->expects($this->any())->method('getObjectType')->willReturn('deckCard'); + $commentsEvent = new CommentsEvent(CommentsEvent::EVENT_ADD, $comment); + $this->activityManager->expects($this->once()) + ->method('triggerEvent') + ->with(ActivityManager::DECK_OBJECT_CARD, $comment, ActivityManager::SUBJECT_CARD_COMMENT_CREATE, ['comment' => 1]); + $this->notificationHelper->expects($this->once()) + ->method('sendMention') + ->with($comment); + $this->commentEventHandler->handle($commentsEvent); + } + + public function testHandleUpdate() { + $comment = $this->createMock(IComment::class); + $comment->expects($this->any())->method('getId')->willReturn(1); + $comment->expects($this->any())->method('getObjectType')->willReturn('deckCard'); + $commentsEvent = new CommentsEvent(CommentsEvent::EVENT_UPDATE, $comment); + $this->activityManager->expects($this->never()) + ->method('triggerEvent'); + $this->notificationHelper->expects($this->once()) + ->method('sendMention') + ->with($comment); + $this->commentEventHandler->handle($commentsEvent); + } + + public function testHandleInvalid() { + $comment = $this->createMock(IComment::class); + $comment->expects($this->any())->method('getId')->willReturn(1); + $comment->expects($this->any())->method('getObjectType')->willReturn('other'); + $commentsEvent = new CommentsEvent(CommentsEvent::EVENT_ADD, $comment); + $this->activityManager->expects($this->never()) + ->method('triggerEvent'); + $this->commentEventHandler->handle($commentsEvent); + } + + public function invokePrivate(&$object, $methodName, array $parameters = array()) + { + $reflection = new \ReflectionClass(get_class($object)); + $method = $reflection->getMethod($methodName); + $method->setAccessible(true); + return $method->invokeArgs($object, $parameters); + } + +} diff --git a/tests/unit/Activity/DeckProviderTest.php b/tests/unit/Activity/DeckProviderTest.php index 81a023585..fce8b16bc 100644 --- a/tests/unit/Activity/DeckProviderTest.php +++ b/tests/unit/Activity/DeckProviderTest.php @@ -26,6 +26,8 @@ use OC\Activity\Event; use OCA\Deck\Db\Acl; use OCP\Activity\IEvent; +use OCP\Comments\IComment; +use OCP\Comments\ICommentsManager; use OCP\IL10N; use OCP\IURLGenerator; use OCP\IUser; @@ -48,6 +50,9 @@ class DeckProviderTest extends TestCase { /** @var IUserManager|MockObject */ private $userManager; + /** @var ICommentsManager|MockObject */ + private $commentsManager; + /** @var string */ private $userId = 'admin'; @@ -56,7 +61,8 @@ public function setUp() { $this->urlGenerator = $this->createMock(IURLGenerator::class); $this->activityManager = $this->createMock(ActivityManager::class); $this->userManager = $this->createMock(IUserManager::class); - $this->provider = new DeckProvider($this->urlGenerator, $this->activityManager, $this->userManager, $this->userId); + $this->commentsManager = $this->createMock(ICommentsManager::class); + $this->provider = new DeckProvider($this->urlGenerator, $this->activityManager, $this->userManager, $this->commentsManager, $this->userId); } private function mockEvent($objectType, $objectId, $objectName, $subject, $subjectParameters = []) { @@ -444,6 +450,30 @@ public function testParseParamForChanges() { $this->assertEquals($expected, $actual); } + public function testParseParamForComment() { + $comment = $this->createMock(IComment::class); + $comment->expects($this->once()) + ->method('getMessage') + ->willReturn('Comment content'); + $this->commentsManager->expects($this->once()) + ->method('get') + ->with(123) + ->willReturn($comment); + $event = $this->createMock(IEvent::class); + $event->expects($this->once()) + ->method('setParsedMessage') + ->with('Comment content'); + $params = []; + $subjectParams = [ + 'comment' => 123 + ]; + $expected = [ + 'comment' => 123, + ]; + $actual = $this->invokePrivate($this->provider, 'parseParamForComment', [$subjectParams, $params, $event]); + $this->assertEquals($expected, $actual); + } + public function invokePrivate(&$object, $methodName, array $parameters = array()) { $reflection = new \ReflectionClass(get_class($object)); diff --git a/tests/unit/Db/CardTest.php b/tests/unit/Db/CardTest.php index ba8ff7030..0fc3c4b6d 100644 --- a/tests/unit/Db/CardTest.php +++ b/tests/unit/Db/CardTest.php @@ -81,7 +81,8 @@ public function testJsonSerialize() { 'attachments' => null, 'attachmentCount' => null, 'assignedUsers' => null, - 'deletedAt' => 0 + 'deletedAt' => 0, + 'commentsUnread' => 0, ], $card->jsonSerialize()); } public function testJsonSerializeLabels() { @@ -104,7 +105,8 @@ public function testJsonSerializeLabels() { 'attachments' => null, 'attachmentCount' => null, 'assignedUsers' => null, - 'deletedAt' => 0 + 'deletedAt' => 0, + 'commentsUnread' => 0, ], $card->jsonSerialize()); } @@ -137,7 +139,8 @@ public function testJsonSerializeAsignedUsers() { 'attachments' => null, 'attachmentCount' => null, 'assignedUsers' => ['user1'], - 'deletedAt' => 0 + 'deletedAt' => 0, + 'commentsUnread' => 0, ], $card->jsonSerialize()); } diff --git a/tests/unit/Notification/NotificationHelperTest.php b/tests/unit/Notification/NotificationHelperTest.php index c9c11bc70..2a2fa2e65 100644 --- a/tests/unit/Notification/NotificationHelperTest.php +++ b/tests/unit/Notification/NotificationHelperTest.php @@ -31,6 +31,7 @@ use OCA\Deck\Db\CardMapper; use OCA\Deck\Db\User; use OCA\Deck\Service\PermissionService; +use OCP\Comments\IComment; use OCP\IGroup; use OCP\IGroupManager; use OCP\IUser; @@ -294,5 +295,62 @@ public function testSendBoardSharedGroup() { $this->notificationHelper->sendBoardShared(123, $acl); } + public function testSendMention() { + $comment = $this->createMock(IComment::class); + $comment->expects($this->any()) + ->method('getObjectId') + ->willReturn(123); + $comment->expects($this->any()) + ->method('getMessage') + ->willReturn('@user1 @user2 This is a message.'); + $comment->expects($this->once()) + ->method('getMentions') + ->willReturn([ + ['id' => 'user1'], + ['id' => 'user2'] + ]); + $card = new Card(); + $card->setId(123); + $card->setTitle('MyCard'); + $this->cardMapper->expects($this->any()) + ->method('find') + ->with(123) + ->willReturn($card); + $this->cardMapper->expects($this->any()) + ->method('findBoardId') + ->with(123) + ->willReturn(1); + + $notification1 = $this->createMock(INotification::class); + $notification1->expects($this->once())->method('setApp')->with('deck')->willReturn($notification1); + $notification1->expects($this->once())->method('setUser')->with('user1')->willReturn($notification1); + $notification1->expects($this->once())->method('setObject')->with('card', 123)->willReturn($notification1); + $notification1->expects($this->once())->method('setSubject')->with('card-comment-mentioned', ['MyCard', 1, 'admin'])->willReturn($notification1); + $notification1->expects($this->once())->method('setDateTime')->willReturn($notification1); + + $notification2 = $this->createMock(INotification::class); + $notification2->expects($this->once())->method('setApp')->with('deck')->willReturn($notification2); + $notification2->expects($this->once())->method('setUser')->with('user2')->willReturn($notification2); + $notification2->expects($this->once())->method('setObject')->with('card', 123)->willReturn($notification2); + $notification2->expects($this->once())->method('setSubject')->with('card-comment-mentioned', ['MyCard', 1, 'admin'])->willReturn($notification2); + $notification2->expects($this->once())->method('setDateTime')->willReturn($notification2); + + $this->notificationManager->expects($this->at(0)) + ->method('createNotification') + ->willReturn($notification1); + $this->notificationManager->expects($this->at(1)) + ->method('notify') + ->with($notification1); + + $this->notificationManager->expects($this->at(2)) + ->method('createNotification') + ->willReturn($notification2); + $this->notificationManager->expects($this->at(3)) + ->method('notify') + ->with($notification2); + + $this->notificationHelper->sendMention($comment); + } + -} \ No newline at end of file +} diff --git a/tests/unit/Notification/NotifierTest.php b/tests/unit/Notification/NotifierTest.php index 3d8992cdd..c61399594 100644 --- a/tests/unit/Notification/NotifierTest.php +++ b/tests/unit/Notification/NotifierTest.php @@ -35,7 +35,7 @@ use OCP\RichObjectStrings\Definitions; -class UnknownUserTest extends \Test\TestCase { +class NotifierTest extends \Test\TestCase { /** @var IFactory */ protected $l10nFactory; @@ -129,6 +129,53 @@ public function testPrepareCardOverdue() { } + public function testPrepareCardCommentMentioned() { + /** @var INotification $notification */ + $notification = $this->createMock(INotification::class); + $notification->expects($this->once()) + ->method('getApp') + ->willReturn('deck'); + + $notification->expects($this->once()) + ->method('getSubjectParameters') + ->willReturn(['Card title', 'Board title', 'admin']); + + $notification->expects($this->once()) + ->method('getSubject') + ->willReturn('card-comment-mentioned'); + $notification->expects($this->once()) + ->method('getObjectId') + ->willReturn('123'); + $this->cardMapper->expects($this->once()) + ->method('findBoardId') + ->willReturn(999); + $expectedMessage = 'admin has mentioned in a comment on "Card title".'; + $notification->expects($this->once()) + ->method('setParsedSubject') + ->with($expectedMessage); + $notification->expects($this->once()) + ->method('setRichSubject') + ->with('{user} has mentioned in a comment on "Card title".'); + + + $this->url->expects($this->once()) + ->method('imagePath') + ->with('deck', 'deck-dark.svg') + ->willReturn('deck-dark.svg'); + $this->url->expects($this->once()) + ->method('getAbsoluteURL') + ->with('deck-dark.svg') + ->willReturn('/absolute/deck-dark.svg'); + $notification->expects($this->once()) + ->method('setIcon') + ->with('/absolute/deck-dark.svg'); + + $actualNotification = $this->notifier->prepare($notification, 'en_US'); + + $this->assertEquals($notification, $actualNotification); + + } + public function dataPrepareCardAssigned() { return [ [true], [false] @@ -269,4 +316,4 @@ public function testPrepareBoardShared($withUserFound = true) { $this->assertEquals($notification, $actualNotification); } -} \ No newline at end of file +} diff --git a/tests/unit/Service/CardServiceTest.php b/tests/unit/Service/CardServiceTest.php index bb72bcfc6..9fcb6d176 100644 --- a/tests/unit/Service/CardServiceTest.php +++ b/tests/unit/Service/CardServiceTest.php @@ -36,31 +36,39 @@ use OCA\Deck\Notification\NotificationHelper; use OCA\Deck\StatusException; use OCP\Activity\IEvent; +use OCP\Comments\ICommentsManager; +use OCP\IUser; +use OCP\IUserManager; +use PHPUnit\Framework\MockObject\MockObject; use Test\TestCase; class CardServiceTest extends TestCase { - /** @var CardService|\PHPUnit\Framework\MockObject\MockObject */ + /** @var CardService|MockObject */ private $cardService; - /** @var CardMapper|\PHPUnit\Framework\MockObject\MockObject */ + /** @var CardMapper|MockObject */ private $cardMapper; - /** @var StackMapper|\PHPUnit\Framework\MockObject\MockObject */ + /** @var StackMapper|MockObject */ private $stackMapper; - /** @var PermissionService|\PHPUnit\Framework\MockObject\MockObject */ + /** @var PermissionService|MockObject */ private $permissionService; /** @var NotificationHelper */ private $notificationHelper; - /** @var AssignedUsersMapper|\PHPUnit\Framework\MockObject\MockObject */ + /** @var AssignedUsersMapper|MockObject */ private $assignedUsersMapper; - /** @var BoardService|\PHPUnit\Framework\MockObject\MockObject */ + /** @var BoardService|MockObject */ private $boardService; - /** @var LabelMapper|\PHPUnit\Framework\MockObject\MockObject */ + /** @var LabelMapper|MockObject */ private $labelMapper; private $boardMapper; - /** @var AttachmentService|\PHPUnit\Framework\MockObject\MockObject */ + /** @var AttachmentService|MockObject */ private $attachmentService; - /** @var ActivityManager|\PHPUnit\Framework\MockObject\MockObject */ + /** @var ActivityManager|MockObject */ private $activityManager; + /** @var ICommentsManager|MockObject */ + private $commentsManager; + /** @var ICommentsManager|MockObject */ + private $userManager; public function setUp() { parent::setUp(); @@ -74,6 +82,8 @@ public function setUp() { $this->assignedUsersMapper = $this->createMock(AssignedUsersMapper::class); $this->attachmentService = $this->createMock(AttachmentService::class); $this->activityManager = $this->createMock(ActivityManager::class); + $this->commentsManager = $this->createMock(ICommentsManager::class); + $this->userManager = $this->createMock(IUserManager::class); $this->cardService = new CardService( $this->cardMapper, $this->stackMapper, @@ -85,6 +95,8 @@ public function setUp() { $this->assignedUsersMapper, $this->attachmentService, $this->activityManager, + $this->commentsManager, + $this->userManager, 'user1' ); } @@ -102,13 +114,17 @@ public function mockActivity($type, $object, $subject) { } public function testFind() { + $user = $this->createMock(IUser::class); + $this->userManager->expects($this->once()) + ->method('get') + ->willReturn($user); $card = new Card(); $card->setId(1337); - $this->cardMapper->expects($this->once()) + $this->cardMapper->expects($this->any()) ->method('find') ->with(123) ->willReturn($card); - $this->assignedUsersMapper->expects($this->once()) + $this->assignedUsersMapper->expects($this->any()) ->method('find') ->with(1337) ->willReturn(['user1', 'user2']);