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.
This commit is contained in:
lilia 2014-05-16 21:48:46 -07:00
parent 2d12a33ead
commit 83508abab8
13 changed files with 460 additions and 211 deletions

116
js/views/conversation.js Normal file
View file

@ -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("<li>delete</li>");
}
});
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 = $('<div class="image">');
this.$name = $('<span class="name">');
this.$header = $('<div class="header">').append(this.$image, this.$name);
this.$button = $('<button class="btn">').append($('<span>').text('Send'));
this.$input = $('<input type="text">').attr('autocomplete','off');
this.$form = $("<form class=''>").append(this.$input);
this.$messages = $('<ul class="messages">');
this.$collapsable = $('<div class="collapsable">').hide();
this.$collapsable.append(this.$messages, this.$form);
this.$el.append(this.$destroy, this.$header, this.$collapsable);
this.addAllMessages();
this.$form.submit(function(input,thread){ return function(e) {
if (!input.val().length) { return false; }
thread.sendMessage(input.val());
input.val("");
e.preventDefault();
};}(this.$input, this.model));
this.$header.click(function(e) {
var $conversation = $(e.target).closest('.conversation');
if (!$conversation.hasClass('closed')) {
$conversation.addClass('closed');
$conversation.find('.collapsable').slideUp(600);
e.stopPropagation();
}
});
this.$button.click(function(button,input,thread){ return function(e) {
if (!input.val().length) { return false; }
button.attr("disabled", "disabled");
button.find('span').text("Sending");
thread.sendMessage(input.val()).then(function(){
button.removeAttr("disabled");
button.find('span').text("Send");
});
input.val("");
};}(this.$button, this.$input, this.model));
this.$el.click(this.open.bind(this));
},
remove: function() {
this.$el.remove();
},
open: function(e) {
if (this.$el.hasClass('closed')) {
this.$el.removeClass('closed');
this.$collapsable.slideDown(600);
}
this.$input.focus();
},
addMessage: function (message) {
var view = new Whisper.MessageView({ model: message });
this.$messages.append(view.render().el);
},
addAllMessages: function () {
_.each(this.model.messages(), this.addMessage, this);
this.render();
},
render: function() {
this.$name.text(this.model.get('name'));
this.$image.css('background-image: ' + this.model.get('image') + ';');
return this;
}
});
})();

64
js/views/message.js Normal file
View file

@ -0,0 +1,64 @@
var Whisper = Whisper || {};
(function () {
'use strict';
var Destroyer = Backbone.View.extend({
tagName: 'button',
className: 'btn btn-square btn-sm',
initialize: function() {
this.$el.html('&times;');
this.listenTo(this.$el, 'click', this.model.destroy);
}
});
Whisper.MessageView = Backbone.View.extend({
tagName: "li",
className: "message",
initialize: function() {
this.$el.
append($('<div class="bubble">').
append(
$('<span class="message-text">'),
$('<span class="message-attachment">'),
$('<span class="metadata">')
)
);
this.$el.addClass(this.model.get('type'));
this.listenTo(this.model, 'change', this.render); // auto update
this.listenTo(this.model, 'destroy', this.remove); // auto update
},
render: function() {
this.$el.find('.message-text').text(this.model.get('body'));
var attachments = this.model.get('attachments');
if (attachments) {
for (var i = 0; i < attachments.length; i++)
this.$el.find('.message-attachment').append('<img src="' + attachments[i] + '" />');
}
this.$el.find('.metadata').text(this.formatTimestamp());
return this;
},
remove: function() {
this.$el.remove();
},
formatTimestamp: function() {
var timestamp = this.model.get('timestamp');
var now = new Date().getTime();
var date = new Date();
date.setTime(timestamp*1000);
if (now - timestamp > 60*60*24*7) {
return date.toLocaleDateString('en-US',{month: 'short', day: 'numeric'});
}
if (now - timestamp > 60*60*24) {
return date.toLocaleDateString('en-US',{weekday: 'short'});
}
return date.toTimeString();
}
});
})();

View file

@ -3,152 +3,71 @@ var Whisper = Whisper || {};
(function () {
'use strict';
var MessageView = Backbone.View.extend({
tagName: "li",
className: "message",
initialize: function() {
this.$el.
append($('<div class="bubble">').
append($('<span class="message-text">')).
append($('<span class="message-attachment">')).
append($('<span class="metadata">'))
);
this.$el.addClass(this.model.get('type'));
this.listenTo(this.model, 'change:completed', this.render); // auto update
},
render: function() {
this.$el.find('.message-text').text(this.model.get('body'));
var attachments = this.model.get('attachments');
if (attachments) {
for (var i = 0; i < attachments.length; i++)
this.$el.find('.message-attachment').append('<img src="' + attachments[i] + '" />');
}
this.$el.find('.metadata').text(this.formatTimestamp());
return this;
},
formatTimestamp: function() {
var timestamp = this.model.get('timestamp');
var now = new Date().getTime() / 1000;
var date = new Date();
date.setTime(timestamp*1000);
if (now - timestamp > 60*60*24*7) {
return date.toLocaleDateString({month: 'short', day: 'numeric'});
}
if (now - timestamp > 60*60*24) {
return date.toLocaleDateString({weekday: 'short'});
}
return date.toTimeString();
}
});
var ConversationView = Backbone.View.extend({
tagName: 'li',
className: 'conversation',
initialize: function(options) {
this.$el.addClass('closed');
this.$header = $('<div class="header">').
append($('<span>').text(options.name)).appendTo(this.$el);
this.$header.prepend($('<div class="avatar">'));
this.$collapsable = $('<div class="collapsable">').hide();
this.$messages = $('<ul>').addClass('messages').appendTo(this.$collapsable);
this.$button = $('<button class="btn">').attr('id', 'button' + this.id).
append($('<span>').text('Send'));
this.$input = $('<input type="text">').attr('autocomplete','off');
this.$form = $("<form class='container'>").append(this.$input, this.$button);
this.$form.appendTo(this.$collapsable);
this.$collapsable.appendTo(this.$el);
this.$header.click(function(e) {
var $conversation = $(e.target).closest('.conversation');
if (!$conversation.hasClass('closed')) {
$conversation.addClass('closed');
$conversation.find('.collapsable').slideUp(600);
e.stopPropagation();
}
});
this.$el.click(function(e) {
var $conversation = $(e.target).closest('.conversation');
if ($conversation.hasClass('closed')) {
$conversation.removeClass('closed');
$conversation.find('.collapsable').slideDown(600);
$conversation.find('input').focus();
}
});
this.$button.click(function(e) {
var $button = $(e.target).closest('.btn');
var $input = $button.closest('form').find('input');
$button.attr("disabled", "disabled");
$button.find('span').text("Sending");
var message = Whisper.Messages.addOutgoingMessage(
$input.val(), options.recipients
);
textsecure.sendMessage(options.recipients, message.toProto(),
function(result) {
console.log(result);
$button.removeAttr("disabled");
$button.find('span').text("Send");
$input.val("");
}
);
});
},
addMessage: function (message) {
var view = new MessageView({ model: message });
this.$messages.append(view.render().el);
},
});
Whisper.ConversationListView = new (Backbone.View.extend({ // singleton
tagName: 'ul',
id: 'conversations',
initialize: function() {
this.views = [];
this.messages = Whisper.Messages;
this.listenTo(this.messages, 'change:completed', this.render);
this.listenTo(this.messages, 'add', this.addMessage);
this.listenTo(this.messages, 'reset', this.addAll);
this.listenTo(this.messages, 'all', this.render);
this.threads = Whisper.Threads;
this.listenTo(this.threads, 'change:completed', this.render); // auto update
this.listenTo(this.threads, 'add', this.addThread);
this.listenTo(this.threads, 'reset', this.addAll);
this.listenTo(this.threads, 'all', this.render);
// Suppresses 'add' events with {reset: true} and prevents the app view
// from being re-rendered for every model. Only renders when the 'reset'
// event is triggered at the end of the fetch.
//this.messages.fetch({reset: true});
//this.messages.threads({reset: true});
Whisper.Messages.fetch();
Whisper.Threads.fetch({reset: true});
this.$el.appendTo($('#inbox'));
$('#send_link').click(function(e) {
$('#send').fadeIn().find('input[type=text]').focus();
});
$('#send').click(function() {
$('#send input[type=text]').focus();
});
$("#compose-cancel").click(function(e) {
$('#send').hide();
e.preventDefault();
});
$("#send").submit((function(e) {
e.preventDefault();
var numbers = [];
var splitString = $("#send_numbers").val().split(",");
for (var i = 0; i < splitString.length; i++) {
try {
numbers.push(verifyNumber(splitString[i]));
} catch (numberError) {
alert(numberError);
}
}
$("#send_numbers").val('');
numbers = _.filter(numbers, _.identity); // rm undefined, null, "", etc...
if (numbers.length) {
$('#send').hide();
Whisper.Threads.findOrCreateForRecipients(numbers).trigger('select');
} else {
Whisper.notify('recipient missing or invalid');
$('#send input[type=text]').focus();
}
}).bind(this));
},
addMessage: function (message) {
// todo: find the right existing view
var threadId = message.get('person'); // TODO: groups
if (this.views[threadId] === undefined) {
this.views[threadId] = new ConversationView({
name: threadId, recipients: [threadId]
});
this.$el.append(this.views[threadId].render().el);
}
this.views[threadId].addMessage(message);
addThread: function(thread) {
this.views[thread.id] = new Whisper.ConversationView({model: thread});
this.$el.prepend(this.views[thread.id].render().el);
},
// Add all items in the collection at once
addAll: function () {
addAll: function() {
this.$el.html('');
this.messages.each(this.addMessage, this);
this.threads.each(this.addThread, this);
},
}))();
})();

34
js/views/notifications.js Normal file
View file

@ -0,0 +1,34 @@
var Whisper = Whisper || {};
(function () {
'use strict';
// This is an ephemeral collection of global notification messages to be
// presented in some nice way to the user. In this case they will fade in/out
// one at a time.
var queue = new Backbone.Collection();
var view = new (Backbone.View.extend({
className: 'help',
initialize: function() {
this.$el.appendTo($('body'));
this.listenToOnce(queue, 'add', this.presentNext);
},
presentNext: function() {
var next = queue.shift();
if (next) {
this.$el.text(next.get('message')).fadeIn(this.setFadeOut.bind(this));
} else {
this.listenToOnce(queue, 'add', this.presentNext);
}
},
setFadeOut: function() {
setTimeout(this.fadeOut.bind(this), 1500);
},
fadeOut: function() {
this.$el.fadeOut(this.presentNext.bind(this));
},
}))();
Whisper.notify = function(str) { queue.add({message: str}); }
})();