Commit cc5a3997 authored by Dennis Schubert's avatar Dennis Schubert

Merge pull request #6728 from svbergerem/mentions-typeahead

Rewrite mentions input using typeahead.js
parents e0d6da7a f556a521
......@@ -90,6 +90,7 @@ Contributions are very welcome, the hard work is done!
* Refactor mobile javascript and add tests [#6394](https://github.com/diaspora/diaspora/pull/6394)
* Dropped `parent_author_signature` from relayables [#6586](https://github.com/diaspora/diaspora/pull/6586)
* Attached ShareVisibilities to the User, not the Contact [#6723](https://github.com/diaspora/diaspora/pull/6723)
* Refactor mentions input, now based on typeahead.js [#6728](https://github.com/diaspora/diaspora/pull/6728)
## Bug fixes
* Destroy Participation when removing interactions with a post [#5852](https://github.com/diaspora/diaspora/pull/5852)
......
......@@ -108,11 +108,9 @@ source "https://rails-assets.org" do
# jQuery plugins
gem "rails-assets-jeresig--jquery.hotkeys", "0.2.0"
gem "rails-assets-jquery-placeholder", "2.3.1"
gem "rails-assets-jquery-textchange", "0.2.3"
gem "rails-assets-perfect-scrollbar", "0.6.10"
gem "rails-assets-jakobmattsson--jquery-elastic", "1.6.11"
gem "rails-assets-autosize", "3.0.15"
gem "rails-assets-blueimp-gallery", "2.17.0"
end
......
......@@ -637,13 +637,9 @@ GEM
rails-assets-jquery.ui (~> 1.11.4)
rails-assets-favico.js (0.3.10)
rails-assets-highlightjs (9.1.0)
rails-assets-jakobmattsson--jquery-elastic (1.6.11)
rails-assets-jquery (>= 1.2.6)
rails-assets-jasmine (2.4.1)
rails-assets-jasmine-ajax (3.2.0)
rails-assets-jasmine (~> 2)
rails-assets-jeresig--jquery.hotkeys (0.2.0)
rails-assets-jquery (>= 1.4.2)
rails-assets-jquery (1.12.0)
rails-assets-jquery-colorbox (1.6.3)
rails-assets-jquery (>= 1.3.2)
......@@ -989,9 +985,7 @@ DEPENDENCIES
rails-assets-blueimp-gallery (= 2.17.0)!
rails-assets-diaspora_jsxc (~> 0.1.5.develop)!
rails-assets-highlightjs (= 9.1.0)!
rails-assets-jakobmattsson--jquery-elastic (= 1.6.11)!
rails-assets-jasmine-ajax (= 3.2.0)!
rails-assets-jeresig--jquery.hotkeys (= 0.2.0)!
rails-assets-jquery (= 1.12.0)!
rails-assets-jquery-placeholder (= 2.3.1)!
rails-assets-jquery-textchange (= 0.2.3)!
......
......@@ -180,8 +180,10 @@ app.Router = Backbone.Router.extend({
}
app.page = new app.views.Stream({model : app.stream});
app.publisher = app.publisher || new app.views.Publisher({collection : app.stream.items});
app.shortcuts = app.shortcuts || new app.views.StreamShortcuts({el: $(document)});
if($("#publisher").length !== 0) {
app.publisher = app.publisher || new app.views.Publisher({collection : app.stream.items});
}
$("#main_stream").html(app.page.render().el);
this._hideInactiveStreamLists();
......
......@@ -32,7 +32,7 @@ app.views.AspectCreate = app.views.Base.extend({
},
inputKeypress: function(evt) {
if(evt.which === 13) {
if(evt.which === Keycodes.ENTER) {
evt.preventDefault();
this.createAspect();
}
......
......@@ -56,7 +56,7 @@ app.views.CommentStream = app.views.Base.extend({
},
keyDownOnCommentBox: function(evt) {
if(evt.keyCode === 13 && evt.ctrlKey) {
if(evt.which === Keycodes.ENTER && evt.ctrlKey) {
this.$("form").submit();
return false;
}
......
......@@ -43,7 +43,7 @@ app.views.ConversationsForm = Backbone.View.extend({
},
keyDown : function(evt) {
if( evt.keyCode === 13 && evt.ctrlKey ) {
if(evt.which === Keycodes.ENTER && evt.ctrlKey) {
$(evt.target).parents("form").submit();
}
}
......
......@@ -50,7 +50,7 @@ app.views.Conversations = Backbone.View.extend({
},
keyDown : function(evt) {
if( evt.keyCode === 13 && evt.ctrlKey ) {
if(evt.which === Keycodes.ENTER && evt.ctrlKey) {
$(evt.target).parents("form").submit();
}
}
......
//= require ../search_base_view
app.views.PublisherMention = app.views.SearchBase.extend({
triggerChar: "@",
invisibleChar: "\u200B", // zero width space
mentionRegex: /@([^@\s]+)$/,
templates: {
mentionItemSyntax: _.template("@{<%= name %> ; <%= handle %>}"),
mentionItemHighlight: _.template("<strong><span><%= name %></span></strong>")
},
events: {
"keydown #status_message_fake_text": "onInputBoxKeyDown",
"input #status_message_fake_text": "onInputBoxInput",
"click #status_message_fake_text": "onInputBoxClick",
"blur #status_message_fake_text": "onInputBoxBlur",
},
initialize: function() {
this.mentionedPeople = [];
// contains the 'fake text' displayed to the user
// also has a data-messageText attribute with the original text
this.inputBox = this.$("#status_message_fake_text");
// contains the mentions displayed to the user
this.mentionsBox = this.$(".mentions-box");
this.typeaheadInput = this.$(".typeahead-mention-box");
this.bindTypeaheadEvents();
app.views.SearchBase.prototype.initialize.call(this, {
typeaheadInput: this.typeaheadInput,
customSearch: true,
autoselect: true
});
},
bindTypeaheadEvents: function() {
var self = this;
// Process mention when the user selects a result.
this.typeaheadInput.on("typeahead:select", function(evt, person) { self.onSuggestionSelection(person); });
},
addPersonToMentions: function(person) {
if(!(person && person.name && person.handle)) { return; }
// This is needed for processing preview
/* jshint camelcase: false */
person.diaspora_id = person.handle;
/* jshint camelcase: true */
this.mentionedPeople.push(person);
this.ignorePersonForSuggestions(person);
},
cleanMentionedPeople: function() {
var inputText = this.inputBox.val();
this.mentionedPeople = this.mentionedPeople.filter(function(person) {
return person.name && inputText.indexOf(person.name) > -1;
});
this.ignoreDiasporaIds = this.mentionedPeople.map(function(person) { return person.handle; });
},
onSuggestionSelection: function(person) {
var messageText = this.inputBox.val();
var caretPosition = this.inputBox[0].selectionStart;
var triggerCharPosition = messageText.lastIndexOf(this.triggerChar, caretPosition);
if(triggerCharPosition === -1) { return; }
this.addPersonToMentions(person);
this.closeSuggestions();
messageText = messageText.substring(0, triggerCharPosition) +
this.invisibleChar + person.name + messageText.substring(caretPosition);
this.inputBox.val(messageText);
this.updateMessageTexts();
this.inputBox.focus();
var newCaretPosition = triggerCharPosition + person.name.length + 1;
this.inputBox[0].setSelectionRange(newCaretPosition, newCaretPosition);
},
/**
* Replaces every combination of this.invisibleChar + mention.name by the
* correct syntax for both hidden text and visible one.
*
* For instance, the text "Hello \u200Buser1" will be tranformed to
* "Hello @{user1 ; user1@pod.tld}" in the hidden element and
* "Hello <strong><span>user1</span></strong>" in the element visible to the user.
*/
updateMessageTexts: function() {
var fakeMessageText = this.inputBox.val(),
mentionBoxText = fakeMessageText,
messageText = fakeMessageText;
this.mentionedPeople.forEach(function(person) {
var mentionName = this.invisibleChar + person.name;
messageText = messageText.replace(mentionName, this.templates.mentionItemSyntax(person));
var textHighlight = this.templates.mentionItemHighlight({name: _.escape(person.name)});
mentionBoxText = mentionBoxText.replace(mentionName, textHighlight);
}, this);
this.inputBox.data("messageText", messageText);
this.mentionsBox.find(".mentions").html(mentionBoxText);
},
updateTypeaheadInput: function() {
var messageText = this.inputBox.val();
var caretPosition = this.inputBox[0].selectionStart;
var result = this.mentionRegex.exec(messageText.substring(0,caretPosition));
if(result === null) {
this.closeSuggestions();
return;
}
// result[1] is the string between the last '@' and the current caret position
this.typeaheadInput.typeahead("val", result[1]);
this.typeaheadInput.typeahead("open");
},
/**
* Let us prefill the publisher with a mention list
* @param persons List of people to mention in a post;
* JSON object of form { handle: <diaspora handle>, name: <name>, ... }
*/
prefillMention: function(persons) {
persons.forEach(function(person) {
this.addPersonToMentions(person);
var text = this.invisibleChar + person.name;
if(this.inputBox.val().length !== 0) {
text = this.inputBox.val() + " " + text;
}
this.inputBox.val(text);
this.updateMessageTexts();
}, this);
},
/**
* Selects next or previous result when result dropdown is open and
* user press up and down arrows.
*/
onArrowKeyDown: function(e) {
if(!this.isVisible() || (e.which !== Keycodes.UP && e.which !== Keycodes.DOWN)) {
return;
}
e.preventDefault();
e.stopPropagation();
this.typeaheadInput.typeahead("activate");
this.typeaheadInput.typeahead("open");
this.typeaheadInput.trigger($.Event("keydown", {keyCode: e.keyCode, which: e.which}));
},
/**
* Listens for user input and opens results dropdown when input contains the trigger char
*/
onInputBoxInput: function() {
this.cleanMentionedPeople();
this.updateMessageTexts();
this.updateTypeaheadInput();
},
onInputBoxKeyDown: function(e) {
// This also matches HOME/END on OSX which is CMD+LEFT, CMD+RIGHT
if(e.which === Keycodes.LEFT || e.which === Keycodes.RIGHT ||
e.which === Keycodes.HOME || e.which === Keycodes.END) {
_.defer(_.bind(this.updateTypeaheadInput, this));
return;
}
if(!this.isVisible) {
return true;
}
switch(e.which) {
case Keycodes.ESC:
case Keycodes.SPACE:
this.closeSuggestions();
break;
case Keycodes.UP:
case Keycodes.DOWN:
this.onArrowKeyDown(e);
break;
case Keycodes.RETURN:
case Keycodes.TAB:
if(this.$(".tt-cursor").length === 1) {
this.$(".tt-cursor").click();
return false;
}
break;
}
return true;
},
onInputBoxClick: function() {
this.updateTypeaheadInput();
},
onInputBoxBlur: function() {
this.closeSuggestions();
},
reset: function() {
this.inputBox.val("");
this.onInputBoxInput();
},
closeSuggestions: function() {
this.typeaheadInput.typeahead("val", "");
this.typeaheadInput.typeahead("close");
},
isVisible: function() {
return this.$(".tt-menu").is(":visible");
},
getTextForSubmit: function() {
return this.mentionedPeople.length ? this.inputBox.data("messageText") : this.inputBox.val();
}
});
......@@ -5,9 +5,11 @@
* the COPYRIGHT file.
*/
//= require ./publisher/services_view
//= require ./publisher/aspect_selector_view
//= require ./publisher/getting_started_view
//= require ./publisher/mention_view
//= require ./publisher/poll_creator_view
//= require ./publisher/services_view
//= require ./publisher/uploader_view
//= require jquery-textchange
......@@ -31,6 +33,7 @@ app.views.Publisher = Backbone.View.extend({
initialize : function(opts){
this.standalone = opts ? opts.standalone : false;
this.prefillMention = opts && opts.prefillMention ? opts.prefillMention : undefined;
this.disabled = false;
// init shortcut references to the various elements
......@@ -41,9 +44,6 @@ app.views.Publisher = Backbone.View.extend({
this.previewEl = this.$("button.post_preview_button");
this.photozoneEl = this.$("#photodropzone");
// init mentions plugin
Mentions.initialize(this.inputEl);
// if there is data in the publisher we ask for a confirmation
// before the user is able to leave the page
$(window).on("beforeunload", _.bind(this._beforeUnload, this));
......@@ -100,6 +100,11 @@ app.views.Publisher = Backbone.View.extend({
},
initSubviews: function() {
this.mention = new app.views.PublisherMention({ el: this.$("#publisher_textarea_wrapper") });
if(this.prefillMention) {
this.mention.prefillMention([this.prefillMention]);
}
var form = this.$(".content_creation form");
this.view_services = new app.views.PublisherServices({
......@@ -222,8 +227,8 @@ app.views.Publisher = Backbone.View.extend({
// creates the location
showLocation: function(){
if($("#location").length === 0){
$("#location_container").append("<div id=\"location\"></div>");
this.wrapperEl.addClass("with_location");
this.$(".location-container").append("<div id=\"location\"></div>");
this.wrapperEl.addClass("with-location");
this.view_locator = new app.views.Location();
}
},
......@@ -232,7 +237,7 @@ app.views.Publisher = Backbone.View.extend({
destroyLocation: function(){
if(this.view_locator){
this.view_locator.remove();
this.wrapperEl.removeClass("with_location");
this.wrapperEl.removeClass("with-location");
delete this.view_locator;
}
},
......@@ -244,8 +249,9 @@ app.views.Publisher = Backbone.View.extend({
// avoid submitting form when pressing Enter key
avoidEnter: function(evt){
if(evt.keyCode === 13)
if(evt.which === Keycodes.ENTER) {
return false;
}
},
getUploadedPhotos: function() {
......@@ -265,32 +271,6 @@ app.views.Publisher = Backbone.View.extend({
return photos;
},
getMentionedPeople: function(serializedForm) {
var mentionedPeople = [],
regexp = /@{([^;]+); ([^}]+)}/g,
user;
var getMentionedUser = function(handle) {
return Mentions.contacts.filter(function(user) {
return user.handle === handle;
})[0];
};
while( (user = regexp.exec(serializedForm["status_message[text]"])) ) {
// user[1]: name, user[2]: handle
var mentionedUser = getMentionedUser(user[2]);
if(mentionedUser){
mentionedPeople.push({
"id": mentionedUser.id,
"guid": mentionedUser.guid,
"name": user[1],
"diaspora_id": user[2],
"avatar": mentionedUser.avatar
});
}
}
return mentionedPeople;
},
getPollData: function(serializedForm) {
var poll;
var pollQuestion = serializedForm.poll_question;
......@@ -321,7 +301,7 @@ app.views.Publisher = Backbone.View.extend({
var serializedForm = $(evt.target).closest("form").serializeObject();
var photos = this.getUploadedPhotos();
var mentionedPeople = this.getMentionedPeople(serializedForm);
var mentionedPeople = this.mention.mentionedPeople;
var date = (new Date()).toISOString();
var poll = this.getPollData(serializedForm);
var locationCoords = serializedForm["location[coords]"];
......@@ -379,7 +359,7 @@ app.views.Publisher = Backbone.View.extend({
},
keyDown : function(evt) {
if( evt.keyCode === 13 && evt.ctrlKey ) {
if(evt.which === Keycodes.ENTER && evt.ctrlKey) {
this.$("form").submit();
this.open();
return false;
......@@ -387,6 +367,9 @@ app.views.Publisher = Backbone.View.extend({
},
clear : function() {
// remove mentions
this.mention.reset();
// clear text(s)
this.inputEl.val("");
this.hiddenInputEl.val("");
......@@ -394,9 +377,6 @@ app.views.Publisher = Backbone.View.extend({
.trigger("keydown");
autosize.update(this.inputEl);
// remove mentions
this.inputEl.mentionsInput("reset");
// remove photos
this.photozoneEl.find("li").remove();
this.$("input[name='photos[]']").remove();
......@@ -450,9 +430,6 @@ app.views.Publisher = Backbone.View.extend({
this.$el.removeClass("closed");
this.wrapperEl.addClass("active");
autosize.update(this.inputEl);
// fetch contacts for mentioning
Mentions.fetchContacts();
return this;
},
......@@ -521,9 +498,7 @@ app.views.Publisher = Backbone.View.extend({
var self = this;
this.checkSubmitAvailability();
this.inputEl.mentionsInput("val", function(value){
self.hiddenInputEl.val(value);
});
this.hiddenInputEl.val(this.mention.getTextForSubmit());
},
_beforeUnload: function(e) {
......
app.views.SearchBase = app.views.Base.extend({
initialize: function(options) {
this.ignoreDiasporaIds = [];
this.typeaheadInput = options.typeaheadInput;
this.setupBloodhound(options);
if(options.customSearch) { this.setupCustomSearch(); }
this.setupTypeahead();
// TODO: Remove this as soon as corejavascript/typeahead.js has its first release
this.setupMouseSelectionEvents();
if(options.autoselect) { this.setupAutoselect(); }
},
setupBloodhound: function(options) {
var bloodhoundOptions = {
datumTokenizer: function(datum) {
var nameTokens = Bloodhound.tokenizers.nonword(datum.name);
var handleTokens = datum.handle ? Bloodhound.tokenizers.nonword(datum.name) : [];
return nameTokens.concat(handleTokens);
},
queryTokenizer: Bloodhound.tokenizers.whitespace,
prefetch: {
url: "/contacts.json",
transform: this.transformBloodhoundResponse,
cache: false
},
sufficient: 5
};
// Allow bloodhound to look for remote results if there is a route given in the options
if(options.remoteRoute) {
bloodhoundOptions.remote = {
url: options.remoteRoute + ".json?q=%QUERY",
wildcard: "%QUERY",
transform: this.transformBloodhoundResponse
};
}
this.bloodhound = new Bloodhound(bloodhoundOptions);
},
setupCustomSearch: function() {
var self = this;
this.bloodhound.customSearch = function(query, sync, async) {
var _sync = function(datums) {
var results = datums.filter(function(datum) {
return datum.handle !== undefined && self.ignoreDiasporaIds.indexOf(datum.handle) === -1;
});
sync(results);
};
self.bloodhound.search(query, _sync, async);
};
},
setupTypeahead: function() {
this.typeaheadInput.typeahead({
hint: false,
highlight: true,
minLength: 2
},
{
name: "search",
display: "name",
limit: 5,
source: this.bloodhound.customSearch !== undefined ? this.bloodhound.customSearch : this.bloodhound,
templates: {
/* jshint camelcase: false */
suggestion: HandlebarsTemplates.search_suggestion_tpl
/* jshint camelcase: true */
}
});
},
transformBloodhoundResponse: function(response) {
return response.map(function(data) {
// person
if(data.handle) {
data.person = true;
return data;
}
// hashtag
return {
hashtag: true,
name: data.name,
url: Routes.tag(data.name.substring(1))
};
});
},
_deselectAllSuggestions: function() {
this.$(".tt-suggestion").removeClass("tt-cursor");
},
_selectSuggestion: function(suggestion) {
this._deselectAllSuggestions();
suggestion.addClass("tt-cursor");
},
// TODO: Remove this as soon as corejavascript/typeahead.js has its first release
setupMouseSelectionEvents: function() {
var self = this,
selectSuggestion = function(e) { self._selectSuggestion($(e.target).closest(".tt-suggestion")); },
deselectAllSuggestions = function() { self._deselectAllSuggestions(); };
this.typeaheadInput.on("typeahead:render", function() {
self.$(".tt-menu .tt-suggestion").off("mouseover").on("mouseover", selectSuggestion);
self.$(".tt-menu .tt-suggestion *").off("mouseover").on("mouseover", selectSuggestion);
self.$(".tt-menu .tt-suggestion").off("mouseleave").on("mouseleave", deselectAllSuggestions);
});
},
// Selects the first result when the result dropdown opens
setupAutoselect: function() {
var self = this;
this.typeaheadInput.on("typeahead:render", function() {
self._selectSuggestion(self.$(".tt-menu .tt-suggestion").first());
});
},
ignorePersonForSuggestions: function(person) {
if(person.handle) { this.ignoreDiasporaIds.push(person.handle); }
},
});
// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-v3-or-Later
app.views.Search = app.views.Base.extend({
app.views.Search = app.views.SearchBase.extend({
events: {
"focusin #q": "toggleSearchActive",
"focusout #q": "toggleSearchActive",
"keypress #q": "inputKeypress",
"keypress #q": "inputKeypress"
},
initialize: function(){
this.searchFormAction = this.$el.attr("action");
initialize: function() {
this.searchInput = this.$("#q");
// constructs the suggestion engine
this.setupBloodhound();
this.setupTypeahead();
this.searchInput.on("typeahead:select", this.suggestionSelected);
},
setupBloodhound: function() {
this.bloodhound = new Bloodhound({
datumTokenizer: function(datum) {
var nameTokens = Bloodhound.tokenizers.nonword(datum.name);
var handleTokens = datum.handle ? Bloodhound.tokenizers.nonword(datum.name) : [];
return nameTokens.concat(handleTokens);
},
queryTokenizer: Bloodhound.tokenizers.whitespace,
remote: {
url: this.searchFormAction + ".json?q=%QUERY",
wildcard: "%QUERY",
transform: this.transformBloodhoundResponse
},
prefetch: {
url: "/contacts.json",
transform: this.transformBloodhoundResponse,
cache: false
},
sufficient: 5
});
},
setupTypeahead: function() {
this.searchInput.typeahead({
hint: false,
highlight: true,
minLength: 2
},
{
name: "search",
display: "name",
limit: 5,
source: this.bloodhound,
templates: {
/* jshint camelcase: false */
suggestion: HandlebarsTemplates.search_suggestion_tpl
/* jshint camelcase: true */
}
});
},
transformBloodhoundResponse: function(response) {
var result = response.map(function(data) {
// person
if(data.handle) {
data.person = true;
return data;
}
// hashtag
return {
hashtag: true,
name: data.name,
url: Routes.tag(data.name.substring(1))
};
app.views.SearchBase.prototype.initialize.call(this, {
typeaheadInput: this.searchInput,
remoteRoute: this.$el.attr("act