Add mentions

pull/1/head
yflory 5 years ago
parent 2608b0f66e
commit 7309ae1b23

@ -9,7 +9,7 @@
@alertify-input-bg: @colortheme_modal-input;
@alertify-input-fg: @colortheme_modal-input-fg;
input:not(.form-control), textarea {
input:not(.form-control), textarea, div.cp-textarea {
// background-color: @alertify-input-fg;
color: @cryptpad_text_col;
border: 1px solid @alertify-input-bg;
@ -44,13 +44,24 @@
}
}
textarea {
textarea, div.cp-textarea {
padding: 8px;
&[readonly] {
overflow: hidden;
resize: none;
}
}
div.cp-textarea {
height: 60px;
width: 100%;
background-color: white;
cursor: text;
outline: none;
white-space: pre-wrap;
overflow-y: auto;
word-break: break-word;
resize: vertical;
}
div.cp-button-confirm {
display: inline-block;

@ -38,6 +38,9 @@
}
}
#cp-comments-label {
display: none;
}
.cp-comment-container {
outline: none;

@ -1,11 +1,30 @@
@import (reference) "./colortheme-all.less";
@import (reference) "./tools.less";
@import (reference) "./avatar.less";
/* The container <div> - needed to position the dropdown content */
.dropdown_main () {
--LessLoader_require: LessLoader_currentFile();
}
& {
.cp-autocomplete-value {
display: flex;
align-items: center;
.cp-avatar {
.avatar_main(30px);
padding: 1px;
}
& > span:last-child {
flex: 1;
height: 32px;
line-height: 32px;
padding: 0 10px;
}
span {
margin: 0px !important;
border: none !important;
}
}
.cp-dropdown-container {
@dropdown_font: @colortheme_app-font-size @colortheme_font;
position: relative;

@ -15,6 +15,7 @@
@import (reference) "./messenger.less";
@import (reference) "./cursor.less";
@import (reference) "./usergrid.less";
@import (reference) "./mentions.less";
@import (reference) "./modals-ui-elements.less";
.framework_main(@bg-color, @warn-color, @color) {
@ -44,6 +45,7 @@
.messenger_main();
.cursor_main();
.usergrid_main();
.mentions_main();
.creation_main(
@bg-color: @bg-color,
@color: @color

@ -3666,5 +3666,259 @@ define([
UI.proposal(div, todo);
};
var insertTextAtCursor = function (text) {
var selection = window.getSelection();
var range = selection.getRangeAt(0);
range.deleteContents();
var node = document.createTextNode(text);
range.insertNode(node);
for (var position = 0; position != text.length; position++) {
selection.modify("move", "right", "character");
};
}
var getSource = {};
getSource['contacts'] = function (common, sources) {
var priv = common.getMetadataMgr().getPrivateData();
Object.keys(priv.friends || {}).forEach(function (key) {
if (key === 'me') { return; }
var f = priv.friends[key];
if (!f.curvePublic || sources[f.curvePublic]) { return; }
sources[f.curvePublic] = {
avatar: f.avatar,
name: f.displayName,
curvePublic: f.curvePublic,
profile: f.profile,
notifications: f.notifications
};
});
};
UIElements.addMentions = function (common, options) {
if (!options.$input) { return; }
var $t = options.$input;
var getValue = function () { return $t.val(); };
var setValue = function (val) { $t.val(val); };
var div = false;
if (options.contenteditable) {
div = true;
getValue = function () { return $t.html(); };
setValue = function () {}; // Not used, we insert data at the node level
$t.on('paste', function (e) {
try {
insertTextAtCursor(e.originalEvent.clipboardData.getData('text'));
e.preventDefault();
} catch (e) { console.error(e); }
});
// Fix backspace with "contenteditable false" children
$t.on('keydown', function (e) {
if (e.which !== 8 && e.which !== 46) { return; } // Backspace or del
var sel = document.getSelection();
if (sel.anchorNode.nodeType !== Node.TEXT_NODE) { return; } // text nodes only
// Only fix node located after mentions
var n = sel.anchorNode;
var prev = n && n.previousSibling;
// Check if our caret is just after a mention
if (!prev || !prev.classList || !prev.classList.contains('cp-mentions')) { return; }
// Del: if we're at offset 0, make sure we won't delete the text node
if (e.which === 46) {
if (!sel.anchorOffset && sel.anchorNode.length === 1) {
sel.anchorNode.nodeValue = " ";
e.preventDefault();
}
return;
}
// Backspace
// If we're not at offset 0, make sure we won't delete the text node
if (e.which === 8 && sel.anchorOffset) {
if (sel.anchorNode.length === 1) {
sel.anchorNode.nodeValue = " ";
e.preventDefault();
}
return;
}
// If we're at offset 0, We're just after a mention: delete it
prev.parentElement.removeChild(prev);
e.preventDefault();
});
}
// Add the sources
// NOTE: Sources must have a "name". They can have an "avatar".
var sources = options.sources || {};
if (!getSource[options.type]) { return; }
getSource[options.type](common, sources);
// Sort autocomplete result by label
var sort = function (a, b) {
var _a = a.label.toLowerCase();
var _b = b.label.toLowerCase();
if (_a.label < _b.label) { return -1; }
if (_b.label < _a.label) { return 1; }
return 0;
};
// Get the text between the last @ before the cursor and the cursor
var extractLast = function (term, offset) {
var offset = typeof(offset) !== "undefined" ? offset : $t[0].selectionStart;
var startOffset = term.slice(0,offset).lastIndexOf('@');
return term.slice(startOffset+1, offset);
};
// Insert the autocomplete value in the input field
var insertValue = function (value, offset, content) {
var offset = typeof(offset) !== "undefined" ? offset : $t[0].selectionStart;
var content = content || getValue();
var startOffset = content.slice(0,offset).lastIndexOf('@');
var length = offset - startOffset;
if (length <= 0) { return; }
var result = content.slice(0,startOffset) + value + content.slice(offset);
if (content) {
return {
result: result,
startOffset: startOffset
};
}
setValue(result);
};
// Set the value to receive from the autocomplete
var toInsert = function (data, key) {
var name = data.name.replace(/[^a-zA-Z0-9]+/g, "-");
return "[@"+name+"|"+key+"]";
};
// Fix the functions when suing a contenteditable div
if (div) {
var _extractLast = extractLast;
// Use getSelection to get the cursor position in contenteditable
extractLast = function () {
var val = getValue();
var sel = document.getSelection();
if (sel.anchorNode.nodeType !== Node.TEXT_NODE) { return; }
return _extractLast(sel.anchorNode.nodeValue, sel.anchorOffset);
};
var _insertValue = insertValue;
insertValue = function (value) {
// Get the selected node
var sel = document.getSelection();
if (sel.anchorNode.nodeType !== Node.TEXT_NODE) { return; }
var node = sel.anchorNode;
// Remove the "term"
var insert =_insertValue("", sel.anchorOffset, node.nodeValue);
if (insert) {
node.nodeValue = insert.result;
}
var breakAt = insert ? insert.startOffset : sel.anchorOffset;
var el;
if (typeof(value) === "string") { el = document.createTextNode(value); }
else { el = value; }
node.parentNode.insertBefore(el, node.splitText(breakAt));
var next = el.nextSibling;
if (!next) {
next = document.createTextNode(" ");
el.parentNode.appendChild(next);
} else if (next.nodeType === Node.TEXT_NODE && !next.nodeValue) {
next.nodeValue = " ";
}
var range = document.createRange();
range.setStart(next, 0);
range.setEnd(next, 0);
var sel = window.getSelection();
sel.removeAllRanges();
sel.addRange(range);
};
// Inserting contacts into contenteditable: use mention UI
if (options.type === "contacts") {
toInsert = function (data, key) {
var avatar = h('span.cp-avatar', {
contenteditable: false
});
common.displayAvatar($(avatar), data.avatar, data.name);
return h('span.cp-mentions', {
'data-curve': data.curvePublic,
'data-notifications': data.notifications,
'data-profile': data.profile,
'data-name': Util.fixHTML(data.name),
'data-avatar': data.avatar || "",
}, [
avatar,
h('span.cp-mentions-name', {
contenteditable: false
}, data.name)
]);
};
}
}
// don't navigate away from the field on tab when selecting an item
$t.on("keydown", function(e) {
// Tab or enter
if ((e.which === 13 || e.which === 9)) {
try {
var visible = $t.autocomplete("instance").menu.activeMenu.is(':visible');
if (visible) {
e.preventDefault();
e.stopPropagation();
}
} catch (e) {}
}
}).autocomplete({
minLength: 0,
source: function(data, cb) {
var term = data.term;
var results = [];
if (term.indexOf("@") >= 0) {
term = extractLast(data.term) || '';
results = Object.keys(sources).filter(function (key) {
var data = sources[key];
return data.name.toLowerCase().indexOf(term.toLowerCase()) !== -1;
}).map(function (key) {
var data = sources[key];
return {
label: data.name,
value: key
};
});
results.sort(sort);
}
cb(results);
},
focus: function() {
// prevent value inserted on focus
return false;
},
select: function(event, ui) {
// add the selected item
var key = ui.item.value;
var data = sources[key];
var value = toInsert(data, key);
insertValue(value);
return false;
}
}).autocomplete( "instance" )._renderItem = function( ul, item ) {
var key = item.value;
var obj = sources[key];
if (!obj) { return; }
var avatar = h('span.cp-avatar');
common.displayAvatar($(avatar), obj.avatar, obj.name);
var li = h('li.cp-autocomplete-value', [
avatar,
h('span', obj.name)
]);
return $(li).appendTo(ul);
};
};
return UIElements;
});

@ -341,6 +341,28 @@ define([
}
};
Messages.mentions_notification = '{0} has mentionned you in <b>{1}</b>'; // XXX
handlers['MENTION'] = function (common, data) {
var content = data.content;
var msg = content.msg;
// Display the notification
var name = Util.fixHTML(msg.content.user.displayName) || Messages.anonymous;
var title = Util.fixHTML(msg.content.title);
var href = msg.content.href;
content.getFormatText = function () {
return Messages._getKey('mentions_notification', [name, title]);
};
content.handler = function () {
common.openURL(href);
defaultDismiss(common, data)();
};
if (!content.archived) {
content.dismissHandler = defaultDismiss(common, data);
}
};
// NOTE: don't forget to fixHTML everything returned by "getFormatText"

@ -583,6 +583,57 @@ define([
}
};
// Hide duplicates when receiving a SHARE_PAD notification:
// Keep only one notification per channel: the stronger and more recent one
var mentions = {};
handlers['MENTION'] = function (ctx, box, data, cb) {
var msg = data.msg;
var hash = data.hash;
var content = msg.content;
if (isMuted(ctx, data)) { return void cb(true); }
var channel = content.channel;
if (!channel) { return void cb(true); }
var res = ctx.store.manager.findChannel(channel);
if (!res.length) { return void cb(true); }
var title, href;
// Check if the pad is in our drive
if (!res.some(function (obj) {
if (!obj.data) { return; }
href = obj.data.href || obj.data.roHref; // XXX send the href when we mention?
title = obj.data.filename || obj.data.title;
return true;
})) { return void cb(true); }
// Add the title
content.href = href;
content.title = title;
// Remove duplicates
var old = mentions[channel];
var toRemove = old ? old.data : undefined;
// Update the data
mentions[channel] = {
data: {
type: box.type,
hash: hash
}
};
cb(false, toRemove);
};
removeHandlers['MENTION'] = function (ctx, box, data, hash) {
var content = data.content;
var channel = content.channel;
var old = mentions[channel];
if (old && old.data && old.data.hash === hash) {
delete mentions[channel];
}
};
return {
add: function (ctx, box, data, cb) {

@ -103,6 +103,7 @@ define([
funcs.getBurnAfterReadingWarning = callWithCommon(UIElements.getBurnAfterReadingWarning);
funcs.createNewPadModal = callWithCommon(UIElements.createNewPadModal);
funcs.onServerError = callWithCommon(UIElements.onServerError);
funcs.addMentions = callWithCommon(UIElements.addMentions);
funcs.importMediaTagMenu = callWithCommon(MT.importMediaTagMenu);
funcs.getMediaTagPreview = callWithCommon(MT.getMediaTagPreview);

@ -130,17 +130,35 @@ define([
};
var cleanMentions = function ($el, full) {
$el.html('');
var el = $el[0];
var allowed = full ? ['data-profile', 'data-name', 'data-avatar', 'class']
: ['class'];
// Remove unnecessary/unsafe attributes
for (var i=el.attributes.length-1; i>0; i--) {
var name = el.attributes[i] && el.attributes[i].name;
if (allowed.indexOf(name) === -1) {
$el.removeAttr(name);
}
}
};
Messages.comments_submit = "Submit"; // XXX
Messages.comments_reply = "Reply"; // XXX
Messages.comments_resolve = "Resolve"; // XXX
var getCommentForm = function (Env, reply, _cb) {
var cb = Util.once(_cb);
var userData = Env.metadataMgr.getUserData();
var name = Util.fixHTML(userData.name || Messages.anonymous);
var avatar = h('span.cp-avatar');
var textarea = h('textarea', {
tabindex: 1
var textarea = h('div.cp-textarea', {
tabindex: 1,
role: 'textbox',
'aria-multiline': true,
'aria-labelledby': 'cp-comments-label',
'aria-required': true,
contenteditable: true,
});
Env.common.displayAvatar($(avatar), userData.avatar, name);
@ -157,25 +175,78 @@ define([
Messages.comments_submit
]);
// List of allowed attributes in mentions
$(submit).click(function (e) {
e.stopPropagation();
cb(textarea.value);
var clone = textarea.cloneNode(true);
var notify = {};
var $clone = $(clone);
$clone.find('span.cp-mentions').each(function (i, el) {
var $el = $(el);
var curve = $el.attr('data-curve');
var notif = $el.attr('data-notifications');
cleanMentions($el, true);
if (!curve || !notif) { return; }
notify[curve] = notif;
});
$clone.find('> *:not(.cp-mentions)').remove();
var content = clone.innerHTML.trim();
if (!content) { return; }
// Send notification
var privateData = Env.metadataMgr.getPrivateData();
Object.keys(notify).forEach(function (curve) {
Env.common.mailbox.sendTo("MENTION", {
channel: privateData.channel,
}, {
channel: notify[curve],
curvePublic: curve
});
});
// Push the content
cb(content);
});
$(cancel).click(function (e) {
e.stopPropagation();
cb();
});
$(textarea).keydown(function (e) {
var $text = $(textarea).keydown(function (e) {
e.stopPropagation();
if (e.which === 27) {
$(cancel).click();
}
if (e.which === 13 && !e.shiftKey) {
// Submit form on Enter is the autocompelte menu is not visible
try {
var visible = $text.autocomplete("instance").menu.activeMenu.is(':visible');
if (visible) { return; }
} catch (e) {}
$(submit).click();
e.preventDefault();
}
}).click(function (e) {
e.stopPropagation();
});
if (Env.common.isLoggedIn()) {
var authors = {};
Object.keys((Env.comments && Env.comments.authors) || {}).forEach(function (id) {
var obj = Util.clone(Env.comments.authors[id]);
authors[obj.curvePublic] = obj;
});
Env.common.addMentions({
$input: $text,
contenteditable: true,
type: 'contacts',
sources: authors
});
}
setTimeout(function () {
$(textarea).focus();
});
@ -206,6 +277,9 @@ define([
Env.$container.html('');
var label = h('label#cp-comments-label', Messages.comments_comment);
Env.$container.append(label);
var show = false;
if ($oldInput && !$oldInput.attr('data-uid')) {
@ -245,6 +319,40 @@ define([
});
}
// Build sanitized html with mentions
var m = h('div.cp-comment-content');
m.innerHTML = msg.m;
var $m = $(m);
$m.find('> *:not(span.cp-mentions)').remove();
$m.find('span.cp-mentions').each(function (i, el) {
var $el = $(el);
var name = $el.attr('data-name');
var avatarUrl = $el.attr('data-avatar');
var profile = $el.attr('data-profile');
if (!name && !avatar && !profile) {
$el.remove();
return;
}
cleanMentions($el);
var avatar = h('span.cp-avatar');
Env.common.displayAvatar($(avatar), avatarUrl, name);
$el.append([
avatar,
h('span.cp-mentions-name', name)
]);
if (profile) {
$el.attr('tabindex', 1);
$el.addClass('cp-mentions-clickable').click(function (e) {
e.preventDefault();
e.stopPropagation();
Env.common.openURL(Hash.hashToHref(profile, 'profile'));
}).focus(function (e) {
e.stopPropagation();
});
}
});
// Add the comment
content.push(h('div.cp-comment'+(i === 0 ? '' : '.cp-comment-reply'), [
h('div.cp-comment-header', [
avatar,
@ -253,9 +361,7 @@ define([
h('span.cp-comment-time', date.toLocaleString())
])
]),
h('div.cp-comment-content', [
msg.m
])
m
]));
});
@ -291,9 +397,9 @@ define([
e.stopPropagation();
$actions.hide();
var form = getCommentForm(Env, key, function (val) {
$(form).remove();
$(form).closest('.cp-comment-container')
.find('.cp-comment-actions').css('display', '');
$(form).remove();
if (!val) { return; }
var obj = Env.comments.data[key];

Loading…
Cancel
Save