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:
parent
2d12a33ead
commit
83508abab8
13 changed files with 460 additions and 211 deletions
|
@ -34,6 +34,7 @@
|
||||||
<script type="text/javascript" src="js-deps/backbone.js"></script>
|
<script type="text/javascript" src="js-deps/backbone.js"></script>
|
||||||
<script type="text/javascript" src="js-deps/backbone.localStorage.js"></script>
|
<script type="text/javascript" src="js-deps/backbone.localStorage.js"></script>
|
||||||
<script type="text/javascript" src="js/models/messages.js"></script>
|
<script type="text/javascript" src="js/models/messages.js"></script>
|
||||||
|
<script type="text/javascript" src="js/models/threads.js"></script>
|
||||||
<script type="text/javascript" src="js/helpers.js"></script>
|
<script type="text/javascript" src="js/helpers.js"></script>
|
||||||
<script type="text/javascript" src="js/api.js"></script>
|
<script type="text/javascript" src="js/api.js"></script>
|
||||||
<script type="text/javascript" src="js/chromium.js"></script>
|
<script type="text/javascript" src="js/chromium.js"></script>
|
||||||
|
|
|
@ -1,26 +1,44 @@
|
||||||
.btn span {
|
.btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
border: 2px solid #7fd0ed;
|
border: 2px solid #acdbf5;
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.btn {
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
padding: 2px;
|
color: #7fd0ed;
|
||||||
border: none;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
.btn:hover, .btn:focus {
|
.btn:hover, .btn:focus {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: none;
|
outline: none;
|
||||||
background-color: #f1fafd;
|
}
|
||||||
|
.btn:hover {
|
||||||
|
background-color: #7fd0ed;
|
||||||
|
border-color: #acdbf5;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
.btn:active {
|
.btn:active {
|
||||||
outline: 2px dashed #acdbf5;
|
outline: 2px dashed #acdbf5;
|
||||||
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
.btn.selected span,
|
.btn.selected ,
|
||||||
.btn:active span {
|
.btn:active {
|
||||||
background-color: #7fd0ed;
|
background-color: #7fd0ed;
|
||||||
border: 2px solid #acdbf5;
|
border: 2px solid #acdbf5;
|
||||||
color: #fff;
|
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;
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
.conversation {
|
.conversation {
|
||||||
|
box-sizing: border-box;
|
||||||
position: relative;
|
position: relative;
|
||||||
max-width: 400px;
|
min-height: 64px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
@ -47,9 +48,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation .header {
|
.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;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
left: 10px;
|
left: 10px;
|
||||||
|
@ -69,18 +73,20 @@
|
||||||
.collapsable {
|
.collapsable {
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
border: 2px solid #acdbf5;
|
border: 2px solid #acdbf5;
|
||||||
padding: 1em 0em;
|
padding: 1em 0em 0em;
|
||||||
line-height: 1.2em;
|
line-height: 1.2em;
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
border-radius: 30px;
|
border-radius: 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.messages + form {
|
.collapsable form {
|
||||||
text-align: right;
|
margin: 0;
|
||||||
|
padding: 1em;
|
||||||
}
|
}
|
||||||
|
.collapsable input[type=text] {
|
||||||
.conversation form {
|
box-sizing: border-box;
|
||||||
margin-top: 0.5em;
|
width: 100%;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-text {
|
.message-text {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
input[type=text], textarea {
|
input[type=text], textarea {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 7px;
|
padding: 0.5em;
|
||||||
border: 2px solid #7fd0ed;
|
border: 2px solid #7fd0ed;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: #fafafa;
|
background-color: #fafafa;
|
||||||
|
@ -9,6 +9,7 @@ input[type=text], textarea {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
input[type=submit]:focus,
|
||||||
input[type=text]:focus {
|
input[type=text]:focus {
|
||||||
outline: 2px dashed #acdbf5;
|
outline: 2px dashed #acdbf5;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
|
|
|
@ -35,22 +35,19 @@ header {
|
||||||
padding: 5px 0;
|
padding: 5px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
form.compose {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
label {
|
||||||
float: left;
|
float: left;
|
||||||
margin-right: 1em;
|
margin-right: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
form.compose input[type=text], form.compose textarea {
|
#compose-cancel {
|
||||||
margin: 0.5em 0;
|
|
||||||
}
|
|
||||||
#send input[type=submit] {
|
|
||||||
float: right;
|
float: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#send .conversation {
|
||||||
|
padding: 0.3em 1em;
|
||||||
|
}
|
||||||
|
|
||||||
#popup_send_numbers {
|
#popup_send_numbers {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
@ -95,5 +92,19 @@ ul {
|
||||||
li {
|
li {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
#send_link ~ #new-chat-help,
|
||||||
/* Formatting */
|
#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;
|
||||||
|
}
|
||||||
|
|
|
@ -4,8 +4,18 @@ var Whisper = Whisper || {};
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var Message = Backbone.Model.extend({
|
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() {
|
toProto: function() {
|
||||||
return new textsecure.protos.PushMessageContentProtobuf({body: this.get('body')});
|
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++)
|
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));
|
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({
|
var m = Whisper.Messages.add({
|
||||||
person: decrypted.pushMessage.source,
|
person: decrypted.pushMessage.source,
|
||||||
group: decrypted.message.group,
|
threadId: thread.id,
|
||||||
body: decrypted.message.body,
|
body: decrypted.message.body,
|
||||||
attachments: attachments,
|
attachments: attachments,
|
||||||
type: 'incoming',
|
type: 'incoming',
|
||||||
timestamp: decrypted.message.timestamp
|
timestamp: decrypted.message.timestamp
|
||||||
});
|
});
|
||||||
m.save();
|
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;
|
return m;
|
||||||
},
|
},
|
||||||
|
|
||||||
addOutgoingMessage: function(message, recipients) {
|
addOutgoingMessage: function(message, thread) {
|
||||||
var m = Whisper.Messages.add({
|
var m = Whisper.Messages.add({
|
||||||
person: recipients[0], // TODO: groups
|
threadId: thread.id,
|
||||||
body: message,
|
body: message,
|
||||||
type: 'outgoing',
|
type: 'outgoing',
|
||||||
timestamp: new Date().getTime()
|
timestamp: new Date().getTime()
|
||||||
});
|
});
|
||||||
m.save();
|
m.save();
|
||||||
|
thread.trigger('message', m);
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
}))();
|
}))();
|
||||||
|
|
||||||
})()
|
})()
|
||||||
|
|
94
js/models/threads.js
Normal file
94
js/models/threads.js
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}))();
|
||||||
|
})();
|
33
js/popup.js
33
js/popup.js
|
@ -14,49 +14,16 @@
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$('#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() {
|
textsecure.registerOnLoadFunction(function() {
|
||||||
if (textsecure.storage.getUnencrypted("number_id") === undefined) {
|
if (textsecure.storage.getUnencrypted("number_id") === undefined) {
|
||||||
chrome.tabs.create({url: "options.html"});
|
chrome.tabs.create({url: "options.html"});
|
||||||
} else {
|
} else {
|
||||||
$(window).bind('storage', function(e) { Whisper.Messages.fetch(); });
|
|
||||||
Whisper.Messages.fetch();
|
|
||||||
$('.my-number').text(textsecure.storage.getUnencrypted("number_id").split(".")[0]);
|
$('.my-number').text(textsecure.storage.getUnencrypted("number_id").split(".")[0]);
|
||||||
textsecure.storage.putUnencrypted("unreadCount", 0);
|
textsecure.storage.putUnencrypted("unreadCount", 0);
|
||||||
chrome.browserAction.setBadgeText({text: ""});
|
chrome.browserAction.setBadgeText({text: ""});
|
||||||
$("#me").click(function() {
|
$("#me").click(function() {
|
||||||
$('#popup_send_numbers').val($('.my-number').text());
|
$('#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);});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
116
js/views/conversation.js
Normal file
116
js/views/conversation.js
Normal 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
64
js/views/message.js
Normal 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('×');
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
})();
|
|
@ -3,152 +3,71 @@ var Whisper = Whisper || {};
|
||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'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
|
Whisper.ConversationListView = new (Backbone.View.extend({ // singleton
|
||||||
|
|
||||||
tagName: 'ul',
|
tagName: 'ul',
|
||||||
id: 'conversations',
|
id: 'conversations',
|
||||||
initialize: function() {
|
initialize: function() {
|
||||||
this.views = [];
|
this.views = [];
|
||||||
this.messages = Whisper.Messages;
|
this.threads = Whisper.Threads;
|
||||||
this.listenTo(this.messages, 'change:completed', this.render);
|
this.listenTo(this.threads, 'change:completed', this.render); // auto update
|
||||||
this.listenTo(this.messages, 'add', this.addMessage);
|
this.listenTo(this.threads, 'add', this.addThread);
|
||||||
this.listenTo(this.messages, 'reset', this.addAll);
|
this.listenTo(this.threads, 'reset', this.addAll);
|
||||||
this.listenTo(this.messages, 'all', this.render);
|
this.listenTo(this.threads, 'all', this.render);
|
||||||
|
|
||||||
// Suppresses 'add' events with {reset: true} and prevents the app view
|
// Suppresses 'add' events with {reset: true} and prevents the app view
|
||||||
// from being re-rendered for every model. Only renders when the 'reset'
|
// from being re-rendered for every model. Only renders when the 'reset'
|
||||||
// event is triggered at the end of the fetch.
|
// 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'));
|
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) {
|
addThread: function(thread) {
|
||||||
// todo: find the right existing view
|
this.views[thread.id] = new Whisper.ConversationView({model: thread});
|
||||||
var threadId = message.get('person'); // TODO: groups
|
this.$el.prepend(this.views[thread.id].render().el);
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Add all items in the collection at once
|
addAll: function() {
|
||||||
addAll: function () {
|
|
||||||
this.$el.html('');
|
this.$el.html('');
|
||||||
this.messages.each(this.addMessage, this);
|
this.threads.each(this.addThread, this);
|
||||||
},
|
},
|
||||||
}))();
|
}))();
|
||||||
})();
|
})();
|
||||||
|
|
34
js/views/notifications.js
Normal file
34
js/views/notifications.js
Normal 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}); }
|
||||||
|
|
||||||
|
})();
|
28
popup.html
28
popup.html
|
@ -24,29 +24,25 @@
|
||||||
<body data-name="curve25519" data-tools="pnacl" data-configs="Debug Release" data-path="pnacl/{config}">
|
<body data-name="curve25519" data-tools="pnacl" data-configs="Debug Release" data-path="pnacl/{config}">
|
||||||
<header class="clearfix">
|
<header class="clearfix">
|
||||||
<div class='container'>
|
<div class='container'>
|
||||||
<button class='btn selected' id='inbox_link'><span>Inbox</span></button>
|
<button class='btn btn-square' id='send_link'>+</button>
|
||||||
<button class='btn' id='send_link'><span>Compose</span></button>
|
<span class='help' id='new-chat-help'>new message</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class='container'>
|
<div class='container'>
|
||||||
<div id="listener"></div>
|
<div id="listener"></div>
|
||||||
<div id="log"></div>
|
<div id="log"></div>
|
||||||
<div id="send" style="display:none;">
|
<div id="inbox">
|
||||||
<form class="compose" action="#">
|
<form id="send" style="display:none;">
|
||||||
<input id="popup_send_numbers" type='text' placeholder="To">
|
<div class='closed conversation'>
|
||||||
<div class='contacts'>
|
<div class='header'>
|
||||||
<div id='me' class='contact'>
|
<span class='image'></span>
|
||||||
<span class='pic'></span>
|
<input id="send_numbers" type='text' placeholder="+xxxxxxxxxx">
|
||||||
<span class='name'>Me</span>
|
<input type=submit id="compose-create" class='btn btn-square' value='>'>
|
||||||
<span class='number my-number'></span>
|
<button id="compose-cancel" class='btn btn-square'>×</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="popup_send_text"></textarea>
|
|
||||||
<button id="popup_send_button">Send</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div id="inbox">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<script type="text/javascript" src="js/webcrypto.js"></script>
|
<script type="text/javascript" src="js/webcrypto.js"></script>
|
||||||
<script type="text/javascript" src="js/crypto.js"></script>
|
<script type="text/javascript" src="js/crypto.js"></script>
|
||||||
|
@ -56,6 +52,10 @@
|
||||||
<script type="text/javascript" src="js-deps/backbone.js"></script>
|
<script type="text/javascript" src="js-deps/backbone.js"></script>
|
||||||
<script type="text/javascript" src="js-deps/backbone.localStorage.js"></script>
|
<script type="text/javascript" src="js-deps/backbone.localStorage.js"></script>
|
||||||
<script type="text/javascript" src="js/models/messages.js"></script>
|
<script type="text/javascript" src="js/models/messages.js"></script>
|
||||||
|
<script type="text/javascript" src="js/models/threads.js"></script>
|
||||||
|
<script type="text/javascript" src="js/views/notifications.js"></script>
|
||||||
|
<script type="text/javascript" src="js/views/message.js"></script>
|
||||||
|
<script type="text/javascript" src="js/views/conversation.js"></script>
|
||||||
<script type="text/javascript" src="js/views/messages.js"></script>
|
<script type="text/javascript" src="js/views/messages.js"></script>
|
||||||
<script type="text/javascript" src="js-deps/core.js"></script>
|
<script type="text/javascript" src="js-deps/core.js"></script>
|
||||||
<script type="text/javascript" src="js-deps/enc-base64.js"></script>
|
<script type="text/javascript" src="js-deps/enc-base64.js"></script>
|
||||||
|
|
Loading…
Reference in a new issue