Merge pull request #632 from nextcloud/feature/10/comments

Comments support
This commit is contained in:
Julius Härtl
2018-10-09 16:44:19 +02:00
committed by GitHub
36 changed files with 1953 additions and 44 deletions

View File

@@ -28,5 +28,7 @@ if ((@include_once __DIR__ . '/../vendor/autoload.php')===false) {
$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');

View File

@@ -14,7 +14,7 @@
- 🚀 Get your project organized
</description>
<version>0.5.0-dev3</version>
<version>0.5.0-dev4</version>
<licence>agpl</licence>
<author>Julius Härtl</author>
<namespace>Deck</namespace>

77
css/autocomplete.scss Normal file
View File

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

View File

@@ -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');
}

View File

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

1
img/description.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" version="1.1" height="16"><path fill="#000" d="m2.5 1c-0.28 0-0.5 0.22-0.5 0.5v13c0 0.28 0.22 0.5 0.5 0.5h11c0.28 0 0.5-0.22 0.5-0.5v-10.5l-3-3h-8.5zm1.5 2h6v1h-6v-1zm0 3h5v1h-5v-1zm0 3h8v1h-8v-1zm0 3h4v1h-4v-1z"/></svg>

After

Width:  |  Height:  |  Size: 292 B

View File

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

View File

@@ -4,20 +4,20 @@
* @author Julius Härtl <jus@bitgrid.net>
*
* @license GNU AGPL version 3 or any later version
*
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*
*/
import app from './App.js';

View File

@@ -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 '<li>' +
'<span class="avatar-name-wrapper">' +
'<span class="avatar" ' +
'data-username="' + escapeHTML(item.uid) + '" ' + // for avatars
'data-user="' + escapeHTML(item.uid) + '" ' + // for contactsmenu
'data-user-display-name="' + escapeHTML(item.displayname) + '">' +
'</span>' +
'<strong>' + escapeHTML(item.displayname) + '</strong>' +
'</span></li>';
},
insertTpl: function (item) {
return '' +
'<span class="avatar-name-wrapper">' +
'<span class="avatar" ' +
'data-username="' + escapeHTML(item.uid) + '" ' + // for avatars
'data-user="' + escapeHTML(item.uid) + '" ' + // for contactsmenu
'data-user-display-name="' + escapeHTML(item.displayname) + '">' +
'</span>' +
'<strong>' + escapeHTML(item.displayname) + '</strong>' +
'</span>';
},
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 = $('<div/>').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(/<br\s*[\/]?>/gi, '\n'));
return $comment.text();
}
static _composeHTMLMention(uid, displayName) {
var avatar = '' +
'<span class="avatar" data-username="' + escapeHTML(uid) + '" data-user="' + escapeHTML(uid) + '" ng-attr-size="16" ' +
'ng-attr-user="' + escapeHTML(uid) + '" ' +
'ng-attr-displayname="' + escapeHTML(displayName) + '" ng-attr-contactsmenu="true">' +
'</span>';
var isCurrentUser = (uid === OC.getCurrentUser().uid);
return '' +
'<span class="atwho-inserted" contenteditable="false">' +
'<span class="avatar-name-wrapper' + (isCurrentUser ? ' currentUser' : '') + '">' +
avatar +
'<strong>' + escapeHTML(displayName) + '</strong>' +
'</span>' +
'</span>';
}
formatMessage(activity) {
let message = activity.message;
let mentions = activity.commentModel.get('mentions');
const editMode = false;
message = escapeHTML(message).replace(/\n/g, '<br/>');
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 = {

View File

@@ -482,4 +482,8 @@ app.controller('BoardController', function ($rootScope, $scope, $stateParams, St
}
return card.attachmentCount;
};
$scope.unreadCommentCount = function(card) {
return card.commentsUnread;
};
});

View File

@@ -0,0 +1,59 @@
/*
* @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
*
* @author Julius Härtl <jus@bitgrid.net>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import 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();
}
});
}
};
});

View File

@@ -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 = '<?xml version="1.0" encoding="utf-8" ?>\n' +
'<oc:filter-comments xmlns:D="DAV:" xmlns:oc="http://owncloud.org/ns">\n' +
// load one more so we know there is more
' <oc:limit>' + (this._limit + 1) + '</oc:limit>\n' +
' <oc:offset>' + this.length + '</oc:offset>\n' +
'</oc:filter-comments>\n';
options = options || {};
var success = options.success;
options = _.extend({
remove: false,
parse: true,
data: body,
davProperties: CommentCollection.prototype.model.prototype.davProperties,
success: function(resp) {
if (resp.length <= self._limit) {
// no new entries, end reached
self._endReached = true;
} else {
// remove last entry, for next page load
resp = _.initial(resp);
}
if (!self.set(resp, options)) {
return false;
}
if (success) {
success.apply(null, arguments);
}
self.trigger('sync', 'REPORT', self, options);
}
}, options);
return this.sync('REPORT', this, options);
},
/**
* Returns the matching summary model
*
* @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;

119
js/legacy/commentmodel.js Normal file
View File

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

View File

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

1
js/legacy/jquery.atwho.min.js vendored Normal file

File diff suppressed because one or more lines are too long

561
js/legacy/jquery.caret.min.js vendored Normal file
View File

@@ -0,0 +1,561 @@
/*
* @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
*
* @author Julius Härtl <jus@bitgrid.net>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
(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
* <pre>
* // Get position
* var pos = $('input:first').caret();
* </pre>
* @example
* <pre>
* // Set position
* $('input:first').caret(15);
* $('input:first').caret(-3);
* </pre>
* @example
* <pre>
* // Insert text at current position
* $('input:first').caret('Some text');
* </pre>
*/
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
* <pre>
* // Get selection range
* var range = $('input:first').range();
* </pre>
* @example
* <pre>
* // Set selection range
* $('input:first').range(15);
* $('input:first').range(15, 20);
* $('input:first').range(-3);
* $('input:first').range(-8, -3);
* </pre>
* @example
* <pre>
* // Replace the currently selected text
* $('input:first').range('Replacement text');
* </pre>
*/
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
* <pre>
* // Select the contents of span elements when clicked
* $('span').on('click', function() { $(this).highlight(); });
* </pre>
*/
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
* <pre>
* // Select some text
* $('span').selectAll();
*
* // Deselect the text
* $.deselectAll();
* </pre>
*/
deselectAll: function() {
_deselectAll();
return this;
}
});
}(window.jQuery || window.Zepto || window.$));

View File

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

View File

@@ -41,6 +41,7 @@ use OCP\Activity\IEvent;
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 @@ class ActivityManager {
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 @@ class ActivityManager {
// 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 @@ class ActivityManager {
*/
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 @@ class ActivityManager {
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);
}

View File

@@ -0,0 +1,85 @@
<?php
/**
* @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
*
* @author Julius Härtl <jus@bitgrid.net>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
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());
}
}

View File

@@ -28,6 +28,9 @@ use cogpowered\FineDiff\Diff;
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 @@ class DeckProvider implements IProvider {
'type' => 'user',
'id' => $author,
'name' => $user !== null ? $user->getDisplayName() : $author
]
],
];
$params = $this->parseParamForBoard('board', $subjectParams, $params);
@@ -117,6 +123,7 @@ class DeckProvider implements IProvider {
$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 @@ class DeckProvider implements IProvider {
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 @@ class DeckProvider implements IProvider {
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

View File

@@ -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 @@ class Application extends App {
}, 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);
});
}
}

View File

@@ -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 @@ class Card extends RelationalEntity {
$this->addRelation('attachments');
$this->addRelation('attachmentCount');
$this->addRelation('participants');
$this->addRelation('commentsUnread');
$this->addResolvable('owner');
}

View File

@@ -29,6 +29,7 @@ use OCA\Deck\Db\Board;
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 @@ class NotificationHelper {
}
}
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 @@ class NotificationHelper {
return $notification;
}
}
}

View File

@@ -105,6 +105,31 @@ class Notifier implements INotifier {
);
$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 @@ class Notifier implements INotifier {
}
return $notification;
}
}
}

View File

@@ -37,6 +37,8 @@ use OCA\Deck\Db\LabelMapper;
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 @@ class CardService {
AssignedUsersMapper $assignedUsersMapper,
AttachmentService $attachmentService,
ActivityManager $activityManager,
ICommentsManager $commentsManager,
IUserManager $userManager,
$userId
) {
$this->cardMapper = $cardMapper;
@@ -75,6 +80,8 @@ class CardService {
$this->assignedUsersMapper = $assignedUsersMapper;
$this->attachmentService = $attachmentService;
$this->activityManager = $activityManager;
$this->commentsManager = $commentsManager;
$this->userManager = $userManager;
$this->currentUser = $userId;
}
@@ -83,6 +90,10 @@ class CardService {
$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 @@ class CardService {
$attachments = $this->attachmentService->findAll($cardId, true);
$card->setAssignedUsers($assignedUsers);
$card->setAttachments($attachments);
$this->enrich($card);
return $card;
}

View File

@@ -34,6 +34,7 @@ use OCA\Deck\Db\Stack;
use OCA\Deck\Db\StackMapper;
use OCA\Deck\StatusException;
use OCA\Deck\BadRequestException;
use OCP\Comments\ICommentsManager;
class StackService {

View File

@@ -25,6 +25,8 @@ use OCP\Util;
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');

View File

@@ -94,7 +94,7 @@
</div>
<div class="card-controls compact-item" ng-if="!compactMode">
<i class="icon icon-filetype-text" ng-if="cardservice.get(c.id).description" title="{{ cardservice.get(c.id).description }}"></i>
<i class="icon icon-description" ng-if="cardservice.get(c.id).description" title="{{ cardservice.get(c.id).description }}"></i>
<span class="due" ng-if="cardservice.get(c.id).duedate" ng-class="{'overdue': cardservice.get(c.id).overdue == 3, 'now': cardservice.get(c.id).overdue == 2, 'next': cardservice.get(c.id).overdue == 1 }" title="{{ cardservice.get(c.id).duedate }}">
<i class="icon icon-badge"></i>
<span data-timestamp="{{ cardservice.get(c.id).duedate | dateToTimestamp }}" class="live-relative-timestamp">{{ cardservice.get(c.id).duedate | relativeDateFilterString }}</span>
@@ -107,6 +107,10 @@
<i class="icon icon-files-dark"></i>
<span>{{ attachmentCount(cardservice.get(c.id)) }}</span>
</div>
<div class="card-comments" ng-if="unreadCommentCount(cardservice.get(c.id)) > 0">
<i class="icon icon-comment"></i>
<span>{{ unreadCommentCount(cardservice.get(c.id)) }}</span>
</div>
<div class="card-assigned-users">
<div class="assigned-user" ng-repeat="user in cardservice.get(c.id).assignedUsers | limitTo: 3">
<avatar data-user="{{ user.participant.uid }}" data-displayname="{{ user.participant.displayname }}" data-tooltip></avatar>

View File

@@ -1,15 +1,47 @@
<div id="commentsTabView">
<div class="newCommentRow comment" data-id="">
<div class="authorRow">
<div class="avatardiv" avatar ng-attr-user="{{ $ctrl.currentUser.uid }}" ng-attr-displayname="{{ $ctrl.currentUser.displayName }}" ng-attr-size="24"></div>
<div class="author currentUser">{{ $ctrl.currentUser.displayName }}</div>
</div>
<form class="newCommentForm" ng-submit="$ctrl.postComment()">
<div ng-contenteditable contenteditable="true" class="message" ng-submit="$ctrl.postComment()" data-placeholder="{{ $ctrl.t('New comment …') }}" ng-model="$ctrl.$scope.newComment" ng-disabled="$ctrl.status.commentCreateLoading"></div>
<input class="submit icon-confirm has-tooltip" type="submit"
value="" title="" data-original-title="Post" ng-if="!$ctrl.status.commentCreateLoading">
<div class="submitLoading icon-loading-small" ng-if="$ctrl.status.commentCreateLoading"></div>
</form>
</div>
<ul class="activities" infinite-scroll="$ctrl.page()" infinite-scroll-container="'#app-sidebar'" infinite-scroll-disabled="$ctrl.activityservice.running" infinite-scroll-immediate-check="false">
<li ng-if="$ctrl.loadingNewer()"><div class="icon-loading-small"></div></li>
<li class="activity box" ng-repeat="activity in $ctrl.getData($ctrl.element.id) track by $index">
<li class="activity box" ng-repeat="activity in $ctrl.getActivityStream() track by $index">
<div class="activity-icon">
<img src="{{activity.icon}}" alt="">
<img ng-if="!activity.commentModel" src="{{activity.icon}}" alt="">
<div ng-if="activity.commentModel" avatar ng-attr-contactsmenu="true" ng-attr-size="24" ng-attr-user="{{ activity.commentModel.get('actorId') }}" ng-attr-displayname="{{ activity.actorDisplayName }}"></div>
</div>
<div class="activitysubject"
bind-html-compile="$ctrl.parseMessage(activity.subject_rich[0], activity.subject_rich[1])"></div>
<span class="activitytime has-tooltip live-relative-timestamp"
data-timestamp="{{ activity.timestamp }}">{{ activity.timestamp/1000 | relativeDateFilter }}</span>
<div class="activitymessage" ng-bind-html="activity.message"></div>
<div class="activitysubject" ng-if="!activity.commentModel" bind-html-compile="$ctrl.parseMessage(activity.subject_rich[0], activity.subject_rich[1])"></div>
<div class="activitysubject commentAuthor" ng-if="activity.commentModel">
{{ activity.subject_rich[1].user.name }}
<div class="app-popover-menu-utils">
<button class="button-inline icon-more ng-pristine ng-valid ng-empty ng-touched" aria-label="Actions"></button>
<div class="popovermenu hidden">
<ul>
<li><a ng-click="$ctrl.updateComment(activity)" class="menuitem action edit permanent" data-action="edit"><span class="icon icon-rename"></span><span>Edit comment</span></a></li>
<li><a ng-click="$ctrl.deleteComment(activity)" class="menuitem action delete permanent" data-action="delete"><span class="icon icon-delete"></span><span>Delete comment</span></a></li>
</ul>
</div>
</div>
</div>
<div class="activitymessage" ng-if="!activity.commentModel" ng-bind-html="activity.message"></div>
<div class="activitymessage" ng-if="activity.commentModel && !activity.commentEdit" bind-html-compile="$ctrl.formatMessage(activity)"></div>
<form class="newCommentForm" ng-show="activity.commentEdit">
<div ng-contenteditable contenteditable="true" class="message" ng-model="activity.commentEdit"></div>
<input class="submit icon-confirm has-tooltip" type="submit"
value="" title="" data-original-title="Post" ng-click="$ctrl.editComment(activity)">
</form>
</li>
<li ng-if="$ctrl.loading"><div class="icon-loading-small"></div></li>
</ul>
</div>

View File

@@ -90,9 +90,9 @@
<div class="section-header-tabbed">
<ul class="tabHeaders ng-scope">
<li class="tabHeader" ng-class="{'selected': (params.tab==0 || !params.tab)}" ui-sref="{tab: 0}"><a><?php p($l->t('Description')); ?></a></li>
<li class="tabHeader" ng-class="{'selected': (params.tab==1)}" ui-sref="{tab: 1}"><a><?php p($l->t('Attachments')); ?></a></li>
<li class="tabHeader" ng-class="{'selected': (params.tab==2)}" ui-sref="{tab: 2}"><a><?php p($l->t('Activity')); ?></a></li>
<li class="tabHeader" ng-class="{'selected': (params.tab==0 || !params.tab)}" ui-sref="{tab: 0}"><a><span class="icon icon-description"></span><?php p($l->t('Description')); ?></a></li>
<li class="tabHeader" ng-class="{'selected': (params.tab==1)}" ui-sref="{tab: 1}"><a><span class="icon icon-files-dark"></span><?php p($l->t('Attachments')); ?></a></li>
<li class="tabHeader" ng-class="{'selected': (params.tab==2)}" ui-sref="{tab: 2}"><a><span class="icon icon-activity"></span><?php p($l->t('Activity/Comments')); ?></a></li>
</ul>
<div class="tabDetails">

View File

@@ -0,0 +1,111 @@
<?php
/**
* @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
*
* @author Julius Härtl <jus@bitgrid.net>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
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);
}
}

View File

@@ -26,6 +26,8 @@ namespace OCA\Deck\Activity;
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 @@ class DeckProviderTest extends TestCase {
$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 @@ class DeckProviderTest extends TestCase {
$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));

View File

@@ -81,7 +81,8 @@ class CardTest extends TestCase {
'attachments' => null,
'attachmentCount' => null,
'assignedUsers' => null,
'deletedAt' => 0
'deletedAt' => 0,
'commentsUnread' => 0,
], $card->jsonSerialize());
}
public function testJsonSerializeLabels() {
@@ -104,7 +105,8 @@ class CardTest extends TestCase {
'attachments' => null,
'attachmentCount' => null,
'assignedUsers' => null,
'deletedAt' => 0
'deletedAt' => 0,
'commentsUnread' => 0,
], $card->jsonSerialize());
}
@@ -137,7 +139,8 @@ class CardTest extends TestCase {
'attachments' => null,
'attachmentCount' => null,
'assignedUsers' => ['user1'],
'deletedAt' => 0
'deletedAt' => 0,
'commentsUnread' => 0,
], $card->jsonSerialize());
}

View File

@@ -31,6 +31,7 @@ use OCA\Deck\Db\Card;
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 @@ class NotificationHelperTest extends \Test\TestCase {
$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);
}
}

View File

@@ -35,7 +35,7 @@ use OCP\Notification\INotifier;
use OCP\RichObjectStrings\Definitions;
class UnknownUserTest extends \Test\TestCase {
class NotifierTest extends \Test\TestCase {
/** @var IFactory */
protected $l10nFactory;
@@ -129,6 +129,53 @@ class UnknownUserTest extends \Test\TestCase {
}
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 @@ class UnknownUserTest extends \Test\TestCase {
$this->assertEquals($notification, $actualNotification);
}
}
}

View File

@@ -36,31 +36,39 @@ use OCA\Deck\NotFoundException;
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 @@ class CardServiceTest extends TestCase {
$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 @@ class CardServiceTest extends TestCase {
$this->assignedUsersMapper,
$this->attachmentService,
$this->activityManager,
$this->commentsManager,
$this->userManager,
'user1'
);
}
@@ -102,13 +114,17 @@ class CardServiceTest extends TestCase {
}
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']);