123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620 |
- /*
- * vim: ts=4:sw=4:expandtab
- */
- (function () {
- 'use strict';
- window.Whisper = window.Whisper || {};
- // TODO: Factor out private and group subclasses of Conversation
- var COLORS = [
- 'red',
- 'pink',
- 'purple',
- 'deep_purple',
- 'indigo',
- 'blue',
- 'light_blue',
- 'cyan',
- 'teal',
- 'green',
- 'light_green',
- 'orange',
- 'deep_orange',
- 'amber',
- 'blue_grey',
- ];
- Whisper.Conversation = Backbone.Model.extend({
- database: Whisper.Database,
- storeName: 'conversations',
- defaults: function() {
- return { unreadCount : 0 };
- },
- initialize: function() {
- this.contactCollection = new Backbone.Collection();
- this.messageCollection = new Whisper.MessageCollection([], {
- conversation: this
- });
- this.on('change:avatar', this.updateAvatarUrl);
- this.on('destroy', this.revokeAvatarUrl);
- this.on('read', this.onReadMessage);
- this.fetchContacts().then(function() {
- this.contactCollection.each(function(contact) {
- textsecure.storage.protocol.on('keychange:' + contact.id, function() {
- this.addKeyChange(contact.id);
- }.bind(this));
- }.bind(this));
- }.bind(this));
- },
- addKeyChange: function(id) {
- var message = this.messageCollection.add({
- conversationId : this.id,
- type : 'keychange',
- sent_at : this.get('timestamp'),
- received_at : this.get('timestamp'),
- key_changed : id
- });
- message.save();
- },
- onReadMessage: function(message) {
- if (this.messageCollection.get(message.id)) {
- this.messageCollection.get(message.id).fetch();
- }
- return this.getUnread().then(function(unreadMessages) {
- this.save({unreadCount: unreadMessages.length});
- }.bind(this));
- },
- getUnread: function() {
- var conversationId = this.id;
- var unreadMessages = new Whisper.MessageCollection();
- return new Promise(function(resolve) {
- return unreadMessages.fetch({
- index: {
- // 'unread' index
- name : 'unread',
- lower : [conversationId],
- upper : [conversationId, Number.MAX_VALUE],
- }
- }).always(function() {
- resolve(unreadMessages);
- });
- });
- },
- validate: function(attributes, options) {
- var required = ['id', 'type'];
- var missing = _.filter(required, function(attr) { return !attributes[attr]; });
- if (missing.length) { return "Conversation must have " + missing; }
- if (attributes.type !== 'private' && attributes.type !== 'group') {
- return "Invalid conversation type: " + attributes.type;
- }
- var error = this.validateNumber();
- if (error) { return error; }
- this.updateTokens();
- },
- validateNumber: function() {
- if (this.isPrivate()) {
- var regionCode = storage.get('regionCode');
- var number = libphonenumber.util.parseNumber(this.id, regionCode);
- if (number.isValidNumber) {
- this.set({ id: number.e164 });
- } else {
- return number.error || "Invalid phone number";
- }
- }
- },
- updateTokens: function() {
- var tokens = [];
- var name = this.get('name');
- if (typeof name === 'string') {
- tokens.push(name.toLowerCase());
- tokens = tokens.concat(name.trim().toLowerCase().split(/[\s\-_\(\)\+]+/));
- }
- if (this.isPrivate()) {
- var regionCode = storage.get('regionCode');
- var number = libphonenumber.util.parseNumber(this.id, regionCode);
- tokens.push(
- number.nationalNumber,
- number.countryCode + number.nationalNumber
- );
- }
- this.set({tokens: tokens});
- },
- queueJob: function(callback) {
- var previous = this.pending || Promise.resolve();
- var current = this.pending = previous.then(callback, callback);
- current.then(function() {
- if (this.pending === current) {
- delete this.pending;
- }
- }.bind(this));
- return current;
- },
- sendMessage: function(body, attachments) {
- this.queueJob(function() {
- var now = Date.now();
- var message = this.messageCollection.add({
- body : body,
- conversationId : this.id,
- type : 'outgoing',
- attachments : attachments,
- sent_at : now,
- received_at : now,
- expireTimer : this.get('expireTimer')
- });
- if (this.isPrivate()) {
- message.set({destination: this.id});
- }
- message.save();
- this.save({
- unreadCount : 0,
- active_at : now,
- timestamp : now,
- lastMessage : message.getNotificationText()
- });
- var sendFunc;
- if (this.get('type') == 'private') {
- sendFunc = textsecure.messaging.sendMessageToNumber;
- }
- else {
- sendFunc = textsecure.messaging.sendMessageToGroup;
- }
- message.send(sendFunc(this.get('id'), body, attachments, now, this.get('expireTimer')));
- }.bind(this));
- },
- updateLastMessage: function() {
- var lastMessage = this.messageCollection.at(this.messageCollection.length - 1);
- if (lastMessage) {
- this.save({
- lastMessage : lastMessage.getNotificationText(),
- timestamp : lastMessage.get('sent_at')
- });
- } else {
- this.save({ lastMessage: '', timestamp: null });
- }
- },
- addExpirationTimerUpdate: function(expireTimer, source, received_at) {
- received_at = received_at || Date.now();
- this.save({ expireTimer: expireTimer });
- var message = this.messageCollection.add({
- conversationId : this.id,
- type : 'outgoing',
- sent_at : received_at,
- received_at : received_at,
- flags : textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
- expirationTimerUpdate : {
- expireTimer : expireTimer,
- source : source
- }
- });
- if (this.isPrivate()) {
- message.set({destination: this.id});
- }
- message.save();
- return message;
- },
- sendExpirationTimerUpdate: function(time) {
- var message = this.addExpirationTimerUpdate(time, textsecure.storage.user.getNumber());
- var sendFunc;
- if (this.get('type') == 'private') {
- sendFunc = textsecure.messaging.sendExpirationTimerUpdateToNumber;
- }
- else {
- sendFunc = textsecure.messaging.sendExpirationTimerUpdateToGroup;
- }
- message.send(sendFunc(this.get('id'), this.get('expireTimer'), message.get('sent_at')));
- },
- isSearchable: function() {
- return !this.get('left') || !!this.get('lastMessage');
- },
- endSession: function() {
- if (this.isPrivate()) {
- var now = Date.now();
- var message = this.messageCollection.create({
- conversationId : this.id,
- type : 'outgoing',
- sent_at : now,
- received_at : now,
- destination : this.id,
- flags : textsecure.protobuf.DataMessage.Flags.END_SESSION
- });
- message.send(textsecure.messaging.closeSession(this.id, now));
- }
- },
- updateGroup: function(group_update) {
- if (this.isPrivate()) {
- throw new Error("Called update group on private conversation");
- }
- if (group_update === undefined) {
- group_update = this.pick(['name', 'avatar', 'members']);
- }
- var now = Date.now();
- var message = this.messageCollection.create({
- conversationId : this.id,
- type : 'outgoing',
- sent_at : now,
- received_at : now,
- group_update : group_update
- });
- message.send(textsecure.messaging.updateGroup(
- this.id,
- this.get('name'),
- this.get('avatar'),
- this.get('members')
- ));
- },
- leaveGroup: function() {
- var now = Date.now();
- if (this.get('type') === 'group') {
- this.save({left: true});
- var message = this.messageCollection.create({
- group_update: { left: 'You' },
- conversationId : this.id,
- type : 'outgoing',
- sent_at : now,
- received_at : now
- });
- message.send(textsecure.messaging.leaveGroup(this.id));
- }
- },
- markRead: function() {
- if (this.get('unreadCount') > 0) {
- this.save({ unreadCount: 0 });
- var conversationId = this.id;
- Whisper.Notifications.remove(Whisper.Notifications.where({
- conversationId: conversationId
- }));
- this.getUnread().then(function(unreadMessages) {
- var read = unreadMessages.map(function(m) {
- if (this.messageCollection.get(m.id)) {
- m = this.messageCollection.get(m.id);
- }
- m.markRead();
- return {
- sender : m.get('source'),
- timestamp : m.get('sent_at')
- };
- }.bind(this));
- if (read.length > 0) {
- console.log('Sending', read.length, 'read receipts');
- textsecure.messaging.syncReadMessages(read);
- }
- }.bind(this));
- }
- },
- fetchMessages: function() {
- if (!this.id) { return false; }
- return this.messageCollection.fetchConversation(this.id);
- },
- fetchContacts: function(options) {
- return new Promise(function(resolve) {
- if (this.isPrivate()) {
- this.contactCollection.reset([this]);
- resolve();
- } else {
- var promises = [];
- var members = this.get('members') || [];
- this.contactCollection.reset(
- members.map(function(number) {
- var c = ConversationController.create({
- id : number,
- type : 'private'
- });
- promises.push(new Promise(function(resolve) {
- c.fetch().always(resolve);
- }));
- return c;
- }.bind(this))
- );
- resolve(Promise.all(promises));
- }
- }.bind(this));
- },
- destroyMessages: function() {
- this.messageCollection.fetch({
- index: {
- // 'conversation' index on [conversationId, received_at]
- name : 'conversation',
- lower : [this.id],
- upper : [this.id, Number.MAX_VALUE],
- }
- }).then(function() {
- var models = this.messageCollection.models;
- this.messageCollection.reset([]);
- _.each(models, function(message) { message.destroy(); });
- this.save({lastMessage: null, timestamp: null}); // archive
- }.bind(this));
- },
- getName: function() {
- if (this.isPrivate()) {
- return this.get('name');
- } else {
- return this.get('name') || 'Unknown group';
- }
- },
- getTitle: function() {
- if (this.isPrivate()) {
- return this.get('name') || this.getNumber();
- } else {
- return this.get('name') || 'Unknown group';
- }
- },
- getNumber: function() {
- if (!this.isPrivate()) {
- return '';
- }
- var number = this.id;
- try {
- var parsedNumber = libphonenumber.parse(number);
- var regionCode = libphonenumber.getRegionCodeForNumber(parsedNumber);
- if (regionCode === storage.get('regionCode')) {
- return libphonenumber.format(parsedNumber, libphonenumber.PhoneNumberFormat.NATIONAL);
- } else {
- return libphonenumber.format(parsedNumber, libphonenumber.PhoneNumberFormat.INTERNATIONAL);
- }
- } catch (e) {
- return number;
- }
- },
- isPrivate: function() {
- return this.get('type') === 'private';
- },
- revokeAvatarUrl: function() {
- if (this.avatarUrl) {
- URL.revokeObjectURL(this.avatarUrl);
- this.avatarUrl = null;
- }
- },
- updateAvatarUrl: function(silent) {
- this.revokeAvatarUrl();
- var avatar = this.get('avatar');
- if (avatar) {
- this.avatarUrl = URL.createObjectURL(
- new Blob([avatar.data], {type: avatar.contentType})
- );
- } else {
- this.avatarUrl = null;
- }
- if (!silent) {
- this.trigger('change');
- }
- },
- getColor: function() {
- var title = this.get('name');
- var color = this.get('color');
- if (!color) {
- if (this.isPrivate()) {
- if (title) {
- color = COLORS[Math.abs(this.hashCode()) % 15];
- } else {
- color = 'grey';
- }
- } else {
- color = 'default';
- }
- }
- return color;
- },
- getAvatar: function() {
- if (this.avatarUrl === undefined) {
- this.updateAvatarUrl(true);
- }
- var title = this.get('name');
- var color = this.getColor();
- if (this.avatarUrl) {
- return { url: this.avatarUrl, color: color };
- } else if (this.isPrivate()) {
- return {
- color: color,
- content: title ? title.trim()[0] : '#'
- };
- } else {
- return { url: '/images/group_default.png', color: color };
- }
- },
- getNotificationIcon: function() {
- return new Promise(function(resolve) {
- var avatar = this.getAvatar();
- if (avatar.url) {
- resolve(avatar.url);
- } else {
- resolve(new Whisper.IdenticonSVGView(avatar).getDataUrl());
- }
- }.bind(this));
- },
- resolveConflicts: function(conflict) {
- var number = conflict.number;
- var identityKey = conflict.identityKey;
- if (this.isPrivate()) {
- number = this.id;
- } else if (!_.include(this.get('members'), number)) {
- throw 'Tried to resolve conflicts for a unknown group member';
- }
- if (!this.messageCollection.hasKeyConflicts()) {
- throw 'No conflicts to resolve';
- }
- return textsecure.storage.protocol.removeIdentityKey(number).then(function() {
- return textsecure.storage.protocol.saveIdentity(number, identityKey).then(function() {
- var promise = Promise.resolve();
- var conflicts = this.messageCollection.filter(function(message) {
- return message.hasKeyConflict(number);
- });
- // group incoming & outgoing
- conflicts = _.groupBy(conflicts, function(m) { return m.get('type'); });
- // sort each group by date and concatenate outgoing after incoming
- conflicts = _.flatten([
- _.sortBy(conflicts.incoming, function(m) { return m.get('received_at'); }),
- _.sortBy(conflicts.outgoing, function(m) { return m.get('received_at'); }),
- ]).forEach(function(message) {
- var resolveConflict = function() {
- return message.resolveConflict(number);
- };
- promise = promise.then(resolveConflict, resolveConflict);
- });
- return promise;
- }.bind(this));
- }.bind(this));
- },
- notify: function(message) {
- if (!message.isIncoming()) {
- return;
- }
- if (window.isOpen() && window.isFocused()) {
- return;
- }
- window.drawAttention();
- var sender = ConversationController.create({
- id: message.get('source'), type: 'private'
- });
- var conversationId = this.id;
- sender.fetch().then(function() {
- sender.getNotificationIcon().then(function(iconUrl) {
- console.log('adding notification');
- Whisper.Notifications.add({
- title : sender.getTitle(),
- message : message.getNotificationText(),
- iconUrl : iconUrl,
- imageUrl : message.getImageUrl(),
- conversationId : conversationId,
- messageId : message.id
- });
- });
- });
- },
- hashCode: function() {
- if (this.hash === undefined) {
- var string = this.getTitle() || '';
- if (string.length === 0) {
- return 0;
- }
- var hash = 0;
- for (var i = 0; i < string.length; i++) {
- hash = ((hash<<5)-hash) + string.charCodeAt(i);
- hash = hash & hash; // Convert to 32bit integer
- }
- this.hash = hash;
- }
- return this.hash;
- }
- });
- Whisper.ConversationCollection = Backbone.Collection.extend({
- database: Whisper.Database,
- storeName: 'conversations',
- model: Whisper.Conversation,
- comparator: function(m) {
- return -m.get('timestamp');
- },
- destroyAll: function () {
- return Promise.all(this.models.map(function(m) {
- return new Promise(function(resolve, reject) {
- m.destroy().then(resolve).fail(reject);
- });
- }));
- },
- search: function(query) {
- query = query.trim().toLowerCase();
- if (query.length > 0) {
- query = query.replace(/[-.\(\)]*/g,'').replace(/^\+(\d*)$/, '$1');
- var lastCharCode = query.charCodeAt(query.length - 1);
- var nextChar = String.fromCharCode(lastCharCode + 1);
- var upper = query.slice(0, -1) + nextChar;
- return new Promise(function(resolve) {
- this.fetch({
- index: {
- name: 'search', // 'search' index on tokens array
- lower: query,
- upper: upper,
- excludeUpper: true
- }
- }).always(resolve);
- }.bind(this));
- }
- },
- fetchAlphabetical: function() {
- return new Promise(function(resolve) {
- this.fetch({
- index: {
- name: 'search', // 'search' index on tokens array
- },
- limit: 100
- }).always(resolve);
- }.bind(this));
- },
- fetchGroups: function(number) {
- return new Promise(function(resolve) {
- this.fetch({
- index: {
- name: 'group',
- only: number
- }
- }).always(resolve);
- }.bind(this));
- },
- fetchActive: function() {
- // Ensures all active conversations are included in this collection,
- // and updates their attributes, but removes nothing.
- return this.fetch({
- index: {
- name: 'inbox', // 'inbox' index on active_at
- order: 'desc' // ORDER timestamp DESC
- // TODO pagination/infinite scroll
- // limit: 10, offset: page*10,
- },
- remove: false
- });
- }
- });
- Whisper.Conversation.COLORS = COLORS.concat(['grey', 'default']).join(' ');
- })();
|