From 83508abab8026715ad5c279263463c712aae2f81 Mon Sep 17 00:00:00 2001 From: lilia Date: Fri, 16 May 2014 21:48:46 -0700 Subject: [PATCH] Thread model and UI improvements Adds thread model/collection for managing conversation-level state, such as unreadCounts, group membership, thread order, etc... plus various UI improvements enabled by thread model, including an improved compose flow, and thread-destroy button. Adds Whisper.notify for presenting messages to the user in an orderly fashion. Currently using a growl-style fade in/out effect. Also some housekeeping: Cut up views into separate files. Partial fix for formatTimestamp. Tweaked buttons and other styles. --- background.html | 1 + css/buttons.css | 38 +++++--- css/conversation.css | 26 +++--- css/forms.css | 3 +- css/popup.css | 31 ++++--- js/models/messages.js | 26 +++++- js/models/threads.js | 94 ++++++++++++++++++++ js/popup.js | 33 ------- js/views/conversation.js | 116 +++++++++++++++++++++++++ js/views/message.js | 64 ++++++++++++++ js/views/messages.js | 177 +++++++++++--------------------------- js/views/notifications.js | 34 ++++++++ popup.html | 28 +++--- 13 files changed, 460 insertions(+), 211 deletions(-) create mode 100644 js/models/threads.js create mode 100644 js/views/conversation.js create mode 100644 js/views/message.js create mode 100644 js/views/notifications.js diff --git a/background.html b/background.html index eb926be7..bd987089 100644 --- a/background.html +++ b/background.html @@ -34,6 +34,7 @@ + diff --git a/css/buttons.css b/css/buttons.css index cec16d5e..7809c84f 100644 --- a/css/buttons.css +++ b/css/buttons.css @@ -1,26 +1,44 @@ -.btn span { +.btn { display: inline-block; padding: 0.5em; - border: 2px solid #7fd0ed; - border-radius: 4px; -} -.btn { + border: 2px solid #acdbf5; border-radius: 4px; background-color: #fff; - padding: 2px; - border: none; + color: #7fd0ed; + font-weight: bold; } .btn:hover, .btn:focus { cursor: pointer; outline: none; - background-color: #f1fafd; +} +.btn:hover { + background-color: #7fd0ed; + border-color: #acdbf5; + color: #fff; } .btn:active { outline: 2px dashed #acdbf5; + outline-offset: 2px; } -.btn.selected span, -.btn:active span { +.btn.selected , +.btn:active { background-color: #7fd0ed; border: 2px solid #acdbf5; color: #fff; } +.btn:active { + background-color: #f1fafd; + color: #7fd0ed; +} + +.btn-square { + display: inline-block; + width: 32px; + line-height: 15px; +} + +.btn-sm.btn-square { + padding: 0; + width: 25px; + height: 25px; +} diff --git a/css/conversation.css b/css/conversation.css index 961ceab1..e479f0ff 100644 --- a/css/conversation.css +++ b/css/conversation.css @@ -1,6 +1,7 @@ .conversation { + box-sizing: border-box; position: relative; - max-width: 400px; + min-height: 64px; margin: auto; padding: 1em; border-radius: 10px; @@ -47,9 +48,12 @@ } .conversation .header { - padding: 0.3em 0.6em 0.3em 46px; + padding: 0.3em 0 0.3em 46px; } -.avatar { +.conversation .btn.destroy { + float: right; +} +.conversation .image { position: absolute; top: 8px; left: 10px; @@ -69,18 +73,20 @@ .collapsable { background-color: #fff; border: 2px solid #acdbf5; - padding: 1em 0em; + padding: 1em 0em 0em; line-height: 1.2em; font-family: sans-serif; - border-radius: 30px; + border-radius: 20px 0; } -.messages + form { - text-align: right; +.collapsable form { + margin: 0; + padding: 1em; } - -.conversation form { - margin-top: 0.5em; +.collapsable input[type=text] { + box-sizing: border-box; + width: 100%; + border: none; } .message-text { diff --git a/css/forms.css b/css/forms.css index 6d203151..a6378d43 100644 --- a/css/forms.css +++ b/css/forms.css @@ -1,7 +1,7 @@ input[type=text], textarea { position: relative; display: inline-block; - padding: 7px; + padding: 0.5em; border: 2px solid #7fd0ed; border-radius: 4px; background-color: #fafafa; @@ -9,6 +9,7 @@ input[type=text], textarea { } +input[type=submit]:focus, input[type=text]:focus { outline: 2px dashed #acdbf5; outline-offset: 2px; diff --git a/css/popup.css b/css/popup.css index bf29813f..1ef45a78 100644 --- a/css/popup.css +++ b/css/popup.css @@ -35,22 +35,19 @@ header { padding: 5px 0; } -form.compose { - position: relative; -} - label { float: left; margin-right: 1em; } -form.compose input[type=text], form.compose textarea { - margin: 0.5em 0; -} -#send input[type=submit] { +#compose-cancel { float: right; } +#send .conversation { + padding: 0.3em 1em; +} + #popup_send_numbers { margin-bottom: 0; } @@ -95,5 +92,19 @@ ul { li { list-style: none; } - -/* Formatting */ +#send_link ~ #new-chat-help, +#new-group ~ #new-group-help { + display: none; +} +#send_link:hover ~ #new-chat-help, +#new-group:hover ~ #new-group-help { + display: block; +} +.help { + display: inline-block; + position: fixed; + top: 10; + right: 10; + font-size: 0.8em; + color: #7fd0ed; +} diff --git a/js/models/messages.js b/js/models/messages.js index d6c90cda..76cae14b 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -4,8 +4,18 @@ var Whisper = Whisper || {}; 'use strict'; var Message = Backbone.Model.extend({ + validate: function(attributes, options) { + var required = ['body', 'timestamp', 'threadId']; + var missing = _.filter(required, function(attr) { return !attributes[attr]; }); + if (missing.length) { return "Message must have " + missing; } + }, + toProto: function() { return new textsecure.protos.PushMessageContentProtobuf({body: this.get('body')}); + }, + + thread: function() { + return Whisper.Threads.get(this.get('threadId')); } }); @@ -20,28 +30,36 @@ var Whisper = Whisper || {}; for (var i = 0; i < decrypted.message.attachments.length; i++) attachments[i] = "data:" + decrypted.message.attachments[i].contentType + ";base64," + btoa(getString(decrypted.message.attachments[i].decrypted)); + var thread = Whisper.Threads.findOrCreateForIncomingMessage(decrypted); var m = Whisper.Messages.add({ person: decrypted.pushMessage.source, - group: decrypted.message.group, + threadId: thread.id, body: decrypted.message.body, attachments: attachments, type: 'incoming', timestamp: decrypted.message.timestamp }); m.save(); + + if (decrypted.message.timestamp > thread.get('timestamp')) { + thread.set('timestamp', decrypted.message.timestamp); + thread.set('unreadCount', thread.get('unreadCount') + 1); + thread.save(); + } + thread.trigger('message', m); return m; }, - addOutgoingMessage: function(message, recipients) { + addOutgoingMessage: function(message, thread) { var m = Whisper.Messages.add({ - person: recipients[0], // TODO: groups + threadId: thread.id, body: message, type: 'outgoing', timestamp: new Date().getTime() }); m.save(); + thread.trigger('message', m); return m; } }))(); - })() diff --git a/js/models/threads.js b/js/models/threads.js new file mode 100644 index 00000000..d947e9e5 --- /dev/null +++ b/js/models/threads.js @@ -0,0 +1,94 @@ +var Whisper = Whisper || {}; + +(function () { + 'use strict'; + + var Thread = Backbone.Model.extend({ + defaults: function() { + return { + image: '/images/default.png', + unreadCount: 0, + timestamp: new Date().getTime() + }; + }, + + validate: function(attributes, options) { + var required = ['id', 'type', 'recipients', 'timestamp', 'image', 'name']; + var missing = _.filter(required, function(attr) { return !attributes[attr]; }); + if (missing.length) { return "Thread must have " + missing; } + if (attributes.recipients.length === 0) { + return "No recipients for thread " + this.id; + } + for (var person in attributes.recipients) { + if (!person) return "Invalid recipient"; + } + }, + + sendMessage: function(message) { + return new Promise(function(resolve) { + var m = Whisper.Messages.addOutgoingMessage(message, this); + textsecure.sendMessage(this.get('recipients'), m.toProto(), + function(result) { + console.log(result); + resolve(); + } + ); + }.bind(this)); + }, + + messages: function() { + return Whisper.Messages.where({threadId: this.id}); + }, + }); + + Whisper.Threads = new (Backbone.Collection.extend({ + localStorage: new Backbone.LocalStorage("Threads"), + model: Thread, + comparator: 'timestamp', + findOrCreate: function(attributes) { + var thread = Whisper.Threads.add(attributes, {merge: true}); + thread.save(); + return thread; + }, + + findOrCreateForRecipients: function(recipients) { + var attributes = {}; + if (recipients.length > 1) { + attributes = { + //TODO group id formatting? + name : recipients, + recipients : recipients, + type : 'group', + }; + } else { + attributes = { + id : recipients[0], + name : recipients[0], + recipients : recipients, + type : 'private', + }; + } + return this.findOrCreate(attributes); + }, + + findOrCreateForIncomingMessage: function(decrypted) { + var attributes = {}; + if (decrypted.message.group) { + attributes = { + id : decrypted.message.group.id, + name : decrypted.message.group.name, + recipients : decrypted.message.group.members, + type : 'group', + }; + } else { + attributes = { + id : decrypted.pushMessage.source, + name : decrypted.pushMessage.source, + recipients : [decrypted.pushMessage.source], + type : 'private' + }; + } + return this.findOrCreate(attributes); + } + }))(); +})(); diff --git a/js/popup.js b/js/popup.js index ba98c1b8..87ec8fc2 100644 --- a/js/popup.js +++ b/js/popup.js @@ -14,49 +14,16 @@ * along with this program. If not, see . */ -$('#inbox_link').click(function(e) { - $('#send').hide(); - $('#send_link').removeClass('selected'); - $('#inbox').show(); - $('#inbox_link').addClass('selected'); -}); -$('#send_link').click(function(e) { - $('#inbox').hide(); - $('#inbox_link').removeClass('selected'); - $('#send').show(); - $('#send_link').addClass('selected'); -}); textsecure.registerOnLoadFunction(function() { if (textsecure.storage.getUnencrypted("number_id") === undefined) { chrome.tabs.create({url: "options.html"}); } else { - $(window).bind('storage', function(e) { Whisper.Messages.fetch(); }); - Whisper.Messages.fetch(); $('.my-number').text(textsecure.storage.getUnencrypted("number_id").split(".")[0]); textsecure.storage.putUnencrypted("unreadCount", 0); chrome.browserAction.setBadgeText({text: ""}); $("#me").click(function() { $('#popup_send_numbers').val($('.my-number').text()); }); - - $("#popup_send_button").click(function() { - var numbers = []; - var splitString = $("#popup_send_numbers").val().split(","); - for (var i = 0; i < splitString.length; i++) { - try { - numbers.push(verifyNumber(splitString[i])); - } catch (numberError) { - //TODO - alert(numberError); - } - } - var message = Whisper.Messages.addOutgoingMessage( - $("#popup_send_text").val(), numbers - ); - textsecure.sendMessage(numbers, message.toProto(), - //TODO: Handle result - function(thing) {console.log(thing);}); - }); } }); diff --git a/js/views/conversation.js b/js/views/conversation.js new file mode 100644 index 00000000..f8358bec --- /dev/null +++ b/js/views/conversation.js @@ -0,0 +1,116 @@ +var Whisper = Whisper || {}; + +(function () { + 'use strict'; + + var destroyer = Backbone.View.extend({ + tagName: 'button', + className: 'btn btn-square btn-sm destroy', + initialize: function() { + this.$el.html('×'); + this.$el.click(this.destroy.bind(this)); + }, + + destroy: function() { + _.each(this.model.messages(), function(message) { message.destroy(); }); + this.model.destroy(); + } + }); + + var menu = Backbone.View.extend({ + tagName: 'ul', + className: 'menu', + initialize: function() { + this.$el.html("
  • delete
  • "); + } + }); + + Whisper.ConversationView = Backbone.View.extend({ + tagName: 'li', + className: 'conversation', + + initialize: function() { + this.listenTo(this.model, 'change', this.render); // auto update + this.listenTo(this.model, 'message', this.addMessage); // auto update + this.listenTo(this.model, 'destroy', this.remove); // auto update + this.listenTo(this.model, 'select', this.open); + + this.$el.addClass('closed'); + this.$destroy = (new destroyer({model: this.model})).$el; + + this.$image = $('
    '); + this.$name = $(''); + this.$header = $('
    ').append(this.$image, this.$name); + + this.$button = $(' - + + new message
    -