Commit f2fdaf1d authored by Augier's avatar Augier Committed by Benjamin Neff

Use typeahead on conversations

parent 5269a0d3
......@@ -79,6 +79,9 @@ app.pages.Contacts = Backbone.View.extend({
},
showMessageModal: function(){
$("#conversationModal").on("modal:loaded", function() {
new app.views.ConversationsForm({prefill: gon.conversationPrefill});
});
app.helpers.showModal("#conversationModal");
},
......
......@@ -5,40 +5,83 @@ app.views.ConversationsForm = Backbone.View.extend({
events: {
"keydown .conversation-message-text": "keyDown",
"click .conversation-recipient-tag .remove": "removeRecipient"
},
initialize: function(opts) {
this.contacts = _.has(opts, "contacts") ? opts.contacts : null;
this.prefill = [];
if (_.has(opts, "prefillName") && _.has(opts, "prefillValue")) {
this.prefill = [{name: opts.prefillName, value: opts.prefillValue}];
opts = opts || {};
this.conversationRecipients = [];
this.typeaheadElement = this.$el.find("#contacts-search-input");
this.contactsIdsListInput = this.$el.find("#contact-ids");
this.tagListElement = this.$("#recipients-tag-list");
this.search = new app.views.SearchBase({
el: this.$el.find("#new-conversation"),
typeaheadInput: this.typeaheadElement,
customSearch: true,
autoselect: true,
remoteRoute: {url: "/contacts", extraParameters: "mutual=true"}
});
this.bindTypeaheadEvents();
this.tagListElement.empty();
if (opts.prefill) {
this.prefill(opts.prefill);
}
this.prepareAutocomplete(this.contacts);
this.$("form#new-conversation").on("ajax:success", this.conversationCreateSuccess);
this.$("form#new-conversation").on("ajax:error", this.conversationCreateError);
},
prepareAutocomplete: function(data){
this.$("#contact-autocomplete").autoSuggest(data, {
selectedItemProp: "name",
searchObjProps: "name",
asHtmlID: "contact_ids",
retrieveLimit: 10,
minChars: 1,
keyDelay: 0,
startText: '',
emptyText: Diaspora.I18n.t("no_results"),
preFill: this.prefill
});
$("#contact_ids").attr("aria-labelledby", "toLabel").focus();
addRecipient: function(person) {
this.conversationRecipients.push(person);
this.updateContactIdsListInput();
/* eslint-disable camelcase */
this.tagListElement.append(HandlebarsTemplates.conversation_recipient_tag_tpl(person));
/* eslint-enable camelcase */
},
prefill: function(handles) {
handles.forEach(this.addRecipient.bind(this));
},
updateContactIdsListInput: function() {
this.contactsIdsListInput.val(_(this.conversationRecipients).pluck("id").join(","));
this.search.ignoreDiasporaIds.length = 0;
this.conversationRecipients.forEach(this.search.ignorePersonForSuggestions.bind(this.search));
},
bindTypeaheadEvents: function() {
this.typeaheadElement.on("typeahead:select", function(evt, person) {
this.onSuggestionSelection(person);
}.bind(this));
},
onSuggestionSelection: function(person) {
this.addRecipient(person);
this.typeaheadElement.typeahead("val", "");
},
keyDown : function(evt) {
if(evt.which === Keycodes.ENTER && evt.ctrlKey) {
keyDown: function(evt) {
if (evt.which === Keycodes.ENTER && evt.ctrlKey) {
$(evt.target).parents("form").submit();
}
},
removeRecipient: function(evt) {
var $recipientTagEl = $(evt.target).parents(".conversation-recipient-tag");
var diasporaHandle = $recipientTagEl.data("diaspora-handle");
this.conversationRecipients = this.conversationRecipients.filter(function(person) {
return diasporaHandle.localeCompare(person.handle) !== 0;
});
this.updateContactIdsListInput();
$recipientTagEl.remove();
},
conversationCreateSuccess: function(evt, data) {
app._changeLocation(Routes.conversation(data.id));
},
......
......@@ -9,7 +9,7 @@ app.views.ConversationsInbox = Backbone.View.extend({
},
initialize: function() {
new app.views.ConversationsForm({contacts: gon.contacts});
new app.views.ConversationsForm();
this.setupConversation();
},
......
......@@ -79,8 +79,11 @@ app.views.ProfileHeader = app.views.Base.extend({
},
showMessageModal: function(){
$("#conversationModal").on("modal:loaded", function() {
new app.views.ConversationsForm({prefill: gon.conversationPrefill});
});
app.helpers.showModal("#conversationModal");
},
}
});
// @license-end
......@@ -32,7 +32,7 @@ app.views.PublisherMention = app.views.SearchBase.extend({
typeaheadInput: this.typeaheadInput,
customSearch: true,
autoselect: true,
remoteRoute: "/contacts"
remoteRoute: {url: "/contacts"}
});
},
......
......@@ -28,9 +28,13 @@ app.views.SearchBase = app.views.Base.extend({
};
// Allow bloodhound to look for remote results if there is a route given in the options
if(options.remoteRoute) {
if (options.remoteRoute && options.remoteRoute.url) {
var extraParameters = "";
if (options.remoteRoute.extraParameters) {
extraParameters += "&" + options.remoteRoute.extraParameters;
}
bloodhoundOptions.remote = {
url: options.remoteRoute + ".json?q=%QUERY",
url: options.remoteRoute.url + ".json?q=%QUERY" + extraParameters,
wildcard: "%QUERY",
transform: this.transformBloodhoundResponse.bind(this)
};
......
......@@ -10,7 +10,7 @@ app.views.Search = app.views.SearchBase.extend({
this.searchInput = this.$("#q");
app.views.SearchBase.prototype.initialize.call(this, {
typeaheadInput: this.searchInput,
remoteRoute: this.$el.attr("action"),
remoteRoute: {url: this.$el.attr("action")},
suggestionLink: true
});
this.searchInput.on("typeahead:select", this.suggestionSelected);
......
......@@ -183,10 +183,60 @@
}
// scss-lint:enable SelectorDepth
#new_conversation_pane {
.new-conversation {
ul.as-selections { width: 100% !important; }
input#contact_ids { box-shadow: none; }
label { font-weight: bold; }
.twitter-typeahead,
.tt-menu {
width: 100%;
}
}
.recipients-tag-list {
.conversation-recipient-tag {
background-color: $brand-primary;
border-radius: $btn-border-radius-base;
display: inline-flex;
margin: 0 2px $form-group-margin-bottom;
padding: 8px;
&:first-child { margin-left: 0; }
&:last-child { margin-right: 0; }
div {
align-self: center;
justify-content: flex-start;
}
}
.avatar {
height: 40px;
margin-right: 8px;
width: 40px;
}
.name-and-handle {
color: $white;
margin-right: 8px;
text-align: left;
.diaspora-id { font-size: $font-size-small; }
}
.entypo-circled-cross {
color: $white;
cursor: pointer;
font-size: 20px;
height: 22px;
line-height: 22px;
&:hover { color: $light-grey; }
}
}
.new-conversation.form-horizontal .form-group:last-of-type { margin-bottom: 0; }
......@@ -61,3 +61,5 @@
.subject { padding: 0 10px; }
.message-count, .unread-message-count { margin: 10px 2px; }
.new-conversation .as-selections { background-color: transparent; }
<div class="conversation-recipient-tag clearfix" data-diaspora-handle="{{ handle }}">
<div href="{{ url }}">
<img src="{{ avatar }}" class="avatar img-responsive center-block">
</div>
<div class="pull-left clearfix name-and-handle" href="{{ url }}">
<div class="name">{{ name }}</div>
<div class="diaspora-id">{{ handle }}</div>
</div>
<div class="remove pull-right clearfix">
<i class="entypo-circled-cross"></i>
</div>
</div>
......@@ -17,7 +17,8 @@ class ContactsController < ApplicationController
# Used for mentions in the publisher and pagination on the contacts page
format.json {
@people = if params[:q].present?
Person.search(params[:q], current_user, only_contacts: true).limit(15)
mutual = params[:mutual].present? && params[:mutual]
Person.search(params[:q], current_user, only_contacts: true, mutual: mutual).limit(15)
else
set_up_contacts_json
end
......
......@@ -30,12 +30,12 @@ class ConversationsController < ApplicationController
end
def create
contact_ids = params[:contact_ids]
# Can't split nil
if contact_ids
contact_ids = contact_ids.split(',') if contact_ids.is_a? String
person_ids = current_user.contacts.where(id: contact_ids).pluck(:person_id)
# Contacts autocomplete does not work the same way on mobile and desktop
# Mobile returns contact ids array while desktop returns person id
# This will have to be removed when mobile autocomplete is ported to Typeahead
recipients_param, column = [%i(contact_ids id), %i(person_ids person_id)].find {|param, _| params[param].present? }
if recipients_param
person_ids = current_user.contacts.where(column => params[recipients_param].split(",")).pluck(:person_id)
end
opts = params.require(:conversation).permit(:subject)
......@@ -91,17 +91,23 @@ class ConversationsController < ApplicationController
return
end
@contacts_json = contacts_data.to_json
@contact_ids = ""
if params[:contact_id]
@contact_ids = current_user.contacts.find(params[:contact_id]).id
elsif params[:aspect_id]
@contact_ids = current_user.aspects.find(params[:aspect_id]).contacts.map{|c| c.id}.join(',')
end
if session[:mobile_view] == true && request.format.html?
@contacts_json = contacts_data.to_json
@contact_ids = if params[:contact_id]
current_user.contacts.find(params[:contact_id]).id
elsif params[:aspect_id]
current_user.aspects.find(params[:aspect_id]).contacts.pluck(:id).join(",")
end
render :layout => true
else
if params[:contact_id]
gon.push conversation_prefill: [current_user.contacts.find(params[:contact_id]).person.as_json]
elsif params[:aspect_id]
gon.push conversation_prefill: current_user.aspects
.find(params[:aspect_id]).contacts.map {|c| c.person.as_json }
end
render :layout => false
end
end
......
......@@ -145,7 +145,7 @@ class Person < ActiveRecord::Base
[where_clause, q_tokens]
end
def self.search(search_str, user, only_contacts: false)
def self.search(search_str, user, only_contacts: false, mutual: false)
search_str.strip!
return none if search_str.blank? || search_str.size < 2
......@@ -159,6 +159,8 @@ class Person < ActiveRecord::Base
).searchable(user)
end
query = query.where(contacts: {sharing: true, receiving: true}) if mutual
query.where(closed_account: false)
.where(sql, *tokens)
.includes(:profile)
......
......@@ -34,7 +34,7 @@
.spinner
-if @aspect
#new_conversation_pane
.conversations-form-container#new_conversation_pane
= render 'shared/modal',
:path => new_conversation_path(:aspect_id => @aspect.id, :name => @aspect.name, :modal => true),
:title => t('conversations.index.new_conversation'),
......
......@@ -2,9 +2,13 @@
= form_for Conversation.new, html: {id: "new-conversation",
class: "new-conversation form-horizontal"}, remote: true do |conversation|
.form-group
%label#toLabel{for: "contact_ids"}
= t(".to")
= text_field_tag "contact_autocomplete", nil, id: "contact-autocomplete", class: "form-control"
%label#to-label{for: "contacts-search-input"}= t(".to")
.recipients-tag-list.clearfix#recipients-tag-list
= text_field_tag "contact_autocomplete", nil, id: "contacts-search-input", class: "form-control"
- unless defined?(mobile) && mobile
= text_field_tag "person_ids", nil, id: "contact-ids", type: "hidden",
aria: {labelledby: "to-label"}
.form-group
%label#subject-label{for: "conversation-subject"}
= t(".subject")
......@@ -14,12 +18,14 @@
aria: {labelledby: "subject-label"},
value: "",
placeholder: t("conversations.new.subject_default")
.form-group
%label.sr-only#message-label{for: "new-message-text"} = t(".message")
%label.sr-only#message-label{for: "new-message-text"}= t(".message")
= text_area_tag "conversation[text]", "",
rows: 5,
id: "new-message-text",
class: "conversation-message-text input-block-level form-control",
aria: {labelledby: "message-label"}
.form-group
= conversation.submit t(".send"), "data-disable-with" => t(".sending"), :class => "btn btn-primary pull-right"
:javascript
$(document).ready(function () {
var data = $.parseJSON( "#{escape_javascript(@contacts_json)}" );
new app.views.ConversationsForm({
el: $("form#new-conversation").parent(),
contacts: data,
prefillName: "#{h params[:name]}",
prefillValue: "#{@contact_ids}"
});
});
= include_gon camel_case: true
= render 'conversations/new'
......@@ -6,7 +6,7 @@
:plain
$(document).ready(function () {
var data = $.parseJSON( "#{escape_javascript(@contacts_json).html_safe}" ),
autocompleteInput = $("#contact-autocomplete");
autocompleteInput = $("#contacts-search-input");
autocompleteInput.autoSuggest(data, {
selectedItemProp: "name",
......@@ -15,7 +15,7 @@
retrieveLimit: 10,
minChars: 1,
keyDelay: 0,
startText: '',
startText: "",
emptyText: "#{t("no_results")}",
preFill: [{name : "#{h params[:name]}",
value : "#{@contact_ids}"}]
......@@ -27,6 +27,6 @@
#flash-messages
.container-fluid.row
%h3
= t('conversations.index.new_conversation')
= t("conversations.index.new_conversation")
= render 'conversations/new'
= render "conversations/new", mobile: true
......@@ -40,7 +40,7 @@
id: 'mentionModal'
-if @contact
#new_conversation_pane
.conversations-form-container#new_conversation_pane
= render 'shared/modal',
path: new_conversation_path(:contact_id => @contact.id, name: @contact.person.name, modal: true),
title: t('conversations.index.new_conversation'),
......
......@@ -15,8 +15,8 @@ end
Then /^I send a message with subject "([^"]*)" and text "([^"]*)" to "([^"]*)"$/ do |subject, text, person|
step %(I am on the conversations page)
within("#new-conversation", match: :first) do
step %(I fill in "contact_autocomplete" with "#{person}")
step %(I press the first ".as-result-item" within ".as-results")
find("#contacts-search-input").native.send_key(person.to_s)
step %(I press the first ".tt-suggestion" within ".twitter-typeahead")
step %(I fill in "conversation-subject" with "#{subject}")
step %(I fill in "new-message-text" with "#{text}")
step %(I press "Send")
......@@ -26,8 +26,8 @@ end
Then /^I send a message with subject "([^"]*)" and text "([^"]*)" to "([^"]*)" using keyboard shortcuts$/ do |subject, text, person|
step %(I am on the conversations page)
within("#new-conversation", match: :first) do
step %(I fill in "contact_autocomplete" with "#{person}")
step %(I press the first ".as-result-item" within ".as-results")
find("#contacts-search-input").native.send_key(person.to_s)
step %(I press the first ".tt-suggestion" within ".twitter-typeahead")
step %(I fill in "conversation-subject" with "#{subject}")
step %(I fill in "new-message-text" with "#{text}")
find("#new-message-text").native.send_key %i(Ctrl Return)
......
......@@ -37,6 +37,8 @@ describe ContactsController, :type => :controller do
@person1 = FactoryGirl.create(:person)
bob.share_with(@person1, bob.aspects.first)
@person2 = FactoryGirl.create(:person)
@person3 = FactoryGirl.create(:person)
bob.contacts.create(person: @person3, aspects: [bob.aspects.first], receiving: true, sharing: true)
end
it "succeeds" do
......@@ -53,6 +55,15 @@ describe ContactsController, :type => :controller do
get :index, q: @person2.first_name, format: "json"
expect(response.body).to eq([].to_json)
end
it "only returns mutual contacts when mutual parameter is true" do
get :index, q: @person1.first_name, mutual: true, format: "json"
expect(response.body).to eq([].to_json)
get :index, q: @person2.first_name, mutual: true, format: "json"
expect(response.body).to eq([].to_json)
get :index, q: @person3.first_name, mutual: true, format: "json"
expect(response.body).to eq([@person3].to_json)
end
end
context "for pagination on the contacts page" do
......
......@@ -29,6 +29,9 @@ describe ConversationsController, :type => :controller do
get :index, :conversation_id => @conv1.id
save_fixture(html_for("body"), "conversations_read")
get :new, modal: true
save_fixture(response.body, "conversations_modal")
end
end
......
......@@ -277,4 +277,31 @@ describe("app.pages.Contacts", function(){
});
});
});
describe("showMessageModal", function() {
beforeEach(function() {
$("body").append("<div id='conversationModal'/>").append(spec.readFixture("conversations_modal"));
});
it("calls app.helpers.showModal", function() {
spyOn(app.helpers, "showModal");
this.view.showMessageModal();
expect(app.helpers.showModal);
});
it("app.views.ConversationsForm with correct parameters when modal is loaded", function() {
gon.conversationPrefill = [
{id: 1, name: "diaspora user", handle: "diaspora-user@pod.tld"},
{id: 2, name: "other diaspora user", handle: "other-diaspora-user@pod.tld"},
{id: 3, name: "user@pod.tld", handle: "user@pod.tld"}
];
spyOn(app.views.ConversationsForm.prototype, "initialize");
this.view.showMessageModal();
$("#conversationModal").trigger("modal:loaded");
expect($("#conversationModal").length).toBe(1);
expect(app.views.ConversationsForm.prototype.initialize)
.toHaveBeenCalledWith({prefill: gon.conversationPrefill});
});
});
});
describe("app.views.ConversationsForm", function() {
beforeEach(function() {
spec.loadFixture("conversations_read");
this.target = new app.views.ConversationsForm();
});
describe("initialize", function() {
it("initializes the conversation participants list", function() {
expect(this.target.conversationRecipients).toEqual([]);
});
it("initializes the search view", function() {
spyOn(app.views.SearchBase.prototype, "initialize");
this.target.initialize();
expect(app.views.SearchBase.prototype.initialize).toHaveBeenCalled();
expect(app.views.SearchBase.prototype.initialize.calls.argsFor(0)[0].customSearch).toBe(true);
expect(app.views.SearchBase.prototype.initialize.calls.argsFor(0)[0].autoselect).toBe(true);
expect(app.views.SearchBase.prototype.initialize.calls.argsFor(0)[0].remoteRoute).toEqual({
url: "/contacts",
extraParameters: "mutual=true"
});
expect(this.target.search).toBeDefined();
});
it("calls bindTypeaheadEvents", function() {
spyOn(app.views.ConversationsForm.prototype, "bindTypeaheadEvents");
this.target.initialize();
expect(app.views.ConversationsForm.prototype.bindTypeaheadEvents).toHaveBeenCalled();
});
it("calls prefill correctly", function() {
spyOn(app.views.ConversationsForm.prototype, "prefill");
this.target.initialize();
expect(app.views.ConversationsForm.prototype.prefill).not.toHaveBeenCalled();
this.target.initialize({prefill: {}});
expect(app.views.ConversationsForm.prototype.prefill).toHaveBeenCalledWith({});
});
});
describe("addRecipient", function() {
beforeEach(function() {
$("#conversation-new").removeClass("hidden");
$("#conversation-show").addClass("hidden");
});
it("add the participant", function() {
expect(this.target.conversationRecipients).toEqual([]);
this.target.addRecipient({name: "diaspora user", handle: "diaspora-user@pod.tld"});
expect(this.target.conversationRecipients).toEqual([{name: "diaspora user", handle: "diaspora-user@pod.tld"}]);
});
it("call updateContactIdsListInput", function() {
spyOn(app.views.ConversationsForm.prototype, "updateContactIdsListInput");
this.target.addRecipient({name: "diaspora user", handle: "diaspora-user@pod.tld"});
expect(app.views.ConversationsForm.prototype.updateContactIdsListInput).toHaveBeenCalled();
});
it("adds a recipient tag", function() {
expect($(".conversation-recipient-tag").length).toBe(0);
this.target.addRecipient({name: "diaspora user", handle: "diaspora-user@pod.tld"});
expect($(".conversation-recipient-tag").length).toBe(1);
});
});
describe("prefill", function() {
beforeEach(function() {
this.prefills = [{name: "diaspora user"}, {name: "other diaspora user"}, {name: "user"}];
});
it("call addRecipient for each prefilled participant", function() {
spyOn(app.views.ConversationsForm.prototype, "addRecipient");
this.target.prefill(this.prefills);
expect(app.views.ConversationsForm.prototype.addRecipient).toHaveBeenCalledTimes(this.prefills.length);
var allArgsFlattened = app.views.ConversationsForm.prototype.addRecipient.calls.allArgs().map(function(arg) {
return arg[0];
});
expect(allArgsFlattened).toEqual(this.prefills);
});
});
describe("updateContactIdsListInput", function() {
beforeEach(function() {
this.target.conversationRecipients.push({id: 1, name: "diaspora user", handle: "diaspora-user@pod.tld"});
this.target.conversationRecipients
.push({id: 2, name: "other diaspora user", handle: "other-diaspora-user@pod.tld"});
this.target.conversationRecipients.push({id: 3, name: "user@pod.tld", handle: "user@pod.tld"});
});
it("updates hidden input value", function() {
this.target.updateContactIdsListInput();
expect(this.target.contactsIdsListInput.val()).toBe("1,2,3");
});
it("calls app.views.SearchBase.ignorePersonForSuggestions() for each participant", function() {
spyOn(app.views.SearchBase.prototype, "ignorePersonForSuggestions");
this.target.updateContactIdsListInput();
expect(app.views.SearchBase.prototype.ignorePersonForSuggestions).toHaveBeenCalledTimes(3);
expect(app.views.SearchBase.prototype.ignorePersonForSuggestions.calls.argsFor(0)[0])
.toEqual({id: 1, name: "diaspora user", handle: "diaspora-user@pod.tld"});
expect(app.views.SearchBase.prototype.ignorePersonForSuggestions.calls.argsFor(1)[0])
.toEqual({id: 2, name: "other diaspora user", handle: "other-diaspora-user@pod.tld"});
expect(app.views.SearchBase.prototype.ignorePersonForSuggestions.calls.argsFor(2)[0])
.toEqual({id: 3, name: "user@pod.tld", handle: "user@pod.tld"});
});
});
describe("bindTypeaheadEvents", function() {
it("calls onSuggestionSelection() when clicking on a result", function() {
spyOn(app.views.ConversationsForm.prototype, "onSuggestionSelection");
var event = $.Event("typeahead:select");
var person = {name: "diaspora user"};
this.target.typeaheadElement.trigger(event, [person]);
expect(app.views.ConversationsForm.prototype.onSuggestionSelection).toHaveBeenCalledWith(person);
});
});
describe("onSuggestionSelection", function() {
it("calls addRecipient, updateContactIdsListInput and $.fn.typeahead", function() {
spyOn(app.views.ConversationsForm.prototype, "addRecipient");
spyOn($.fn, "typeahead");
var person = {name: "diaspora user"};
this.target.onSuggestionSelection(person);
expect(app.views.ConversationsForm.prototype.addRecipient).toHaveBeenCalledWith(person);
expect($.fn.typeahead).toHaveBeenCalledWith("val", "");
});
});
describe("keyDown", function() {
beforeEach(function() {
this.submitCallback = jasmine.createSpy().and.returnValue(false);
new app.views.ConversationsForm();
});
context("on new message form", function() {
......@@ -52,6 +173,54 @@ describe("app.views.ConversationsForm", function() {
});
});
describe("removeRecipient", function() {
beforeEach(function() {
this.target.addRecipient({id: 1, name: "diaspora user", handle: "diaspora-user@pod.tld"});
this.target.addRecipient({id: 2, name: "other diaspora user", handle: "other-diaspora-user@pod.tld"});
this.target.addRecipient({id: 3, name: "user@pod.tld", handle: "user@pod.tld"});
});
it("removes the user from conversation recipients when clicking the tag's remove button", function() {
expect(this.target.conversationRecipients).toEqual([
{id: 1, name: "diaspora user", handle: "diaspora-user@pod.tld"},
{id: 2, name: "other diaspora user", handle: "other-diaspora-user@pod.tld"},
{id: 3, name: "user@pod.tld", handle: "user@pod.tld"}
]);