From f70c22f898e8967e7ca470859da1310f919659fc Mon Sep 17 00:00:00 2001 From: lilia Date: Thu, 15 Oct 2015 12:10:03 -0700 Subject: [PATCH] Add search field to inbox Using the search field produces a filtered view of all contacts and groups containing the input. To make this fast and scalable, add an index on a 'tokens' array containing words from the conversation name and different forms of phone number. Closes #365 // FREEBIE --- background.html | 8 ++- js/database.js | 15 ++++ js/models/conversations.js | 37 +++++++++- js/views/conversation_list_item_view.js | 7 +- js/views/conversation_search_view.js | 94 +++++++++++++++++++++++++ js/views/inbox_view.js | 37 +++++++++- stylesheets/_index.scss | 20 ++++++ stylesheets/manifest.css | 12 ++++ 8 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 js/views/conversation_search_view.js diff --git a/background.html b/background.html index fa3edd17..0ef363f7 100644 --- a/background.html +++ b/background.html @@ -15,10 +15,13 @@
+
-
- +
+
+
+
@@ -327,6 +330,7 @@ + diff --git a/js/database.js b/js/database.js index 3924e609..8f40c1a8 100644 --- a/js/database.js +++ b/js/database.js @@ -34,6 +34,21 @@ var items = transaction.db.createObjectStore("items"); next(); } + }, + { + version: "2.0", + migrate: function(transaction, next) { + var conversations = transaction.objectStore("conversations"); + conversations.createIndex("search", "tokens", { unique: false, multiEntry: true }); + + var all = new Whisper.ConversationCollection(); + all.fetch().then(function() { + all.each(function(model) { + model.updateTokens(); + model.save(); + }); + }); + } } ]; }()); diff --git a/js/models/conversations.js b/js/models/conversations.js index 9ae7e511..7b0c8d40 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -42,6 +42,7 @@ conversation: this }); + this.on('change:id change:name', this.updateTokens); this.on('change:avatar', this.updateAvatarUrl); this.on('destroy', this.revokeAvatarUrl); }, @@ -54,22 +55,33 @@ if (attributes.type !== 'private' && attributes.type !== 'group') { return "Invalid conversation type: " + attributes.type; } + }, + + updateTokens: function() { + var tokens = []; + var name = this.get('name'); + if (typeof name === 'string') { + tokens = name.trim().toLowerCase().split(/[\s\-_\(\)\+]+/); + } - // hack if (this.isPrivate()) { try { this.id = libphonenumber.util.verifyNumber(this.id); var number = libphonenumber.util.splitCountryCode(this.id); + var international_number = '' + number.country_code + number.national_number; + var national_number = '' + number.national_number; this.set({ e164_number: this.id, - national_number: '' + number.national_number, - international_number: '' + number.country_code + number.national_number + national_number: national_number, + international_number: international_number }); + tokens = tokens.concat(national_number, international_number); } catch(ex) { return ex; } } + this.set({tokens: tokens}); }, sendMessage: function(body, attachments) { @@ -332,6 +344,25 @@ })); }, + search: function(query) { + query = query.trim().toLowerCase(); + if (query.length > 0) { + var lastCharCode = query.charCodeAt(query.length - 1); + var nextChar = String.fromCharCode(lastCharCode + 1); + var upper = query.slice(0, -1) + nextChar; + console.log('searching', query, ' -> ', upper); + return new Promise(function(resolve) { + this.fetch({ + index: { + name: 'search', // 'search' index on tokens array + lower: query, + upper: upper + } + }).always(resolve); + }.bind(this)); + } + }, + fetchGroups: function(number) { return this.fetch({ index: { diff --git a/js/views/conversation_list_item_view.js b/js/views/conversation_list_item_view.js index faba8a8f..3597a86e 100644 --- a/js/views/conversation_list_item_view.js +++ b/js/views/conversation_list_item_view.js @@ -18,13 +18,18 @@ initialize: function() { this.listenTo(this.model, 'change', this.render); // auto update this.listenTo(this.model, 'destroy', this.remove); // auto update + this.listenTo(this.model, 'opened', this.markSelected); // auto update extension.windows.beforeUnload(function() { this.stopListening(); }.bind(this)); }, - select: function(e) { + markSelected: function() { this.$el.addClass('selected').siblings('.selected').removeClass('selected'); + }, + + select: function(e) { + this.markSelected(); this.$el.trigger('select', this.model); }, diff --git a/js/views/conversation_search_view.js b/js/views/conversation_search_view.js new file mode 100644 index 00000000..9de91612 --- /dev/null +++ b/js/views/conversation_search_view.js @@ -0,0 +1,94 @@ +/* + * vim: ts=4:sw=4:expandtab + */ +(function () { + 'use strict'; + window.Whisper = window.Whisper || {}; + + Whisper.ConversationSearchView = Whisper.View.extend({ + className: 'conversation-search', + initialize: function(options) { + this.$input = options.input; + this.$new_contact = this.$('.new-contact'); + + this.typeahead = new Whisper.ConversationCollection(); + // View to display the matched contacts from typeahead + this.typeahead_view = new Whisper.ConversationListView({ + collection : new Whisper.ConversationCollection([], { + comparator: function(m) { return m.getTitle().toLowerCase(); } + }) + }); + this.$el.append(this.typeahead_view.el); + this.initNewContact(); + //this.listenTo(this.collection, 'reset', this.filterContacts); + + }, + + events: { + 'select .new-contact': 'createConversation', + 'select .contacts': 'open' + }, + + filterContacts: function(e) { + var query = this.$input.val(); + if (query.length) { + if (this.maybeNumber(query)) { + this.new_contact_view.model.set('id', query); + this.new_contact_view.render().$el.show(); + } else { + this.new_contact_view.$el.hide(); + } + this.typeahead.search(query).then(function() { + this.typeahead_view.collection.reset(this.typeahead.models); + }.bind(this)); + this.trigger('show'); + } else { + this.resetTypeahead(); + } + }, + + initNewContact: function() { + if (this.new_contact_view) { + this.new_contact_view.undelegateEvents(); + this.new_contact_view.$el.hide(); + } + // Creates a view to display a new contact + this.new_contact_view = new Whisper.ConversationListItemView({ + el: this.$new_contact, + model: ConversationController.create({ + type: 'private', + newContact: true + }) + }).render(); + }, + + createConversation: function() { + this.$el.trigger('open', this.new_contact_view.model); + this.initNewContact(); + this.resetTypeahead(); + }, + + open: function(e, conversation) { + this.$el.trigger('open', conversation); + }, + + reset: function() { + this.delegateEvents(); + this.typeahead_view.delegateEvents(); + this.new_contact_view.delegateEvents(); + this.resetTypeahead(); + }, + + resetTypeahead: function() { + this.new_contact_view.$el.hide(); + this.$input.val('').focus(); + this.typeahead_view.collection.reset([]); + this.trigger('hide'); + }, + + maybeNumber: function(number) { + return number.match(/^\+?[0-9]*$/); + } + }); + +})(); diff --git a/js/views/inbox_view.js b/js/views/inbox_view.js index 61d99620..01aa7d81 100644 --- a/js/views/inbox_view.js +++ b/js/views/inbox_view.js @@ -67,6 +67,7 @@ }); }); conversation.markRead(); + conversation.trigger('opened'); } }); @@ -88,11 +89,30 @@ var inboxCollection = bg.getInboxCollection(); this.inboxListView = new Whisper.ConversationListView({ - el : this.$('.conversations'), + el : this.$('.inbox'), collection : inboxCollection }).render(); - this.inboxListView.listenTo(inboxCollection, 'add change:active_at', this.inboxListView.onChangeActiveAt); + this.inboxListView.listenTo(inboxCollection, + 'add change:active_at', + this.inboxListView.onChangeActiveAt); + + this.searchView = new Whisper.ConversationSearchView({ + el : this.$('.search-results'), + input : this.$('input.search') + }); + + this.searchView.$el.hide().insertAfter(this.inboxListView.el); + + this.listenTo(this.searchView, 'hide', function() { + this.searchView.$el.hide(); + this.inboxListView.$el.show(); + }); + this.listenTo(this.searchView, 'show', function() { + this.searchView.$el.show(); + this.inboxListView.$el.hide(); + }); + new SocketView().render().$el.appendTo(this.$('.socket-status')); @@ -109,9 +129,20 @@ 'click .hamburger': 'toggleMenu', 'click .show-debug-log': 'showDebugLog', 'click .show-new-conversation': 'showCompose', - 'select .gutter .contact': 'openConversation' + 'select .gutter .contact': 'openConversation', + 'input input.search': 'filterContacts' + }, + filterContacts: function(e) { + this.searchView.filterContacts(e); + var input = this.$('input.search'); + if (input.val().length > 0) { + input.addClass('active'); + } else { + input.removeClass('active'); + } }, openConversation: function(e, conversation) { + conversation = ConversationController.create(conversation); this.conversation_stack.open(conversation); this.hideCompose(); }, diff --git a/stylesheets/_index.scss b/stylesheets/_index.scss index 730971a1..9d451030 100644 --- a/stylesheets/_index.scss +++ b/stylesheets/_index.scss @@ -106,6 +106,26 @@ input.search { background-color: darken($grey_l, 3%); } } + + input.search { + height: $header-height - 10px; + width: calc(100% - #{$header-height + 10px}); + background: $grey_l; + margin: 5px; + padding: 5px; + + &:before { + content: 'Search'; + } + + &.active, &:active, &:focus { + background: white; + + &:before { + content: ''; + } + } + } } .last-timestamp { diff --git a/stylesheets/manifest.css b/stylesheets/manifest.css index b7b0d4b4..8fed1d80 100644 --- a/stylesheets/manifest.css +++ b/stylesheets/manifest.css @@ -462,6 +462,18 @@ input.search { background: url("/images/pencil.png") no-repeat center center; } .tool-bar button.show-new-conversation:hover { background-color: #ebebeb; } +.tool-bar input.search { + height: 26px; + width: calc(100% - 46px); + background: #f3f3f3; + margin: 5px; + padding: 5px; } + .tool-bar input.search:before { + content: 'Search'; } + .tool-bar input.search.active, .tool-bar input.search:active, .tool-bar input.search:focus { + background: white; } + .tool-bar input.search.active:before, .tool-bar input.search:active:before, .tool-bar input.search:focus:before { + content: ''; } .last-timestamp { font-size: smaller; }