diff --git a/background.html b/background.html index 8d021db40877..1d6d25d17b7a 100644 --- a/background.html +++ b/background.html @@ -339,7 +339,7 @@ + diff --git a/js/background.js b/js/background.js index f395ec7631b3..993e04011945 100644 --- a/js/background.js +++ b/js/background.js @@ -182,15 +182,17 @@ SERVER_URL, USERNAME, PASSWORD, mySignalingKey, options ); messageReceiver.addEventListener('message', onMessageReceived); - messageReceiver.addEventListener('receipt', onDeliveryReceipt); + messageReceiver.addEventListener('delivery', onDeliveryReceipt); messageReceiver.addEventListener('contact', onContactReceived); messageReceiver.addEventListener('group', onGroupReceived); messageReceiver.addEventListener('sent', onSentMessage); + messageReceiver.addEventListener('readSync', onReadSync); messageReceiver.addEventListener('read', onReadReceipt); messageReceiver.addEventListener('verified', onVerified); messageReceiver.addEventListener('error', onError); messageReceiver.addEventListener('empty', onEmpty); messageReceiver.addEventListener('progress', onProgress); + messageReceiver.addEventListener('settings', onSettings); window.textsecure.messaging = new textsecure.MessageSender( SERVER_URL, USERNAME, PASSWORD, CDN_URL @@ -208,6 +210,21 @@ } } + // If we've just upgraded to read receipt support on desktop, kick off a + // one-time configuration sync request to get the read-receipt setting + // from the master device. + var readReceiptConfigurationSync = 'read-receipt-configuration-sync'; + if (!storage.get(readReceiptConfigurationSync)) { + + if (!firstRun && textsecure.storage.user.getDeviceId() != '1') { + textsecure.messaging.sendRequestConfigurationSyncMessage().then(function() { + storage.put(readReceiptConfigurationSync, true); + }).catch(function(e) { + console.log(e); + }); + } + } + if (firstRun === true && textsecure.storage.user.getDeviceId() != '1') { if (!storage.get('theme-setting') && textsecure.storage.get('userAgent') === 'OWI') { storage.put('theme-setting', 'ios'); @@ -223,6 +240,12 @@ console.log('sync timed out'); Whisper.events.trigger('contactsync'); }); + + if (Whisper.Import.isComplete()) { + textsecure.messaging.sendRequestConfigurationSyncMessage().catch(function(e) { + console.log(e); + }); + } } } @@ -248,6 +271,13 @@ view.onProgress(count); } } + function onSettings(ev) { + if (ev.settings.readReceipts) { + storage.put('read-receipt-setting', true); + } else { + storage.put('read-receipt-setting', false); + } + } function onContactReceived(ev) { var details = ev.contactDetails; @@ -366,9 +396,17 @@ var now = new Date().getTime(); var data = ev.data; + var type, id; + if (data.message.group) { + type = 'group'; + id = data.message.group.id; + } else { + type = 'private'; + id = data.destination; + } + if (data.message.flags & textsecure.protobuf.DataMessage.Flags.PROFILE_KEY_UPDATE) { - var id = data.message.group ? data.message.group.id : data.destination; - return ConversationController.getOrCreateAndWait(id, 'private').then(function(convo) { + return ConversationController.getOrCreateAndWait(id, type).then(function(convo) { return convo.save({profileSharing: true}).then(ev.confirm); }); } @@ -391,15 +429,6 @@ return; } - var type, id; - if (data.message.group) { - type = 'group'; - id = data.message.group.id; - } else { - type = 'private'; - id = data.destination; - } - return ConversationController.getOrCreateAndWait(id, type).then(function() { return message.handleDataMessage(data.message, ev.confirm, { initialLoadComplete: initialLoadComplete @@ -528,10 +557,32 @@ function onReadReceipt(ev) { var read_at = ev.timestamp; var timestamp = ev.read.timestamp; - var sender = ev.read.sender; - console.log('read receipt', sender, timestamp); + var reader = ev.read.reader; + console.log('read receipt', reader, timestamp); + + if (!storage.get('read-receipt-setting')) { + return ev.confirm(); + } var receipt = Whisper.ReadReceipts.add({ + reader : reader, + timestamp : timestamp, + read_at : read_at, + }); + + receipt.on('remove', ev.confirm); + + // Calling this directly so we can wait for completion + return Whisper.ReadReceipts.onReceipt(receipt); + } + + function onReadSync(ev) { + var read_at = ev.timestamp; + var timestamp = ev.read.timestamp; + var sender = ev.read.sender; + console.log('read sync', sender, timestamp); + + var receipt = Whisper.ReadSyncs.add({ sender : sender, timestamp : timestamp, read_at : read_at @@ -540,7 +591,7 @@ receipt.on('remove', ev.confirm); // Calling this directly so we can wait for completion - return Whisper.ReadReceipts.onReceipt(receipt); + return Whisper.ReadSyncs.onReceipt(receipt); } function onVerified(ev) { @@ -593,17 +644,16 @@ } function onDeliveryReceipt(ev) { - var pushMessage = ev.proto; - var timestamp = pushMessage.timestamp.toNumber(); + var deliveryReceipt = ev.deliveryReceipt; console.log( 'delivery receipt from', - pushMessage.source + '.' + pushMessage.sourceDevice, - timestamp + deliveryReceipt.source + '.' + deliveryReceipt.sourceDevice, + deliveryReceipt.timestamp ); var receipt = Whisper.DeliveryReceipts.add({ - timestamp: timestamp, - source: pushMessage.source + timestamp: deliveryReceipt.timestamp, + source: deliveryReceipt.source }); receipt.on('remove', ev.confirm); diff --git a/js/delivery_receipts.js b/js/delivery_receipts.js index 90346cfd8e59..e4289a57fbfd 100644 --- a/js/delivery_receipts.js +++ b/js/delivery_receipts.js @@ -41,8 +41,10 @@ }).then(function(message) { if (message) { var deliveries = message.get('delivered') || 0; + var delivered_to = message.get('delivered_to') || []; return new Promise(function(resolve, reject) { message.save({ + delivered_to: _.union(delivered_to, [receipt.get('source')]), delivered: deliveries + 1 }).then(function() { // notify frontend listeners diff --git a/js/libtextsecure.js b/js/libtextsecure.js index b7bc609a346b..2f16f47783c9 100644 --- a/js/libtextsecure.js +++ b/js/libtextsecure.js @@ -37999,9 +37999,13 @@ var TextSecureServer = (function() { return res; }); }, - sendMessages: function(destination, messageArray, timestamp) { + sendMessages: function(destination, messageArray, timestamp, silent) { var jsonData = { messages: messageArray, timestamp: timestamp}; + if (silent) { + jsonData.silent = true; + } + return this.ajax({ call : 'messages', httpType : 'PUT', @@ -38143,7 +38147,8 @@ var TextSecureServer = (function() { provisionMessage.identityKeyPair, provisionMessage.profileKey, deviceName, - provisionMessage.userAgent + provisionMessage.userAgent, + provisionMessage.readReceipts ).then(generateKeys). then(registerKeys). then(registrationDone); @@ -38242,7 +38247,8 @@ var TextSecureServer = (function() { }); }); }, - createAccount: function(number, verificationCode, identityKeyPair, profileKey, deviceName, userAgent) { + createAccount: function(number, verificationCode, identityKeyPair, + profileKey, deviceName, userAgent, readReceipts) { var signalingKey = libsignal.crypto.getRandomBytes(32 + 20); var password = btoa(getString(libsignal.crypto.getRandomBytes(16))); password = password.substring(0, password.length - 2); @@ -38261,6 +38267,7 @@ var TextSecureServer = (function() { textsecure.storage.remove('regionCode'); textsecure.storage.remove('userAgent'); textsecure.storage.remove('profileKey'); + textsecure.storage.remove('read-receipts-setting'); // update our own identity key, which may have changed // if we're relinking after a reinstall on the master device @@ -38283,6 +38290,12 @@ var TextSecureServer = (function() { if (userAgent) { textsecure.storage.put('userAgent', userAgent); } + if (readReceipts) { + textsecure.storage.put('read-receipt-setting', true); + } else { + textsecure.storage.put('read-receipt-setting', false); + } + textsecure.storage.user.setNumberAndDeviceId(number, response.deviceId || 1, deviceName); textsecure.storage.put('regionCode', libphonenumber.util.getRegionCodeForNumber(number)); @@ -38692,9 +38705,13 @@ MessageReceiver.prototype.extend({ }, onDeliveryReceipt: function (envelope) { return new Promise(function(resolve, reject) { - var ev = new Event('receipt'); + var ev = new Event('delivery'); ev.confirm = this.removeFromCache.bind(this, envelope); - ev.proto = envelope; + ev.deliveryReceipt = { + timestamp : envelope.timestamp.toNumber(), + source : envelope.source, + sourceDevice : envelope.sourceDevice + }; this.dispatchAndWait(ev).then(resolve, reject); }.bind(this)); }, @@ -38856,6 +38873,8 @@ MessageReceiver.prototype.extend({ return this.handleNullMessage(envelope, content.nullMessage); } else if (content.callMessage) { return this.handleCallMessage(envelope, content.callMessage); + } else if (content.receiptMessage) { + return this.handleReceiptMessage(envelope, content.receiptMessage); } else { this.removeFromCache(envelope); throw new Error('Unsupported content message'); @@ -38865,6 +38884,33 @@ MessageReceiver.prototype.extend({ console.log('call message from', this.getEnvelopeId(envelope)); this.removeFromCache(envelope); }, + handleReceiptMessage: function(envelope, receiptMessage) { + var results = []; + if (receiptMessage.type === textsecure.protobuf.ReceiptMessage.Type.DELIVERY) { + for (var i = 0; i < receiptMessage.timestamps.length; ++i) { + var ev = new Event('delivery'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.deliveryReceipt = { + timestamp : receiptMessage.timestamps[i].toNumber(), + source : envelope.source, + sourceDevice : envelope.sourceDevice + }; + results.push(this.dispatchAndWait(ev)); + } + } else if (receiptMessage.type === textsecure.protobuf.ReceiptMessage.Type.READ) { + for (var i = 0; i < receiptMessage.timestamps.length; ++i) { + var ev = new Event('read'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.timestamp = envelope.timestamp.toNumber(); + ev.read = { + timestamp : receiptMessage.timestamps[i].toNumber(), + reader : envelope.source + } + results.push(this.dispatchAndWait(ev)); + } + } + return Promise.all(results); + }, handleNullMessage: function(envelope, nullMessage) { console.log('null message from', this.getEnvelopeId(envelope)); this.removeFromCache(envelope); @@ -38906,10 +38952,20 @@ MessageReceiver.prototype.extend({ return this.handleRead(envelope, syncMessage.read); } else if (syncMessage.verified) { return this.handleVerified(envelope, syncMessage.verified); + } else if (syncMessage.settings) { + return this.handleSettings(envelope, syncMessage.settings); } else { throw new Error('Got empty SyncMessage'); } }, + handleSettings: function(envelope, settings) { + var ev = new Event('settings'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.settings = { + readReceipts: settings.readReceipts + }; + return this.dispatchAndWait(ev); + }, handleVerified: function(envelope, verified) { var ev = new Event('verified'); ev.confirm = this.removeFromCache.bind(this, envelope); @@ -38923,7 +38979,7 @@ MessageReceiver.prototype.extend({ handleRead: function(envelope, read) { var results = []; for (var i = 0; i < read.length; ++i) { - var ev = new Event('read'); + var ev = new Event('readSync'); ev.confirm = this.removeFromCache.bind(this, envelope); ev.timestamp = envelope.timestamp.toNumber(); ev.read = { @@ -39236,7 +39292,7 @@ textsecure.MessageReceiver.prototype = { /* * vim: ts=4:sw=4:expandtab */ -function OutgoingMessage(server, timestamp, numbers, message, callback) { +function OutgoingMessage(server, timestamp, numbers, message, silent, callback) { if (message instanceof textsecure.protobuf.DataMessage) { var content = new textsecure.protobuf.Content(); content.dataMessage = message; @@ -39247,6 +39303,7 @@ function OutgoingMessage(server, timestamp, numbers, message, callback) { this.numbers = numbers; this.message = message; // ContentMessage proto this.callback = callback; + this.silent = silent; this.numbersCompleted = 0; this.errors = []; @@ -39329,7 +39386,7 @@ OutgoingMessage.prototype = { }, transmitMessage: function(number, jsonData, timestamp) { - return this.server.sendMessages(number, jsonData, timestamp).catch(function(e) { + return this.server.sendMessages(number, jsonData, timestamp, this.silent).catch(function(e) { if (e.name === 'HTTPError' && (e.code !== 409 && e.code !== 410)) { // 409 and 410 should bubble and be handled by doSendMessage // 404 should throw UnregisteredUserError @@ -39725,13 +39782,13 @@ MessageSender.prototype = { }.bind(this)); }.bind(this)); }, - sendMessageProto: function(timestamp, numbers, message, callback) { + sendMessageProto: function(timestamp, numbers, message, callback, silent) { var rejections = textsecure.storage.get('signedKeyRotationRejected', 0); if (rejections > 5) { throw new textsecure.SignedPreKeyRotationError(numbers, message.toArrayBuffer(), timestamp); } - var outgoing = new OutgoingMessage(this.server, timestamp, numbers, message, callback); + var outgoing = new OutgoingMessage(this.server, timestamp, numbers, message, silent, callback); numbers.forEach(function(number) { this.queueJobForNumber(number, function() { @@ -39752,14 +39809,14 @@ MessageSender.prototype = { }.bind(this)); }, - sendIndividualProto: function(number, proto, timestamp) { + sendIndividualProto: function(number, proto, timestamp, silent) { return new Promise(function(resolve, reject) { this.sendMessageProto(timestamp, [number], proto, function(res) { if (res.errors.length > 0) reject(res); else resolve(res); - }); + }, silent); }.bind(this)); }, @@ -39807,6 +39864,22 @@ MessageSender.prototype = { return this.server.getAvatar(path); }, + sendRequestConfigurationSyncMessage: function() { + var myNumber = textsecure.storage.user.getNumber(); + var myDevice = textsecure.storage.user.getDeviceId(); + if (myDevice != 1) { + var request = new textsecure.protobuf.SyncMessage.Request(); + request.type = textsecure.protobuf.SyncMessage.Request.Type.CONFIGURATION; + var syncMessage = this.createSyncMessage(); + syncMessage.request = request; + var contentMessage = new textsecure.protobuf.Content(); + contentMessage.syncMessage = syncMessage; + + return this.sendIndividualProto(myNumber, contentMessage, Date.now()); + } + + return Promise.resolve(); + }, sendRequestGroupSyncMessage: function() { var myNumber = textsecure.storage.user.getNumber(); var myDevice = textsecure.storage.user.getDeviceId(); @@ -39840,6 +39913,16 @@ MessageSender.prototype = { return Promise.resolve(); }, + sendReadReceipts: function(sender, timestamps) { + var receiptMessage = new textsecure.protobuf.ReceiptMessage(); + receiptMessage.type = textsecure.protobuf.ReceiptMessage.Type.READ; + receiptMessage.timestamps = timestamps; + + var contentMessage = new textsecure.protobuf.Content(); + contentMessage.receiptMessage = receiptMessage; + + return this.sendIndividualProto(sender, contentMessage, Date.now(), true /*silent*/); + }, syncReadMessages: function(reads) { var myNumber = textsecure.storage.user.getNumber(); var myDevice = textsecure.storage.user.getDeviceId(); @@ -40129,24 +40212,26 @@ textsecure.MessageSender = function(url, username, password, cdn_url) { textsecure.replay.registerFunction(sender.sendMessage.bind(sender), textsecure.replay.Type.REBUILD_MESSAGE); textsecure.replay.registerFunction(sender.retrySendMessageProto.bind(sender), textsecure.replay.Type.RETRY_SEND_MESSAGE_PROTO); - this.sendExpirationTimerUpdateToNumber = sender.sendExpirationTimerUpdateToNumber.bind(sender); - this.sendExpirationTimerUpdateToGroup = sender.sendExpirationTimerUpdateToGroup .bind(sender); - this.sendRequestGroupSyncMessage = sender.sendRequestGroupSyncMessage .bind(sender); - this.sendRequestContactSyncMessage = sender.sendRequestContactSyncMessage .bind(sender); - this.sendMessageToNumber = sender.sendMessageToNumber .bind(sender); - this.closeSession = sender.closeSession .bind(sender); - this.sendMessageToGroup = sender.sendMessageToGroup .bind(sender); - this.createGroup = sender.createGroup .bind(sender); - this.updateGroup = sender.updateGroup .bind(sender); - this.addNumberToGroup = sender.addNumberToGroup .bind(sender); - this.setGroupName = sender.setGroupName .bind(sender); - this.setGroupAvatar = sender.setGroupAvatar .bind(sender); - this.leaveGroup = sender.leaveGroup .bind(sender); - this.sendSyncMessage = sender.sendSyncMessage .bind(sender); - this.getProfile = sender.getProfile .bind(sender); - this.getAvatar = sender.getAvatar .bind(sender); - this.syncReadMessages = sender.syncReadMessages .bind(sender); - this.syncVerification = sender.syncVerification .bind(sender); + this.sendExpirationTimerUpdateToNumber = sender.sendExpirationTimerUpdateToNumber.bind(sender); + this.sendExpirationTimerUpdateToGroup = sender.sendExpirationTimerUpdateToGroup .bind(sender); + this.sendRequestGroupSyncMessage = sender.sendRequestGroupSyncMessage .bind(sender); + this.sendRequestContactSyncMessage = sender.sendRequestContactSyncMessage .bind(sender); + this.sendRequestConfigurationSyncMessage = sender.sendRequestConfigurationSyncMessage.bind(sender); + this.sendMessageToNumber = sender.sendMessageToNumber .bind(sender); + this.closeSession = sender.closeSession .bind(sender); + this.sendMessageToGroup = sender.sendMessageToGroup .bind(sender); + this.createGroup = sender.createGroup .bind(sender); + this.updateGroup = sender.updateGroup .bind(sender); + this.addNumberToGroup = sender.addNumberToGroup .bind(sender); + this.setGroupName = sender.setGroupName .bind(sender); + this.setGroupAvatar = sender.setGroupAvatar .bind(sender); + this.leaveGroup = sender.leaveGroup .bind(sender); + this.sendSyncMessage = sender.sendSyncMessage .bind(sender); + this.getProfile = sender.getProfile .bind(sender); + this.getAvatar = sender.getAvatar .bind(sender); + this.syncReadMessages = sender.syncReadMessages .bind(sender); + this.syncVerification = sender.syncVerification .bind(sender); + this.sendReadReceipts = sender.sendReadReceipts .bind(sender); }; textsecure.MessageSender.prototype = { @@ -40328,6 +40413,7 @@ ProvisioningCipher.prototype = { number : provisionMessage.number, provisioningCode : provisionMessage.provisioningCode, userAgent : provisionMessage.userAgent, + readReceipts : provisionMessage.readReceipts }; if (provisionMessage.profileKey) { ret.profileKey = provisionMessage.profileKey.toArrayBuffer(); diff --git a/js/models/conversations.js b/js/models/conversations.js index 74c538eb743c..6ed910e028f0 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -487,15 +487,15 @@ // We mark as read everything older than this message - to clean up old stuff // still marked unread in the database. If the user generally doesn't read in - // the desktop app, so the desktop app only gets read receipts, we can very + // the desktop app, so the desktop app only gets read syncs, we can very // easily end up with messages never marked as read (our previous early read - // receipt handling, read receipts never sent because app was offline) + // sync handling, read syncs never sent because app was offline) - // We queue it because we often get a whole lot of read receipts at once, and + // We queue it because we often get a whole lot of read syncs at once, and // their markRead calls could very easily overlap given the async pull from DB. - // Lastly, we don't send read receipts for any message marked read due to a read - // receipt. That's a notification explosion we don't need. + // Lastly, we don't send read syncs for any message marked read due to a read + // sync. That's a notification explosion we don't need. return this.queueJob(function() { return this.markRead(message.get('received_at'), {sendReadReceipts: false}); }.bind(this)); @@ -583,6 +583,15 @@ return current; }, + getRecipients: function() { + if (this.isPrivate()) { + return [ this.id ]; + } else { + var me = textsecure.storage.user.getNumber(); + return _.without(this.get('members'), me); + } + }, + sendMessage: function(body, attachments) { this.queueJob(function() { var now = Date.now(); @@ -601,7 +610,8 @@ attachments : attachments, sent_at : now, received_at : now, - expireTimer : this.get('expireTimer') + expireTimer : this.get('expireTimer'), + recipients : this.getRecipients() }); if (this.isPrivate()) { message.set({destination: this.id}); @@ -671,6 +681,9 @@ if (this.isPrivate()) { message.set({destination: this.id}); } + if (message.isOutgoing()) { + message.set({recipients: this.getRecipients() }); + } message.save(); if (message.isOutgoing()) { // outgoing update, send it to the number/group var sendFunc; @@ -702,6 +715,7 @@ sent_at : now, received_at : now, destination : this.id, + recipients : this.getRecipients(), flags : textsecure.protobuf.DataMessage.Flags.END_SESSION }); message.send(textsecure.messaging.closeSession(this.id, now)); @@ -793,6 +807,13 @@ if (read.length && options.sendReadReceipts) { console.log('Sending', read.length, 'read receipts'); promises.push(textsecure.messaging.syncReadMessages(read)); + + if (storage.get('read-receipt-setting')) { + _.each(_.groupBy(read, 'sender'), function(receipts, sender) { + var timestamps = _.map(receipts, 'timestamp'); + promises.push(textsecure.messaging.sendReadReceipts(sender, timestamps)); + }); + } } return Promise.all(promises); diff --git a/js/models/messages.js b/js/models/messages.js index b2101b429bb7..6de9667513a1 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -188,6 +188,21 @@ return _.size(this.get('errors')) > 0; }, + getStatus: function(number) { + var read_by = this.get('read_by') || []; + if (read_by.indexOf(number) >= 0) { + return 'read'; + } + var delivered_to = this.get('delivered_to') || []; + if (delivered_to.indexOf(number) >= 0) { + return 'delivered'; + } + var sent_to = this.get('sent_to') || []; + if (sent_to.indexOf(number) >= 0) { + return 'sent'; + } + }, + send: function(promise) { this.trigger('pending'); return promise.then(function(result) { @@ -196,7 +211,12 @@ if (result.dataMessage) { this.set({dataMessage: result.dataMessage}); } - this.save({sent: true, expirationStartTimestamp: now}); + var sent_to = this.get('sent_to') || []; + this.save({ + sent_to: _.union(sent_to, result.successfulNumbers), + sent: true, + expirationStartTimestamp: now + }); this.sendSyncMessage(); }.bind(this)).catch(function(result) { var now = Date.now(); @@ -219,7 +239,12 @@ } else { this.saveErrors(result.errors); if (result.successfulNumbers.length > 0) { - this.set({sent: true, expirationStartTimestamp: now}); + var sent_to = this.get('sent_to') || []; + this.set({ + sent_to: _.union(sent_to, result.successfulNumbers), + sent: true, + expirationStartTimestamp: now + }); promises.push(this.sendSyncMessage()); } promises = promises.concat(_.map(result.errors, function(error) { @@ -428,23 +453,37 @@ } } if (type === 'incoming') { - var readReceipt = Whisper.ReadReceipts.forMessage(message); - if (readReceipt) { + var readSync = Whisper.ReadSyncs.forMessage(message); + if (readSync) { if (message.get('expireTimer') && !message.get('expirationStartTimestamp')) { - message.set('expirationStartTimestamp', readReceipt.get('read_at')); + message.set('expirationStartTimestamp', readSync.get('read_at')); } } - if (readReceipt || message.isExpirationTimerUpdate()) { + if (readSync || message.isExpirationTimerUpdate()) { message.unset('unread'); // This is primarily to allow the conversation to mark all older messages as - // read, as is done when we receive a read receipt for a message we already + // read, as is done when we receive a read sync for a message we already // know about. - Whisper.ReadReceipts.notifyConversation(message); + Whisper.ReadSyncs.notifyConversation(message); } else { conversation.set('unreadCount', conversation.get('unreadCount') + 1); } } + if (type === 'outgoing') { + var reads = Whisper.ReadReceipts.forMessage(conversation, message); + if (reads.length) { + var read_by = reads.map(function(receipt) { + return receipt.get('reader'); + }); + message.set({ + read_by: _.union(message.get('read_by'), read_by) + }); + } + + message.set({recipients: conversation.getRecipients()}); + } + var conversation_timestamp = conversation.get('timestamp'); if (!conversation_timestamp || message.get('sent_at') > conversation_timestamp) { conversation.set({ diff --git a/js/read_receipts.js b/js/read_receipts.js index f0025ef16f28..db5363f97136 100644 --- a/js/read_receipts.js +++ b/js/read_receipts.js @@ -5,45 +5,75 @@ 'use strict'; window.Whisper = window.Whisper || {}; Whisper.ReadReceipts = new (Backbone.Collection.extend({ - forMessage: function(message) { - var receipt = this.findWhere({ - sender: message.get('source'), - timestamp: message.get('sent_at') - }); - if (receipt) { - console.log('Found early read receipt for message'); - this.remove(receipt); - return receipt; + forMessage: function(conversation, message) { + if (!message.isOutgoing()) { + return []; } + var ids = []; + if (conversation.isPrivate()) { + ids = [conversation.id]; + } else { + ids = conversation.get('members'); + } + var receipts = this.filter(function(receipt) { + return receipt.get('timestamp') === message.get('sent_at') + && _.contains(ids, receipt.get('reader')); + }); + if (receipts.length) { + console.log('Found early read receipts for message'); + this.remove(receipts); + } + return receipts; }, onReceipt: function(receipt) { var messages = new Whisper.MessageCollection(); return messages.fetchSentAt(receipt.get('timestamp')).then(function() { + if (messages.length === 0) { return; } var message = messages.find(function(message) { - return (message.isIncoming() && message.isUnread() && - message.get('source') === receipt.get('sender')); + return (message.isOutgoing() && receipt.get('reader') === message.get('conversationId')); }); + if (message) { return message; } + + var groups = new Whisper.GroupCollection(); + return groups.fetchGroups(receipt.get('reader')).then(function() { + var ids = groups.pluck('id'); + ids.push(receipt.get('reader')); + return messages.find(function(message) { + return (message.isOutgoing() && + _.contains(ids, message.get('conversationId'))); + }); + }); + }).then(function(message) { if (message) { - return message.markRead(receipt.get('read_at')).then(function() { - this.notifyConversation(message); - this.remove(receipt); + var read_by = message.get('read_by') || []; + read_by.push(receipt.get('reader')); + return new Promise(function(resolve, reject) { + message.save({ read_by: read_by }).then(function() { + // notify frontend listeners + var conversation = ConversationController.get( + message.get('conversationId') + ); + if (conversation) { + conversation.trigger('read', message); + } + + this.remove(receipt); + resolve(); + }.bind(this), reject); }.bind(this)); } else { console.log( 'No message for read receipt', - receipt.get('sender'), receipt.get('timestamp') + receipt.get('reader'), + receipt.get('timestamp') ); } - }.bind(this)); - }, - notifyConversation: function(message) { - var conversation = ConversationController.get({ - id: message.get('conversationId') + }.bind(this)).catch(function(error) { + console.log( + 'ReadReceipts.onReceipt error:', + error && error.stack ? error.stack : error + ); }); - - if (conversation) { - conversation.onReadMessage(message); - } }, }))(); })(); diff --git a/js/read_syncs.js b/js/read_syncs.js new file mode 100644 index 000000000000..fbb78d785060 --- /dev/null +++ b/js/read_syncs.js @@ -0,0 +1,49 @@ +/* + * vim: ts=4:sw=4:expandtab + */ +;(function() { + 'use strict'; + window.Whisper = window.Whisper || {}; + Whisper.ReadSyncs = new (Backbone.Collection.extend({ + forMessage: function(message) { + var receipt = this.findWhere({ + sender: message.get('source'), + timestamp: message.get('sent_at') + }); + if (receipt) { + console.log('Found early read sync for message'); + this.remove(receipt); + return receipt; + } + }, + onReceipt: function(receipt) { + var messages = new Whisper.MessageCollection(); + return messages.fetchSentAt(receipt.get('timestamp')).then(function() { + var message = messages.find(function(message) { + return (message.isIncoming() && message.isUnread() && + message.get('source') === receipt.get('sender')); + }); + if (message) { + return message.markRead(receipt.get('read_at')).then(function() { + this.notifyConversation(message); + this.remove(receipt); + }.bind(this)); + } else { + console.log( + 'No message for read sync', + receipt.get('sender'), receipt.get('timestamp') + ); + } + }.bind(this)); + }, + notifyConversation: function(message) { + var conversation = ConversationController.get({ + id: message.get('conversationId') + }); + + if (conversation) { + conversation.onReadMessage(message); + } + }, + }))(); +})(); diff --git a/js/views/contact_list_view.js b/js/views/contact_list_view.js index eea919f4f48c..4aacbdbd72c6 100644 --- a/js/views/contact_list_view.js +++ b/js/views/contact_list_view.js @@ -23,7 +23,6 @@ render_attributes: function() { if (this.model.id === this.ourNumber) { return { - class: 'not-clickable', title: i18n('me'), number: this.model.getNumber(), avatar: this.model.getAvatar() @@ -31,6 +30,7 @@ } return { + class: 'clickable', title: this.model.getTitle(), number: this.model.getNumber(), avatar: this.model.getAvatar(), diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 55b4ee82a074..55af530c55a9 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -109,6 +109,7 @@ this.listenTo(this.model, 'change:avatar change:profileAvatar', this.updateAvatar); this.listenTo(this.model, 'newmessage', this.addMessage); this.listenTo(this.model, 'delivered', this.updateMessage); + this.listenTo(this.model, 'read', this.updateMessage); this.listenTo(this.model, 'opened', this.onOpened); this.listenTo(this.model, 'expired', this.onExpired); this.listenTo(this.model, 'prune', this.onPrune); diff --git a/js/views/message_detail_view.js b/js/views/message_detail_view.js index a8e3306efbfd..0de79ec43f69 100644 --- a/js/views/message_detail_view.js +++ b/js/views/message_detail_view.js @@ -67,6 +67,7 @@ var showButton = Boolean(this.outgoingKeyError); return { + status : this.message.getStatus(this.model.id), name : this.model.getTitle(), avatar : this.model.getAvatar(), errors : this.errors, @@ -105,20 +106,22 @@ this.$el.prepend(dialog.el); dialog.focusCancel(); }, - getContact: function(number) { - var c = ConversationController.get(number); - return { - number: number, - title: c ? c.getTitle() : number - }; - }, - contacts: function() { + getContacts: function() { + // Return the set of models to be rendered in this view + var ids; if (this.model.isIncoming()) { - var number = this.model.get('source'); - return [this.conversation.contactCollection.get(number)]; - } else { - return this.conversation.contactCollection.models; + ids = [ this.model.get('source') ]; + } else if (this.model.isOutgoing()) { + ids = this.model.get('recipients'); + if (!ids) { + // older messages have no recipients field + // use the current set of recipients + ids = this.conversation.getRecipients(); + } } + return Promise.all(ids.map(function(number) { + return ConversationController.getOrCreateAndWait(number, 'private'); + })); }, renderContact: function(contact) { var view = new ContactView({ @@ -150,21 +153,15 @@ this.view.$el.prependTo(this.$('.message-container')); this.grouped = _.groupBy(this.model.get('errors'), 'number'); - if (this.model.isOutgoing()) { - var contacts = this.conversation.contactCollection.reject(function(c) { - return c.isMe(); - }); + this.getContacts().then(function(contacts) { _.sortBy(contacts, function(c) { var prefix = this.grouped[c.id] ? '0' : '1'; // this prefix ensures that contacts with errors are listed first; // otherwise it's alphabetical return prefix + c.getTitle(); }.bind(this)).forEach(this.renderContact.bind(this)); - } else { - var c = this.conversation.contactCollection.get(this.model.get('source')); - this.renderContact(c); - } + }.bind(this)); } }); diff --git a/js/views/message_view.js b/js/views/message_view.js index 963cc3f34e2b..90cfca7bf50a 100644 --- a/js/views/message_view.js +++ b/js/views/message_view.js @@ -175,6 +175,7 @@ this.listenTo(this.model, 'change:errors', this.onErrorsChanged); this.listenTo(this.model, 'change:body', this.render); this.listenTo(this.model, 'change:delivered', this.renderDelivered); + this.listenTo(this.model, 'change:read_by', this.renderRead); this.listenTo(this.model, 'change:expirationStartTimestamp', this.renderExpiring); this.listenTo(this.model, 'change', this.renderSent); this.listenTo(this.model, 'change:flags change:group_update', this.renderControl); @@ -271,6 +272,11 @@ renderDelivered: function() { if (this.model.get('delivered')) { this.$el.addClass('delivered'); } }, + renderRead: function() { + if (!_.isEmpty(this.model.get('read_by'))) { + this.$el.addClass('read'); + } + }, onErrorsChanged: function() { if (this.model.isIncoming()) { this.render(); @@ -359,6 +365,7 @@ this.renderSent(); this.renderDelivered(); + this.renderRead(); this.renderErrors(); this.renderExpiring(); diff --git a/libtextsecure/ProvisioningCipher.js b/libtextsecure/ProvisioningCipher.js index b50d7e00b151..080faf9db162 100644 --- a/libtextsecure/ProvisioningCipher.js +++ b/libtextsecure/ProvisioningCipher.js @@ -36,6 +36,7 @@ ProvisioningCipher.prototype = { number : provisionMessage.number, provisioningCode : provisionMessage.provisioningCode, userAgent : provisionMessage.userAgent, + readReceipts : provisionMessage.readReceipts }; if (provisionMessage.profileKey) { ret.profileKey = provisionMessage.profileKey.toArrayBuffer(); diff --git a/libtextsecure/account_manager.js b/libtextsecure/account_manager.js index 01d18c0803c5..61c06a44c6f9 100644 --- a/libtextsecure/account_manager.js +++ b/libtextsecure/account_manager.js @@ -86,7 +86,8 @@ provisionMessage.identityKeyPair, provisionMessage.profileKey, deviceName, - provisionMessage.userAgent + provisionMessage.userAgent, + provisionMessage.readReceipts ).then(generateKeys). then(registerKeys). then(registrationDone); @@ -185,7 +186,8 @@ }); }); }, - createAccount: function(number, verificationCode, identityKeyPair, profileKey, deviceName, userAgent) { + createAccount: function(number, verificationCode, identityKeyPair, + profileKey, deviceName, userAgent, readReceipts) { var signalingKey = libsignal.crypto.getRandomBytes(32 + 20); var password = btoa(getString(libsignal.crypto.getRandomBytes(16))); password = password.substring(0, password.length - 2); @@ -204,6 +206,7 @@ textsecure.storage.remove('regionCode'); textsecure.storage.remove('userAgent'); textsecure.storage.remove('profileKey'); + textsecure.storage.remove('read-receipts-setting'); // update our own identity key, which may have changed // if we're relinking after a reinstall on the master device @@ -226,6 +229,12 @@ if (userAgent) { textsecure.storage.put('userAgent', userAgent); } + if (readReceipts) { + textsecure.storage.put('read-receipt-setting', true); + } else { + textsecure.storage.put('read-receipt-setting', false); + } + textsecure.storage.user.setNumberAndDeviceId(number, response.deviceId || 1, deviceName); textsecure.storage.put('regionCode', libphonenumber.util.getRegionCodeForNumber(number)); diff --git a/libtextsecure/api.js b/libtextsecure/api.js index 62bb8782bfcc..9910ef7863d1 100644 --- a/libtextsecure/api.js +++ b/libtextsecure/api.js @@ -357,9 +357,13 @@ var TextSecureServer = (function() { return res; }); }, - sendMessages: function(destination, messageArray, timestamp) { + sendMessages: function(destination, messageArray, timestamp, silent) { var jsonData = { messages: messageArray, timestamp: timestamp}; + if (silent) { + jsonData.silent = true; + } + return this.ajax({ call : 'messages', httpType : 'PUT', diff --git a/libtextsecure/message_receiver.js b/libtextsecure/message_receiver.js index 2f96cc5e2c42..8aa4c48be2d2 100644 --- a/libtextsecure/message_receiver.js +++ b/libtextsecure/message_receiver.js @@ -338,9 +338,13 @@ MessageReceiver.prototype.extend({ }, onDeliveryReceipt: function (envelope) { return new Promise(function(resolve, reject) { - var ev = new Event('receipt'); + var ev = new Event('delivery'); ev.confirm = this.removeFromCache.bind(this, envelope); - ev.proto = envelope; + ev.deliveryReceipt = { + timestamp : envelope.timestamp.toNumber(), + source : envelope.source, + sourceDevice : envelope.sourceDevice + }; this.dispatchAndWait(ev).then(resolve, reject); }.bind(this)); }, @@ -502,6 +506,8 @@ MessageReceiver.prototype.extend({ return this.handleNullMessage(envelope, content.nullMessage); } else if (content.callMessage) { return this.handleCallMessage(envelope, content.callMessage); + } else if (content.receiptMessage) { + return this.handleReceiptMessage(envelope, content.receiptMessage); } else { this.removeFromCache(envelope); throw new Error('Unsupported content message'); @@ -511,6 +517,33 @@ MessageReceiver.prototype.extend({ console.log('call message from', this.getEnvelopeId(envelope)); this.removeFromCache(envelope); }, + handleReceiptMessage: function(envelope, receiptMessage) { + var results = []; + if (receiptMessage.type === textsecure.protobuf.ReceiptMessage.Type.DELIVERY) { + for (var i = 0; i < receiptMessage.timestamps.length; ++i) { + var ev = new Event('delivery'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.deliveryReceipt = { + timestamp : receiptMessage.timestamps[i].toNumber(), + source : envelope.source, + sourceDevice : envelope.sourceDevice + }; + results.push(this.dispatchAndWait(ev)); + } + } else if (receiptMessage.type === textsecure.protobuf.ReceiptMessage.Type.READ) { + for (var i = 0; i < receiptMessage.timestamps.length; ++i) { + var ev = new Event('read'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.timestamp = envelope.timestamp.toNumber(); + ev.read = { + timestamp : receiptMessage.timestamps[i].toNumber(), + reader : envelope.source + } + results.push(this.dispatchAndWait(ev)); + } + } + return Promise.all(results); + }, handleNullMessage: function(envelope, nullMessage) { console.log('null message from', this.getEnvelopeId(envelope)); this.removeFromCache(envelope); @@ -552,10 +585,20 @@ MessageReceiver.prototype.extend({ return this.handleRead(envelope, syncMessage.read); } else if (syncMessage.verified) { return this.handleVerified(envelope, syncMessage.verified); + } else if (syncMessage.settings) { + return this.handleSettings(envelope, syncMessage.settings); } else { throw new Error('Got empty SyncMessage'); } }, + handleSettings: function(envelope, settings) { + var ev = new Event('settings'); + ev.confirm = this.removeFromCache.bind(this, envelope); + ev.settings = { + readReceipts: settings.readReceipts + }; + return this.dispatchAndWait(ev); + }, handleVerified: function(envelope, verified) { var ev = new Event('verified'); ev.confirm = this.removeFromCache.bind(this, envelope); @@ -569,7 +612,7 @@ MessageReceiver.prototype.extend({ handleRead: function(envelope, read) { var results = []; for (var i = 0; i < read.length; ++i) { - var ev = new Event('read'); + var ev = new Event('readSync'); ev.confirm = this.removeFromCache.bind(this, envelope); ev.timestamp = envelope.timestamp.toNumber(); ev.read = { diff --git a/libtextsecure/outgoing_message.js b/libtextsecure/outgoing_message.js index b97d5ee90932..2b68762b557b 100644 --- a/libtextsecure/outgoing_message.js +++ b/libtextsecure/outgoing_message.js @@ -1,7 +1,7 @@ /* * vim: ts=4:sw=4:expandtab */ -function OutgoingMessage(server, timestamp, numbers, message, callback) { +function OutgoingMessage(server, timestamp, numbers, message, silent, callback) { if (message instanceof textsecure.protobuf.DataMessage) { var content = new textsecure.protobuf.Content(); content.dataMessage = message; @@ -12,6 +12,7 @@ function OutgoingMessage(server, timestamp, numbers, message, callback) { this.numbers = numbers; this.message = message; // ContentMessage proto this.callback = callback; + this.silent = silent; this.numbersCompleted = 0; this.errors = []; @@ -94,7 +95,7 @@ OutgoingMessage.prototype = { }, transmitMessage: function(number, jsonData, timestamp) { - return this.server.sendMessages(number, jsonData, timestamp).catch(function(e) { + return this.server.sendMessages(number, jsonData, timestamp, this.silent).catch(function(e) { if (e.name === 'HTTPError' && (e.code !== 409 && e.code !== 410)) { // 409 and 410 should bubble and be handled by doSendMessage // 404 should throw UnregisteredUserError diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index 37ff61184195..b0fb6b81c569 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -246,13 +246,13 @@ MessageSender.prototype = { }.bind(this)); }.bind(this)); }, - sendMessageProto: function(timestamp, numbers, message, callback) { + sendMessageProto: function(timestamp, numbers, message, callback, silent) { var rejections = textsecure.storage.get('signedKeyRotationRejected', 0); if (rejections > 5) { throw new textsecure.SignedPreKeyRotationError(numbers, message.toArrayBuffer(), timestamp); } - var outgoing = new OutgoingMessage(this.server, timestamp, numbers, message, callback); + var outgoing = new OutgoingMessage(this.server, timestamp, numbers, message, silent, callback); numbers.forEach(function(number) { this.queueJobForNumber(number, function() { @@ -273,14 +273,14 @@ MessageSender.prototype = { }.bind(this)); }, - sendIndividualProto: function(number, proto, timestamp) { + sendIndividualProto: function(number, proto, timestamp, silent) { return new Promise(function(resolve, reject) { this.sendMessageProto(timestamp, [number], proto, function(res) { if (res.errors.length > 0) reject(res); else resolve(res); - }); + }, silent); }.bind(this)); }, @@ -328,6 +328,22 @@ MessageSender.prototype = { return this.server.getAvatar(path); }, + sendRequestConfigurationSyncMessage: function() { + var myNumber = textsecure.storage.user.getNumber(); + var myDevice = textsecure.storage.user.getDeviceId(); + if (myDevice != 1) { + var request = new textsecure.protobuf.SyncMessage.Request(); + request.type = textsecure.protobuf.SyncMessage.Request.Type.CONFIGURATION; + var syncMessage = this.createSyncMessage(); + syncMessage.request = request; + var contentMessage = new textsecure.protobuf.Content(); + contentMessage.syncMessage = syncMessage; + + return this.sendIndividualProto(myNumber, contentMessage, Date.now()); + } + + return Promise.resolve(); + }, sendRequestGroupSyncMessage: function() { var myNumber = textsecure.storage.user.getNumber(); var myDevice = textsecure.storage.user.getDeviceId(); @@ -361,6 +377,16 @@ MessageSender.prototype = { return Promise.resolve(); }, + sendReadReceipts: function(sender, timestamps) { + var receiptMessage = new textsecure.protobuf.ReceiptMessage(); + receiptMessage.type = textsecure.protobuf.ReceiptMessage.Type.READ; + receiptMessage.timestamps = timestamps; + + var contentMessage = new textsecure.protobuf.Content(); + contentMessage.receiptMessage = receiptMessage; + + return this.sendIndividualProto(sender, contentMessage, Date.now(), true /*silent*/); + }, syncReadMessages: function(reads) { var myNumber = textsecure.storage.user.getNumber(); var myDevice = textsecure.storage.user.getDeviceId(); @@ -650,24 +676,26 @@ textsecure.MessageSender = function(url, username, password, cdn_url) { textsecure.replay.registerFunction(sender.sendMessage.bind(sender), textsecure.replay.Type.REBUILD_MESSAGE); textsecure.replay.registerFunction(sender.retrySendMessageProto.bind(sender), textsecure.replay.Type.RETRY_SEND_MESSAGE_PROTO); - this.sendExpirationTimerUpdateToNumber = sender.sendExpirationTimerUpdateToNumber.bind(sender); - this.sendExpirationTimerUpdateToGroup = sender.sendExpirationTimerUpdateToGroup .bind(sender); - this.sendRequestGroupSyncMessage = sender.sendRequestGroupSyncMessage .bind(sender); - this.sendRequestContactSyncMessage = sender.sendRequestContactSyncMessage .bind(sender); - this.sendMessageToNumber = sender.sendMessageToNumber .bind(sender); - this.closeSession = sender.closeSession .bind(sender); - this.sendMessageToGroup = sender.sendMessageToGroup .bind(sender); - this.createGroup = sender.createGroup .bind(sender); - this.updateGroup = sender.updateGroup .bind(sender); - this.addNumberToGroup = sender.addNumberToGroup .bind(sender); - this.setGroupName = sender.setGroupName .bind(sender); - this.setGroupAvatar = sender.setGroupAvatar .bind(sender); - this.leaveGroup = sender.leaveGroup .bind(sender); - this.sendSyncMessage = sender.sendSyncMessage .bind(sender); - this.getProfile = sender.getProfile .bind(sender); - this.getAvatar = sender.getAvatar .bind(sender); - this.syncReadMessages = sender.syncReadMessages .bind(sender); - this.syncVerification = sender.syncVerification .bind(sender); + this.sendExpirationTimerUpdateToNumber = sender.sendExpirationTimerUpdateToNumber.bind(sender); + this.sendExpirationTimerUpdateToGroup = sender.sendExpirationTimerUpdateToGroup .bind(sender); + this.sendRequestGroupSyncMessage = sender.sendRequestGroupSyncMessage .bind(sender); + this.sendRequestContactSyncMessage = sender.sendRequestContactSyncMessage .bind(sender); + this.sendRequestConfigurationSyncMessage = sender.sendRequestConfigurationSyncMessage.bind(sender); + this.sendMessageToNumber = sender.sendMessageToNumber .bind(sender); + this.closeSession = sender.closeSession .bind(sender); + this.sendMessageToGroup = sender.sendMessageToGroup .bind(sender); + this.createGroup = sender.createGroup .bind(sender); + this.updateGroup = sender.updateGroup .bind(sender); + this.addNumberToGroup = sender.addNumberToGroup .bind(sender); + this.setGroupName = sender.setGroupName .bind(sender); + this.setGroupAvatar = sender.setGroupAvatar .bind(sender); + this.leaveGroup = sender.leaveGroup .bind(sender); + this.sendSyncMessage = sender.sendSyncMessage .bind(sender); + this.getProfile = sender.getProfile .bind(sender); + this.getAvatar = sender.getAvatar .bind(sender); + this.syncReadMessages = sender.syncReadMessages .bind(sender); + this.syncVerification = sender.syncVerification .bind(sender); + this.sendReadReceipts = sender.sendReadReceipts .bind(sender); }; textsecure.MessageSender.prototype = { diff --git a/protos/DeviceMessages.proto b/protos/DeviceMessages.proto index 1322c159d3e8..d63739ca5d17 100644 --- a/protos/DeviceMessages.proto +++ b/protos/DeviceMessages.proto @@ -15,5 +15,6 @@ message ProvisionMessage { optional string number = 3; optional string provisioningCode = 4; optional string userAgent = 5; - optional bytes profileKey = 6; + optional bytes profileKey = 6; + optional bool readReceipts = 7; } diff --git a/protos/IncomingPushMessageSignal.proto b/protos/IncomingPushMessageSignal.proto index 76070e062f7e..02160e5f3876 100644 --- a/protos/IncomingPushMessageSignal.proto +++ b/protos/IncomingPushMessageSignal.proto @@ -22,10 +22,21 @@ message Envelope { } message Content { - optional DataMessage dataMessage = 1; - optional SyncMessage syncMessage = 2; - optional CallMessage callMessage = 3; - optional NullMessage nullMessage = 4; + optional DataMessage dataMessage = 1; + optional SyncMessage syncMessage = 2; + optional CallMessage callMessage = 3; + optional NullMessage nullMessage = 4; + optional ReceiptMessage receiptMessage = 5; +} + +message ReceiptMessage { + enum Type { + DELIVERY = 0; + READ = 1; + } + + optional Type type = 1; + repeated uint64 timestamps = 2; } message NullMessage { @@ -117,10 +128,11 @@ message SyncMessage { message Request { enum Type { - UNKNOWN = 0; - CONTACTS = 1; - GROUPS = 2; - BLOCKED = 3; + UNKNOWN = 0; + CONTACTS = 1; + GROUPS = 2; + BLOCKED = 3; + CONFIGURATION = 4; } optional Type type = 1; @@ -131,6 +143,10 @@ message SyncMessage { optional uint64 timestamp = 2; } + message Settings { + optional bool readReceipts = 1; + } + optional Sent sent = 1; optional Contacts contacts = 2; optional Groups groups = 3; @@ -139,6 +155,7 @@ message SyncMessage { optional Blocked blocked = 6; optional Verified verified = 7; optional bytes padding = 8; + optional Settings settings = 9; } message AttachmentPointer { diff --git a/stylesheets/_conversation.scss b/stylesheets/_conversation.scss index 2be88d5cfd74..750fd19b2273 100644 --- a/stylesheets/_conversation.scss +++ b/stylesheets/_conversation.scss @@ -253,6 +253,7 @@ padding: 0 36px; margin-bottom: 5px; + .status-icon-container, .error-icon-container { float: right; } @@ -390,6 +391,28 @@ li.entry .error-icon-container { white-space: nowrap; } +.status { + width: 18px; + height: 18px; +} +.sent .status { + display: inline-block; + @include color-svg('../images/check.svg', black); +} +.delivered .status { + display: inline-block; + @include color-svg('../images/double-check.svg', black); +} +.read .status { + display: inline-block; + @include color-svg('../images/double-check.svg', $blue); +} +.pending .status { + display: inline-block; + background: none; + &:before { content: '...'; } +} + .message-container, .message-list { list-style: none; @@ -481,24 +504,6 @@ li.entry .error-icon-container { } } - .status { - width: 18px; - height: 18px; - } - .sent .status { - display: inline-block; - @include color-svg('../images/check.svg', black); - } - .delivered .status { - display: inline-block; - @include color-svg('../images/double-check.svg', black); - } - .pending .status { - display: inline-block; - background: none; - &:before { content: '...'; } - } - .incoming { .avatar, .bubble { float: left; diff --git a/stylesheets/_global.scss b/stylesheets/_global.scss index b21b097733e6..e62547d7ba67 100644 --- a/stylesheets/_global.scss +++ b/stylesheets/_global.scss @@ -392,7 +392,6 @@ $avatar-size: 44px; margin: 0 0 0 $left-margin; width: calc(100% - #{$avatar-size} - #{$left-margin} - #{(4/14) + em}); text-align: left; - cursor: pointer; p { overflow-x: hidden; @@ -413,8 +412,8 @@ $avatar-size: 44px; font-size: $font-size-small; } - &.not-clickable { - cursor: default; + &.clickable { + cursor: pointer; } .verified-icon { diff --git a/stylesheets/android-dark.scss b/stylesheets/android-dark.scss index 6b2cc0b01a9c..a72ab9fdb625 100644 --- a/stylesheets/android-dark.scss +++ b/stylesheets/android-dark.scss @@ -133,6 +133,10 @@ $text-dark_l2: darken($text-dark, 30%); display: inline-block; @include color-svg('../images/double-check.svg', white); } + .read .status { + display: inline-block; + @include color-svg('../images/double-check.svg', $blue); + } .file-input .paperclip:before { content: ''; display: inline-block; diff --git a/stylesheets/manifest.css b/stylesheets/manifest.css index d850a44d89b4..e1d5f51b6745 100644 --- a/stylesheets/manifest.css +++ b/stylesheets/manifest.css @@ -376,8 +376,7 @@ button.hamburger { display: inline-block; margin: 0 0 0 8px; width: calc(100% - 44px - 8px - 0.2857142857em); - text-align: left; - cursor: pointer; } + text-align: left; } .contact-details p { overflow-x: hidden; text-overflow: ellipsis; } @@ -391,8 +390,8 @@ button.hamburger { .contact-details .number { color: #616161; font-size: 0.9285714286em; } - .contact-details.not-clickable { - cursor: default; } + .contact-details.clickable { + cursor: pointer; } .contact-details .verified-icon { -webkit-mask: url("../images/verified-check.svg") no-repeat center; -webkit-mask-size: 100%; @@ -1289,6 +1288,7 @@ input.search { .message-detail .contacts .contact-detail { padding: 0 36px; margin-bottom: 5px; } + .message-detail .contacts .contact-detail .status-icon-container, .message-detail .contacts .contact-detail .error-icon-container { float: right; } .message-detail .contacts .contact-detail button.error { @@ -1393,6 +1393,34 @@ li.entry .error-icon-container { margin-right: 3px; white-space: nowrap; } +.status { + width: 18px; + height: 18px; } + +.sent .status { + display: inline-block; + -webkit-mask: url("../images/check.svg") no-repeat center; + -webkit-mask-size: 100%; + background-color: black; } + +.delivered .status { + display: inline-block; + -webkit-mask: url("../images/double-check.svg") no-repeat center; + -webkit-mask-size: 100%; + background-color: black; } + +.read .status { + display: inline-block; + -webkit-mask: url("../images/double-check.svg") no-repeat center; + -webkit-mask-size: 100%; + background-color: #2090ea; } + +.pending .status { + display: inline-block; + background: none; } + .pending .status:before { + content: '...'; } + .message-container, .message-list { list-style: none; } @@ -1472,29 +1500,6 @@ li.entry .error-icon-container { .message-list .meta .timestamp:hover, .message-list .meta .status:hover { opacity: 1.0; } - .message-container .status, - .message-list .status { - width: 18px; - height: 18px; } - .message-container .sent .status, - .message-list .sent .status { - display: inline-block; - -webkit-mask: url("../images/check.svg") no-repeat center; - -webkit-mask-size: 100%; - background-color: black; } - .message-container .delivered .status, - .message-list .delivered .status { - display: inline-block; - -webkit-mask: url("../images/double-check.svg") no-repeat center; - -webkit-mask-size: 100%; - background-color: black; } - .message-container .pending .status, - .message-list .pending .status { - display: inline-block; - background: none; } - .message-container .pending .status:before, - .message-list .pending .status:before { - content: '...'; } .message-container .incoming .avatar, .message-container .incoming .bubble, .message-list .incoming .avatar, .message-list .incoming .bubble { @@ -2352,6 +2357,11 @@ li.entry .error-icon-container { -webkit-mask: url("../images/double-check.svg") no-repeat center; -webkit-mask-size: 100%; background-color: white; } + .android-dark .read .status { + display: inline-block; + -webkit-mask: url("../images/double-check.svg") no-repeat center; + -webkit-mask-size: 100%; + background-color: #2090ea; } .android-dark .file-input .paperclip:before { content: ''; display: inline-block;