+ *
+ * @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)) }}
+
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 @@
+
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 @@
- -
+
-
-
+
+
-
{{ activity.timestamp/1000 | relativeDateFilter }}
-
+
+
+ {{ activity.subject_rich[1].user.name }}
+
+
+
+
+
+
+
+
+
++- Edit comment
+ - Delete comment
+
+