Implement user mentioning in frontend

Signed-off-by: Julius Härtl <jus@bitgrid.net>
This commit is contained in:
Julius Härtl
2018-10-06 11:54:15 +02:00
parent e29efe8935
commit 310af37d67
10 changed files with 804 additions and 15 deletions

77
css/autocomplete.scss Normal file
View File

@@ -0,0 +1,77 @@
/**
* based upon apps/comments/js/vendor/At.js/dist/css/jquery.atwho.css,
* only changed colors and font-weight
*/
.atwho-view {
position:absolute;
top: 0;
left: 0;
display: none;
margin-top: 18px;
background: var(--color-main-background);
color: var(--color-main-text);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
box-shadow: 0 0 5px var(--color-box-shadow);
min-width: 120px;
z-index: 11110 !important;
}
.atwho-view .atwho-header {
padding: 5px;
margin: 5px;
cursor: pointer;
border-bottom: solid 1px var(--color-border);
color: var(--color-main-text);
font-size: 11px;
font-weight: bold;
}
.atwho-view .atwho-header .small {
color: var(--color-main-text);
float: right;
padding-top: 2px;
margin-right: -5px;
font-size: 12px;
font-weight: normal;
}
.atwho-view .atwho-header:hover {
cursor: default;
}
.atwho-view .cur {
background: var(--color-primary);
color: var(--color-primary-text);
}
.atwho-view .cur small {
color: var(--color-primary-text);
}
.atwho-view strong {
color: var(--color-main-text);
font-weight: normal;
}
.atwho-view .cur strong {
color: var(--color-primary-text);
font-weight: normal;
}
.atwho-view ul {
/* width: 100px; */
list-style:none;
padding:0;
margin:auto;
max-height: 200px;
overflow-y: auto;
}
.atwho-view ul li {
display: block;
padding: 5px 10px;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
}
.atwho-view small {
font-size: smaller;
color: var(--color-main-text);
font-weight: normal;
}

View File

@@ -41,6 +41,7 @@ $compact-board-last-item-margin: 5px 10px 10px;
@import 'icons';
@import 'animations';
@import 'compact-mode';
@import 'autocomplete';
/**
* General styles

View File

@@ -49,6 +49,8 @@ import md from 'angular-markdown-it';
import nganimate from 'angular-animate';
import 'angular-file-upload';
import ngInfiniteScroll from 'ng-infinite-scroll';
import '../legacy/jquery.atwho.min';
import '../legacy/jquery.caret.min';
var app = angular.module('Deck', [
ngsanitize,

View File

@@ -49,31 +49,146 @@ class ActivityController {
}
self.activityservice.fetchNewerActivities(self.type, self.element.id).then(function () {});
}, true);
let $target = $('.newCommentForm .message');
if (!$target) {
return;
}
$target.atwho({
at: "@",
data:[{id: 'johndoe', label: 'John Doe'}],
callbacks: {
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.id) + '" ' + // for avatars
'data-user="' + escapeHTML(item.id) + '" ' + // for contactsmenu
'data-user-display-name="' + escapeHTML(item.label) + '">' +
'</span>' +
'<strong>' + escapeHTML(item.label) + '</strong>' +
'</span></li>';
},
insertTpl: function (item) {
return '' +
'<span class="avatar-name-wrapper">' +
'<span class="avatar" ' +
'data-username="' + escapeHTML(item.id) + '" ' + // for avatars
'data-user="' + escapeHTML(item.id) + '" ' + // for contactsmenu
'data-user-display-name="' + escapeHTML(item.label) + '">' +
'</span>' +
'<strong>' + escapeHTML(item.label) + '</strong>' +
'</span>';
},
searchKey: "label"
});
$target.on('inserted.atwho', function (je, $el) {
$(je.target).find(
'span[data-username="' + $el.find('[data-username]').data('username') + '"]'
).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" ng-attr-size="16" ' +
'ng-attr-user="' + _.escape(uid) + '" ' +
'ng-attr-displayname="' + _.escape(displayName) + '">' +
'</span>';
var isCurrentUser = (uid === OC.getCurrentUser().uid);
return '' +
'<span class="atwho-inserted">' +
'<span class="avatar-name-wrapper' + (isCurrentUser ? ' currentUser' : '') + '">' +
avatar +
'<strong>' + _.escape(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: self.$scope.newComment,
message: content,
creationDateTime: (new Date()).toUTCString()
}, {
at: 0,
// wait for real creation before adding
wait: true,
success: function() {
console.log("SUCCESS");
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.'));
}
});
}

View File

@@ -45,7 +45,7 @@ var CommentCollection = OC.Backbone.Collection.extend(
*
* @type int
*/
_limit : 2,
_limit : 5,
/**
* Initializes the collection

View File

@@ -1,7 +1,16 @@
/*
* 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 OCA.AnnouncementCenter.Comments.CommentModel
* @class CommentModel
* @classdesc
*
* Comment
@@ -49,7 +58,8 @@ var CommentModel = OC.Backbone.Model.extend(
'creationDateTime': '{' + NS_OWNCLOUD + '}creationDateTime',
'objectType': '{' + NS_OWNCLOUD + '}objectType',
'objectId': '{' + NS_OWNCLOUD + '}objectId',
'isUnread': '{' + NS_OWNCLOUD + '}isUnread'
'isUnread': '{' + NS_OWNCLOUD + '}isUnread',
'mentions': '{' + NS_OWNCLOUD + '}mentions'
},
parse: function(data) {
@@ -62,10 +72,32 @@ var CommentModel = OC.Backbone.Model.extend(
creationDateTime: data.creationDateTime,
objectType: data.objectType,
objectId: data.objectId,
isUnread: (data.isUnread === 'true')
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() {
if (typeof this.get('id') !== 'undefined') {
return this.collection.url() + this.get('id');
@@ -73,7 +105,6 @@ var CommentModel = OC.Backbone.Model.extend(
return this.collection.url();
}
}
});
export default CommentModel;

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

File diff suppressed because one or more lines are too long

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

@@ -0,0 +1,561 @@
/*
* @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net>
*
* @author Julius Härtl <jus@bitgrid.net>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
(function($, undefined) {
var _input = document.createElement('input');
var _support = {
setSelectionRange: ('setSelectionRange' in _input) || ('selectionStart' in _input),
createTextRange: ('createTextRange' in _input) || ('selection' in document)
};
var _rNewlineIE = /\r\n/g,
_rCarriageReturn = /\r/g;
var _getValue = function(input) {
if (typeof(input.value) !== 'undefined') {
return input.value;
}
return $(input).text();
};
var _setValue = function(input, value) {
if (typeof(input.value) !== 'undefined') {
input.value = value;
} else {
$(input).text(value);
}
};
var _getIndex = function(input, pos) {
var norm = _getValue(input).replace(_rCarriageReturn, '');
var len = norm.length;
if (typeof(pos) === 'undefined') {
pos = len;
}
pos = Math.floor(pos);
// Negative index counts backward from the end of the input/textarea's value
if (pos < 0) {
pos = len + pos;
}
// Enforce boundaries
if (pos < 0) { pos = 0; }
if (pos > len) { pos = len; }
return pos;
};
var _hasAttr = function(input, attrName) {
return input.hasAttribute ? input.hasAttribute(attrName) : (typeof(input[attrName]) !== 'undefined');
};
/**
* @class
* @constructor
*/
var Range = function(start, end, length, text) {
this.start = start || 0;
this.end = end || 0;
this.length = length || 0;
this.text = text || '';
};
Range.prototype.toString = function() {
return JSON.stringify(this, null, ' ');
};
var _getCaretW3 = function(input) {
return input.selectionStart;
};
/**
* @see http://stackoverflow.com/q/6943000/467582
*/
var _getCaretIE = function(input) {
var caret, range, textInputRange, rawValue, len, endRange;
// Yeah, you have to focus twice for IE 7 and 8. *cries*
input.focus();
input.focus();
range = document.selection.createRange();
if (range && range.parentElement() === input) {
rawValue = _getValue(input);
len = rawValue.length;
// Create a working TextRange that lives only in the input
textInputRange = input.createTextRange();
textInputRange.moveToBookmark(range.getBookmark());
// Check if the start and end of the selection are at the very end
// of the input, since moveStart/moveEnd doesn't return what we want
// in those cases
endRange = input.createTextRange();
endRange.collapse(false);
if (textInputRange.compareEndPoints("StartToEnd", endRange) > -1) {
caret = rawValue.replace(_rNewlineIE, '\n').length;
} else {
caret = -textInputRange.moveStart("character", -len);
}
return caret;
}
// NOTE: This occurs when you highlight part of a textarea and then click in the middle of the highlighted portion in IE 6-10.
// There doesn't appear to be anything we can do about it.
// alert("Your browser is incredibly stupid. I don't know what else to say.");
// alert(range + '\n\n' + range.parentElement().tagName + '#' + range.parentElement().id);
return 0;
};
/**
* Gets the position of the caret in the given input.
* @param {HTMLInputElement|HTMLTextAreaElement} input input or textarea element
* @returns {Number}
* @see http://stackoverflow.com/questions/263743/how-to-get-cursor-position-in-textarea/263796#263796
*/
var _getCaret = function(input) {
if (!input) {
return undefined;
}
// Mozilla, et al.
if (_support.setSelectionRange) {
return _getCaretW3(input);
}
// IE
else if (_support.createTextRange) {
return _getCaretIE(input);
}
return undefined;
};
var _setCaretW3 = function(input, pos) {
input.setSelectionRange(pos, pos);
};
var _setCaretIE = function(input, pos) {
var range = input.createTextRange();
range.move('character', pos);
range.select();
};
/**
* Sets the position of the caret in the given input.
* @param {HTMLInputElement|HTMLTextAreaElement} input input or textarea element
* @param {Number} pos
* @see http://parentnode.org/javascript/working-with-the-cursor-position/
*/
var _setCaret = function(input, pos) {
input.focus();
pos = _getIndex(input, pos);
// Mozilla, et al.
if (_support.setSelectionRange) {
_setCaretW3(input, pos);
}
// IE
else if (_support.createTextRange) {
_setCaretIE(input, pos);
}
};
/**
* Inserts the specified text at the current caret position in the given input.
* @param {HTMLInputElement|HTMLTextAreaElement} input input or textarea element
* @param {String} text
* @see http://parentnode.org/javascript/working-with-the-cursor-position/
*/
var _insertAtCaret = function(input, text) {
var curPos = _getCaret(input);
var oldValueNorm = _getValue(input).replace(_rCarriageReturn, '');
var newLength = +(curPos + text.length + (oldValueNorm.length - curPos));
var maxLength = +input.getAttribute('maxlength');
if(_hasAttr(input, 'maxlength') && newLength > maxLength) {
var delta = text.length - (newLength - maxLength);
text = text.substr(0, delta);
}
_setValue(input, oldValueNorm.substr(0, curPos) + text + oldValueNorm.substr(curPos));
_setCaret(input, curPos + text.length);
};
var _getInputRangeW3 = function(input) {
var range = new Range();
range.start = input.selectionStart;
range.end = input.selectionEnd;
var min = Math.min(range.start, range.end);
var max = Math.max(range.start, range.end);
range.length = max - min;
range.text = _getValue(input).substring(min, max);
return range;
};
/** @see http://stackoverflow.com/a/3648244/467582 */
var _getInputRangeIE = function(input) {
var range = new Range();
input.focus();
var selection = document.selection.createRange();
if (selection && selection.parentElement() === input) {
var len, normalizedValue, textInputRange, endRange, start = 0, end = 0;
var rawValue = _getValue(input);
len = rawValue.length;
normalizedValue = rawValue.replace(/\r\n/g, "\n");
// Create a working TextRange that lives only in the input
textInputRange = input.createTextRange();
textInputRange.moveToBookmark(selection.getBookmark());
// Check if the start and end of the selection are at the very end
// of the input, since moveStart/moveEnd doesn't return what we want
// in those cases
endRange = input.createTextRange();
endRange.collapse(false);
if (textInputRange.compareEndPoints("StartToEnd", endRange) > -1) {
start = end = len;
} else {
start = -textInputRange.moveStart("character", -len);
start += normalizedValue.slice(0, start).split("\n").length - 1;
if (textInputRange.compareEndPoints("EndToEnd", endRange) > -1) {
end = len;
} else {
end = -textInputRange.moveEnd("character", -len);
end += normalizedValue.slice(0, end).split("\n").length - 1;
}
}
/// normalize newlines
start -= (rawValue.substring(0, start).split('\r\n').length - 1);
end -= (rawValue.substring(0, end).split('\r\n').length - 1);
/// normalize newlines
range.start = start;
range.end = end;
range.length = range.end - range.start;
range.text = normalizedValue.substr(range.start, range.length);
}
return range;
};
/**
* Gets the selected text range of the given input.
* @param {HTMLInputElement|HTMLTextAreaElement} input input or textarea element
* @returns {Range}
* @see http://stackoverflow.com/a/263796/467582
* @see http://stackoverflow.com/a/2966703/467582
*/
var _getInputRange = function(input) {
if (!input) {
return undefined;
}
// Mozilla, et al.
if (_support.setSelectionRange) {
return _getInputRangeW3(input);
}
// IE
else if (_support.createTextRange) {
return _getInputRangeIE(input);
}
return undefined;
};
var _setInputRangeW3 = function(input, startPos, endPos) {
input.setSelectionRange(startPos, endPos);
};
var _setInputRangeIE = function(input, startPos, endPos) {
var tr = input.createTextRange();
tr.moveEnd('textedit', -1);
tr.moveStart('character', startPos);
tr.moveEnd('character', endPos - startPos);
tr.select();
};
/**
* Sets the selected text range of (i.e., highlights text in) the given input.
* @param {HTMLInputElement|HTMLTextAreaElement} input input or textarea element
* @param {Number} startPos Zero-based index
* @param {Number} endPos Zero-based index
* @see http://parentnode.org/javascript/working-with-the-cursor-position/
* @see http://stackoverflow.com/a/2966703/467582
*/
var _setInputRange = function(input, startPos, endPos) {
startPos = _getIndex(input, startPos);
endPos = _getIndex(input, endPos);
// Mozilla, et al.
if (_support.setSelectionRange) {
_setInputRangeW3(input, startPos, endPos);
}
// IE
else if (_support.createTextRange) {
_setInputRangeIE(input, startPos, endPos);
}
};
/**
* Replaces the currently selected text with the given string.
* @param {HTMLInputElement|HTMLTextAreaElement} input input or textarea element
* @param {String} text New text that will replace the currently selected text.
* @see http://parentnode.org/javascript/working-with-the-cursor-position/
*/
var _replaceInputRange = function(input, text) {
var $input = $(input);
var oldValue = $input.val();
var selection = _getInputRange(input);
var newLength = +(selection.start + text.length + (oldValue.length - selection.end));
var maxLength = +$input.attr('maxlength');
if($input.is('[maxlength]') && newLength > maxLength) {
var delta = text.length - (newLength - maxLength);
text = text.substr(0, delta);
}
// Now that we know what the user selected, we can replace it
var startText = oldValue.substr(0, selection.start);
var endText = oldValue.substr(selection.end);
$input.val(startText + text + endText);
// Reset the selection
var startPos = selection.start;
var endPos = startPos + text.length;
_setInputRange(input, selection.length ? startPos : endPos, endPos);
};
var _selectAllW3 = function(elem) {
var selection = window.getSelection();
var range = document.createRange();
range.selectNodeContents(elem);
selection.removeAllRanges();
selection.addRange(range);
};
var _selectAllIE = function(elem) {
var range = document.body.createTextRange();
range.moveToElementText(elem);
range.select();
};
/**
* Select all text in the given element.
* @param {HTMLElement} elem Any block or inline element other than a form element.
*/
var _selectAll = function(elem) {
var $elem = $(elem);
if ($elem.is('input, textarea') || elem.select) {
$elem.select();
return;
}
// Mozilla, et al.
if (_support.setSelectionRange) {
_selectAllW3(elem);
}
// IE
else if (_support.createTextRange) {
_selectAllIE(elem);
}
};
var _deselectAll = function() {
if (document.selection) {
document.selection.empty();
}
else if (window.getSelection) {
window.getSelection().removeAllRanges();
}
};
$.extend($.fn, {
/**
* Gets or sets the position of the caret or inserts text at the current caret position in an input or textarea element.
* @returns {Number|jQuery} The current caret position if invoked as a getter (with no arguments)
* or this jQuery object if invoked as a setter or inserter.
* @see http://web.archive.org/web/20080704185920/http://parentnode.org/javascript/working-with-the-cursor-position/
* @since 1.0.0
* @example
* <pre>
* // Get position
* var pos = $('input:first').caret();
* </pre>
* @example
* <pre>
* // Set position
* $('input:first').caret(15);
* $('input:first').caret(-3);
* </pre>
* @example
* <pre>
* // Insert text at current position
* $('input:first').caret('Some text');
* </pre>
*/
caret: function() {
var $inputs = this.filter('input, textarea');
// getCaret()
if (arguments.length === 0) {
var input = $inputs.get(0);
return _getCaret(input);
}
// setCaret(position)
else if (typeof arguments[0] === 'number') {
var pos = arguments[0];
$inputs.each(function(_i, input) {
_setCaret(input, pos);
});
}
// insertAtCaret(text)
else {
var text = arguments[0];
$inputs.each(function(_i, input) {
_insertAtCaret(input, text);
});
}
return this;
},
/**
* Gets or sets the selection range or replaces the currently selected text in an input or textarea element.
* @returns {Range|jQuery} The current selection range if invoked as a getter (with no arguments)
* or this jQuery object if invoked as a setter or replacer.
* @see http://stackoverflow.com/a/2966703/467582
* @since 1.0.0
* @example
* <pre>
* // Get selection range
* var range = $('input:first').range();
* </pre>
* @example
* <pre>
* // Set selection range
* $('input:first').range(15);
* $('input:first').range(15, 20);
* $('input:first').range(-3);
* $('input:first').range(-8, -3);
* </pre>
* @example
* <pre>
* // Replace the currently selected text
* $('input:first').range('Replacement text');
* </pre>
*/
range: function() {
var $inputs = this.filter('input, textarea');
// getRange() = { start: pos, end: pos }
if (arguments.length === 0) {
var input = $inputs.get(0);
return _getInputRange(input);
}
// setRange(startPos, endPos)
else if (typeof arguments[0] === 'number') {
var startPos = arguments[0];
var endPos = arguments[1];
$inputs.each(function(_i, input) {
_setInputRange(input, startPos, endPos);
});
}
// replaceRange(text)
else {
var text = arguments[0];
$inputs.each(function(_i, input) {
_replaceInputRange(input, text);
});
}
return this;
},
/**
* Selects all text in each element of this jQuery object.
* @returns {jQuery} This jQuery object
* @see http://stackoverflow.com/a/11128179/467582
* @since 1.5.0
* @example
* <pre>
* // Select the contents of span elements when clicked
* $('span').on('click', function() { $(this).highlight(); });
* </pre>
*/
selectAll: function() {
return this.each(function(_i, elem) {
_selectAll(elem);
});
}
});
$.extend($, {
/**
* Deselects all text on the page.
* @returns {jQuery} The jQuery function
* @since 1.5.0
* @example
* <pre>
* // Select some text
* $('span').selectAll();
*
* // Deselect the text
* $.deselectAll();
* </pre>
*/
deselectAll: function() {
_deselectAll();
return this;
}
});
}(window.jQuery || window.Zepto || window.$));

View File

@@ -43,12 +43,10 @@ class ActivityService {
this.data[DECK_ACTIVITY_TYPE_CARD] = {};
this.toEnhanceWithComments = [];
this.commentCollection = new CommentCollection();
this.commentCollection._limit = this.RESULT_PER_PAGE;
this.commentCollection._limit = ActivityService.RESULT_PER_PAGE;
this.commentCollection.on('request', function() {
console.log("REQUEST");
}, this);
this.commentCollection.on('sync', function() {
console.log("SYNC");
this.commentCollection.on('sync', function(a) {
for (let index in this.toEnhanceWithComments) {
let item = this.toEnhanceWithComments[index];
item.commentModel = this.commentCollection.get(item.subject_rich[1].comment);
@@ -61,8 +59,10 @@ class ActivityService {
this.commentCollection.updateReadMarker();
}
}, this);
this.commentCollection.on('add', function(data) {
console.log("ADD");
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: {

View File

@@ -34,7 +34,8 @@
</div>
<span class="activitytime has-tooltip live-relative-timestamp"
data-timestamp="{{ activity.timelineTimestamp }}">{{ activity.timestamp/1000 | relativeDateFilter }}</span>
<div class="activitymessage" ng-bind-html="activity.message"></div>
<div class="activitymessage" ng-if="!activity.commentModel" ng-bind-html="activity.message"></div>
<div class="activitymessage" ng-if="activity.commentModel" bind-html-compile="$ctrl.formatMessage(activity)"></div>
</li>
<li ng-if="$ctrl.loading"><div class="icon-loading-small"></div></li>