Merge pull request #632 from nextcloud/feature/10/comments
Comments support
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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
77
css/autocomplete.scss
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
1
img/description.svg
Normal 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 |
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -482,4 +482,8 @@ app.controller('BoardController', function ($rootScope, $scope, $stateParams, St
|
||||
}
|
||||
return card.attachmentCount;
|
||||
};
|
||||
|
||||
$scope.unreadCommentCount = function(card) {
|
||||
return card.commentsUnread;
|
||||
};
|
||||
});
|
||||
|
||||
59
js/directive/contenteditable.js
Normal file
59
js/directive/contenteditable.js
Normal 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();
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
};
|
||||
});
|
||||
161
js/legacy/commentcollection.js
Normal file
161
js/legacy/commentcollection.js
Normal 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
119
js/legacy/commentmodel.js
Normal 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;
|
||||
|
||||
54
js/legacy/commentsummarymodel.js
Normal file
54
js/legacy/commentsummarymodel.js
Normal 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
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
561
js/legacy/jquery.caret.min.js
vendored
Normal 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.$));
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
85
lib/Activity/CommentEventHandler.php
Normal file
85
lib/Activity/CommentEventHandler.php
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
111
tests/unit/Activity/CommentEventHandlerTest.php
Normal file
111
tests/unit/Activity/CommentEventHandlerTest.php
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user