/* global _: false */ /* global Backbone: false */ /* global libphonenumber: false */ /* global ConversationController: false */ /* global libsignal: false */ /* global storage: false */ /* global textsecure: false */ /* global Whisper: false */ /* eslint-disable more/no-then */ // eslint-disable-next-line func-names (function() { 'use strict'; window.Whisper = window.Whisper || {}; const { Util } = window.Signal; const { Conversation, Contact, Errors, Message, PhoneNumber, } = window.Signal.Types; const { upgradeMessageSchema, loadAttachmentData, getAbsoluteAttachmentPath, writeNewAttachmentData, deleteAttachmentData, } = window.Signal.Migrations; const COLORS = [ 'red', 'deep_orange', 'brown', 'pink', 'purple', 'indigo', 'blue', 'teal', 'green', 'light_green', 'blue_grey', ]; Whisper.Conversation = Backbone.Model.extend({ database: Whisper.Database, storeName: 'conversations', defaults() { return { unreadCount: 0, verified: textsecure.storage.protocol.VerifiedStatus.DEFAULT, }; }, idForLogging() { if (this.isPrivate()) { return this.id; } return `group(${this.id})`; }, handleMessageError(message, errors) { this.trigger('messageError', message, errors); }, initialize() { this.ourNumber = textsecure.storage.user.getNumber(); this.verifiedEnum = textsecure.storage.protocol.VerifiedStatus; // This may be overridden by ConversationController.getOrCreate, and signify // our first save to the database. Or first fetch from the database. this.initialPromise = Promise.resolve(); this.contactCollection = new Backbone.Collection(); const collator = new Intl.Collator(); this.contactCollection.comparator = (left, right) => { const leftLower = left.getTitle().toLowerCase(); const rightLower = right.getTitle().toLowerCase(); return collator.compare(leftLower, rightLower); }; this.messageCollection = new Whisper.MessageCollection([], { conversation: this, }); this.messageCollection.on('change:errors', this.handleMessageError, this); this.messageCollection.on('send-error', this.onMessageError, this); const debouncedUpdateLastMessage = _.debounce( this.updateLastMessage.bind(this), 200 ); this.listenTo( this.messageCollection, 'add remove destroy', debouncedUpdateLastMessage ); this.listenTo(this.messageCollection, 'sent', this.updateLastMessage); this.listenTo( this.messageCollection, 'send-error', this.updateLastMessage ); this.on('newmessage', this.updateLastMessage); this.on('change:profileKey', this.onChangeProfileKey); // Listening for out-of-band data updates this.on('delivered', this.updateAndMerge); this.on('read', this.updateAndMerge); this.on('expiration-change', this.updateAndMerge); this.on('expired', this.onExpired); }, isMe() { return this.id === this.ourNumber; }, async cleanup() { await window.Signal.Types.Conversation.deleteExternalFiles( this.attributes, { deleteAttachmentData, } ); }, async updateAndMerge(message) { this.updateLastMessage(); const mergeMessage = () => { const existing = this.messageCollection.get(message.id); if (!existing) { return; } existing.merge(message.attributes); }; await this.inProgressFetch; mergeMessage(); }, async onExpired(message) { this.updateLastMessage(); const removeMessage = () => { const { id } = message; const existing = this.messageCollection.get(id); if (!existing) { return; } window.log.info('Remove expired message from collection', { sentAt: existing.get('sent_at'), }); this.messageCollection.remove(id); existing.trigger('expired'); }; // If a fetch is in progress, then we need to wait until that's complete to // do this removal. Otherwise we could remove from messageCollection, then // the async database fetch could include the removed message. await this.inProgressFetch; removeMessage(); }, addSingleMessage(message) { const model = this.messageCollection.add(message, { merge: true }); model.setToExpire(); return model; }, format() { const { format } = PhoneNumber; const regionCode = storage.get('regionCode'); const color = this.getColor(); return { phoneNumber: format(this.id, { ourRegionCode: regionCode, }), color, avatarPath: this.getAvatarPath(), name: this.getName(), profileName: this.getProfileName(), title: this.getTitle(), }; }, getPropsForListItem() { const result = { ...this.format(), conversationType: this.isPrivate() ? 'direct' : 'group', lastUpdated: this.get('timestamp'), unreadCount: this.get('unreadCount') || 0, isSelected: this.isSelected, lastMessage: { status: this.lastMessageStatus, text: this.lastMessage, }, onClick: () => this.trigger('select', this), }; return result; }, onMessageError() { this.updateVerified(); }, safeGetVerified() { const promise = textsecure.storage.protocol.getVerified(this.id); return promise.catch( () => textsecure.storage.protocol.VerifiedStatus.DEFAULT ); }, async updateVerified() { if (this.isPrivate()) { await this.initialPromise; const verified = await this.safeGetVerified(); // we don't await here because we don't need to wait for this to finish window.Signal.Data.updateConversation( this.id, { verified }, { Conversation: Whisper.Conversation } ); return; } await this.fetchContacts(); await Promise.all( this.contactCollection.map(async contact => { if (!contact.isMe()) { await contact.updateVerified(); } }) ); this.onMemberVerifiedChange(); }, setVerifiedDefault(options) { const { DEFAULT } = this.verifiedEnum; return this.queueJob(() => this._setVerified(DEFAULT, options)); }, setVerified(options) { const { VERIFIED } = this.verifiedEnum; return this.queueJob(() => this._setVerified(VERIFIED, options)); }, setUnverified(options) { const { UNVERIFIED } = this.verifiedEnum; return this.queueJob(() => this._setVerified(UNVERIFIED, options)); }, async _setVerified(verified, providedOptions) { const options = providedOptions || {}; _.defaults(options, { viaSyncMessage: false, viaContactSync: false, key: null, }); const { VERIFIED, UNVERIFIED } = this.verifiedEnum; if (!this.isPrivate()) { throw new Error( 'You cannot verify a group conversation. ' + 'You must verify individual contacts.' ); } const beginningVerified = this.get('verified'); let keyChange; if (options.viaSyncMessage) { // handle the incoming key from the sync messages - need different // behavior if that key doesn't match the current key keyChange = await textsecure.storage.protocol.processVerifiedMessage( this.id, verified, options.key ); } else { keyChange = await textsecure.storage.protocol.setVerified( this.id, verified ); } this.set({ verified }); await window.Signal.Data.updateConversation(this.id, this.attributes, { Conversation: Whisper.Conversation, }); // Three situations result in a verification notice in the conversation: // 1) The message came from an explicit verification in another client (not // a contact sync) // 2) The verification value received by the contact sync is different // from what we have on record (and it's not a transition to UNVERIFIED) // 3) Our local verification status is VERIFIED and it hasn't changed, // but the key did change (Key1/VERIFIED to Key2/VERIFIED - but we don't // want to show DEFAULT->DEFAULT or UNVERIFIED->UNVERIFIED) if ( !options.viaContactSync || (beginningVerified !== verified && verified !== UNVERIFIED) || (keyChange && verified === VERIFIED) ) { await this.addVerifiedChange(this.id, verified === VERIFIED, { local: !options.viaSyncMessage, }); } if (!options.viaSyncMessage) { await this.sendVerifySyncMessage(this.id, verified); } }, sendVerifySyncMessage(number, state) { // Because syncVerification sends a (null) message to the target of the verify and // a sync message to our own devices, we need to send the accessKeys down for both // contacts. So we merge their sendOptions. const { sendOptions } = ConversationController.prepareForSend( this.ourNumber ); const recipientSendOptions = this.getSendOptions(); const options = Object.assign({}, sendOptions, recipientSendOptions); const promise = textsecure.storage.protocol.loadIdentityKey(number); return promise.then(key => this.wrapSend( textsecure.messaging.syncVerification(number, state, key, options) ) ); }, isVerified() { if (this.isPrivate()) { return this.get('verified') === this.verifiedEnum.VERIFIED; } if (!this.contactCollection.length) { return false; } return this.contactCollection.every(contact => { if (contact.isMe()) { return true; } return contact.isVerified(); }); }, isUnverified() { if (this.isPrivate()) { const verified = this.get('verified'); return ( verified !== this.verifiedEnum.VERIFIED && verified !== this.verifiedEnum.DEFAULT ); } if (!this.contactCollection.length) { return true; } return this.contactCollection.any(contact => { if (contact.isMe()) { return false; } return contact.isUnverified(); }); }, getUnverified() { if (this.isPrivate()) { return this.isUnverified() ? new Backbone.Collection([this]) : new Backbone.Collection(); } return new Backbone.Collection( this.contactCollection.filter(contact => { if (contact.isMe()) { return false; } return contact.isUnverified(); }) ); }, setApproved() { if (!this.isPrivate()) { throw new Error( 'You cannot set a group conversation as trusted. ' + 'You must set individual contacts as trusted.' ); } return textsecure.storage.protocol.setApproval(this.id, true); }, safeIsUntrusted() { return textsecure.storage.protocol .isUntrusted(this.id) .catch(() => false); }, isUntrusted() { if (this.isPrivate()) { return this.safeIsUntrusted(); } if (!this.contactCollection.length) { return Promise.resolve(false); } return Promise.all( this.contactCollection.map(contact => { if (contact.isMe()) { return false; } return contact.safeIsUntrusted(); }) ).then(results => _.any(results, result => result)); }, getUntrusted() { // This is a bit ugly because isUntrusted() is async. Could do the work to cache // it locally, but we really only need it for this call. if (this.isPrivate()) { return this.isUntrusted().then(untrusted => { if (untrusted) { return new Backbone.Collection([this]); } return new Backbone.Collection(); }); } return Promise.all( this.contactCollection.map(contact => { if (contact.isMe()) { return [false, contact]; } return Promise.all([contact.isUntrusted(), contact]); }) ).then(results => { const filtered = _.filter(results, result => { const untrusted = result[0]; return untrusted; }); return new Backbone.Collection( _.map(filtered, result => { const contact = result[1]; return contact; }) ); }); }, onMemberVerifiedChange() { // If the verified state of a member changes, our aggregate state changes. // We trigger both events to replicate the behavior of Backbone.Model.set() this.trigger('change:verified'); this.trigger('change'); }, toggleVerified() { if (this.isVerified()) { return this.setVerifiedDefault(); } return this.setVerified(); }, async addKeyChange(keyChangedId) { window.log.info( 'adding key change advisory for', this.idForLogging(), keyChangedId, this.get('timestamp') ); const timestamp = Date.now(); const message = { conversationId: this.id, type: 'keychange', sent_at: this.get('timestamp'), received_at: timestamp, key_changed: keyChangedId, unread: 1, }; const id = await window.Signal.Data.saveMessage(message, { Message: Whisper.Message, }); this.trigger( 'newmessage', new Whisper.Message({ ...message, id, }) ); }, async addVerifiedChange(verifiedChangeId, verified, providedOptions) { const options = providedOptions || {}; _.defaults(options, { local: true }); if (this.isMe()) { window.log.info( 'refusing to add verified change advisory for our own number' ); return; } const lastMessage = this.get('timestamp') || Date.now(); window.log.info( 'adding verified change advisory for', this.idForLogging(), verifiedChangeId, lastMessage ); const timestamp = Date.now(); const message = { conversationId: this.id, type: 'verified-change', sent_at: lastMessage, received_at: timestamp, verifiedChanged: verifiedChangeId, verified, local: options.local, unread: 1, }; const id = await window.Signal.Data.saveMessage(message, { Message: Whisper.Message, }); this.trigger( 'newmessage', new Whisper.Message({ ...message, id, }) ); if (this.isPrivate()) { ConversationController.getAllGroupsInvolvingId(this.id).then(groups => { _.forEach(groups, group => { group.addVerifiedChange(this.id, verified, options); }); }); } }, async onReadMessage(message, readAt) { const existing = this.messageCollection.get(message.id); if (existing) { const fetched = await window.Signal.Data.getMessageById(existing.id, { Message: Whisper.Message, }); existing.merge(fetched); } // 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 syncs, we can very // easily end up with messages never marked as read (our previous early read // sync handling, read syncs never sent because app was offline) // 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 syncs for any message marked read due to a read // sync. That's a notification explosion we don't need. return this.queueJob(() => this.markRead(message.get('received_at'), { sendReadReceipts: false, readAt, }) ); }, getUnread() { return window.Signal.Data.getUnreadByConversation(this.id, { MessageCollection: Whisper.MessageCollection, }); }, validate(attributes) { const required = ['id', 'type']; const missing = _.filter(required, attr => !attributes[attr]); if (missing.length) { return `Conversation must have ${missing}`; } if (attributes.type !== 'private' && attributes.type !== 'group') { return `Invalid conversation type: ${attributes.type}`; } const error = this.validateNumber(); if (error) { return error; } return null; }, validateNumber() { if (this.isPrivate()) { const regionCode = storage.get('regionCode'); const number = libphonenumber.util.parseNumber(this.id, regionCode); if (number.isValidNumber) { this.set({ id: number.e164 }); return null; } return number.error || 'Invalid phone number'; } return null; }, queueJob(callback) { const previous = this.pending || Promise.resolve(); const taskWithTimeout = textsecure.createTaskWithTimeout( callback, `conversation ${this.idForLogging()}` ); this.pending = previous.then(taskWithTimeout, taskWithTimeout); const current = this.pending; current.then(() => { if (this.pending === current) { delete this.pending; } }); return current; }, getRecipients() { if (this.isPrivate()) { return [this.id]; } const me = textsecure.storage.user.getNumber(); return _.without(this.get('members'), me); }, async makeQuote(quotedMessage) { const { getName } = Contact; const contact = quotedMessage.getContact(); const attachments = quotedMessage.get('attachments'); const body = quotedMessage.get('body'); const embeddedContact = quotedMessage.get('contact'); const embeddedContactName = embeddedContact && embeddedContact.length > 0 ? getName(embeddedContact[0]) : ''; return { author: contact.id, id: quotedMessage.get('sent_at'), text: body || embeddedContactName, attachments: await Promise.all( (attachments || []).map(async attachment => { const { contentType, fileName, thumbnail } = attachment; return { contentType, // Our protos library complains about this field being undefined, so we // force it to null fileName: fileName || null, thumbnail: thumbnail ? { ...(await loadAttachmentData(thumbnail)), objectUrl: getAbsoluteAttachmentPath(thumbnail.path), } : null, }; }) ), }; }, sendMessage(body, attachments, quote) { const destination = this.id; const expireTimer = this.get('expireTimer'); const recipients = this.getRecipients(); let profileKey; if (this.get('profileSharing')) { profileKey = storage.get('profileKey'); } this.queueJob(async () => { const now = Date.now(); window.log.info( 'Sending message to conversation', this.idForLogging(), 'with timestamp', now ); const messageWithSchema = await upgradeMessageSchema({ type: 'outgoing', body, conversationId: destination, quote, attachments, sent_at: now, received_at: now, expireTimer, recipients, }); const message = this.addSingleMessage(messageWithSchema); this.lastMessage = message.getNotificationText(); this.lastMessageStatus = 'sending'; this.set({ active_at: now, timestamp: now, }); await window.Signal.Data.updateConversation(this.id, this.attributes, { Conversation: Whisper.Conversation, }); if (this.isPrivate()) { message.set({ destination }); } const id = await window.Signal.Data.saveMessage(message.attributes, { Message: Whisper.Message, }); message.set({ id }); // We're offline! if (!textsecure.messaging) { const errors = this.contactCollection.map(contact => { const error = new Error('Network is not available'); error.name = 'SendMessageNetworkError'; error.number = contact.id; return error; }); await message.saveErrors(errors); return null; } const conversationType = this.get('type'); const sendFunction = (() => { switch (conversationType) { case Message.PRIVATE: return textsecure.messaging.sendMessageToNumber; case Message.GROUP: return textsecure.messaging.sendMessageToGroup; default: throw new TypeError( `Invalid conversation type: '${conversationType}'` ); } })(); const attachmentsWithData = await Promise.all( messageWithSchema.attachments.map(loadAttachmentData) ); const options = this.getSendOptions(); return message.send( this.wrapSend( sendFunction( destination, body, attachmentsWithData, quote, now, expireTimer, profileKey, options ) ) ); }); }, wrapSend(promise) { return promise.then( async result => { // success if ( result && result.failoverNumbers && result.failoverNumbers.length ) { await this.handleFailover(result.failoverNumbers); } return result; }, async result => { // failure if ( result && result.failoverNumbers && result.failoverNumbers.length ) { await this.handleFailover(result.failoverNumbers); } throw result; } ); }, handleFailover(numberArray) { return Promise.all( (numberArray || []).map(async number => { const conversation = ConversationController.get(number); if (conversation && conversation.get('unidentifiedDelivery')) { window.log.info( `Marking unidentifiedDelivery false for conversation ${conversation.idForLogging()}` ); conversation.set({ unidentifiedDelivery: false }); await window.Signal.Data.updateConversation( conversation.id, conversation.attributes, { Conversation: Whisper.Conversation } ); } }) ); }, getSendOptions() { const senderCertificate = storage.get('senderCertificate'); const numberInfo = this.getNumberInfo(); return { senderCertificate, numberInfo, }; }, getNumberInfo() { const UD = 'unidentifiedDelivery'; const UNRESTRICTED_UD = 'unidentifiedDeliveryUnrestricted'; // We don't want to enable unidentified delivery for send unless it is // also enabled for our own account. const me = ConversationController.getOrCreate(this.ourNumber, 'private'); if (!me.get(UD) && !me.get(UNRESTRICTED_UD)) { return null; } if (this.isPrivate()) { const accessKey = this.get('accessKey'); const unidentifiedDelivery = this.get(UD); const unrestricted = this.get(UNRESTRICTED_UD); if (!unidentifiedDelivery && !unrestricted) { return null; } return { [this.id]: { accessKey: accessKey && !unrestricted ? accessKey : window.Signal.Crypto.arrayBufferToBase64( window.Signal.Crypto.getRandomBytes(16) ), }, }; } const infoArray = this.contactCollection.map(conversation => conversation.getNumberInfo() ); return Object.assign({}, ...infoArray); }, async updateLastMessage() { if (!this.id) { return; } const messages = await window.Signal.Data.getMessagesByConversation( this.id, { limit: 1, MessageCollection: Whisper.MessageCollection } ); const lastMessageModel = messages.at(0); const lastMessageJSON = lastMessageModel ? lastMessageModel.toJSON() : null; const lastMessageStatusModel = lastMessageModel ? lastMessageModel.getMessagePropStatus() : null; const lastMessageUpdate = Conversation.createLastMessageUpdate({ currentLastMessageText: this.get('lastMessage') || null, currentTimestamp: this.get('timestamp') || null, lastMessage: lastMessageJSON, lastMessageStatus: lastMessageStatusModel, lastMessageNotificationText: lastMessageModel ? lastMessageModel.getNotificationText() : null, }); let hasChanged = false; const { lastMessage, lastMessageStatus } = lastMessageUpdate; lastMessageUpdate.lastMessage = null; lastMessageUpdate.lastMessageStatus = null; hasChanged = hasChanged || lastMessage !== this.lastMessage; this.lastMessage = lastMessage; hasChanged = hasChanged || lastMessageStatus !== this.lastMessageStatus; this.lastMessageStatus = lastMessageStatus; // Because we're no longer using Backbone-integrated saves, we need to manually // clear the changed fields here so our hasChanged() check below is useful. this.changed = {}; this.set(lastMessageUpdate); if (this.hasChanged()) { await window.Signal.Data.updateConversation(this.id, this.attributes, { Conversation: Whisper.Conversation, }); } else if (hasChanged) { this.trigger('change'); } }, async updateExpirationTimer( providedExpireTimer, providedSource, receivedAt, options = {} ) { let expireTimer = providedExpireTimer; let source = providedSource; _.defaults(options, { fromSync: false, fromGroupUpdate: false }); if (!expireTimer) { expireTimer = null; } if ( this.get('expireTimer') === expireTimer || (!expireTimer && !this.get('expireTimer')) ) { return null; } window.log.info("Update conversation 'expireTimer'", { id: this.idForLogging(), expireTimer, source, }); source = source || textsecure.storage.user.getNumber(); // When we add a disappearing messages notification to the conversation, we want it // to be above the message that initiated that change, hence the subtraction. const timestamp = (receivedAt || Date.now()) - 1; this.set({ expireTimer }); await window.Signal.Data.updateConversation(this.id, this.attributes, { Conversation: Whisper.Conversation, }); const message = this.messageCollection.add({ // Even though this isn't reflected to the user, we want to place the last seen // indicator above it. We set it to 'unread' to trigger that placement. unread: 1, conversationId: this.id, // No type; 'incoming' messages are specially treated by conversation.markRead() sent_at: timestamp, received_at: timestamp, flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, expirationTimerUpdate: { expireTimer, source, fromSync: options.fromSync, fromGroupUpdate: options.fromGroupUpdate, }, }); if (this.isPrivate()) { message.set({ destination: this.id }); } if (message.isOutgoing()) { message.set({ recipients: this.getRecipients() }); } const id = await window.Signal.Data.saveMessage(message.attributes, { Message: Whisper.Message, }); message.set({ id }); // if change was made remotely, don't send it to the number/group if (receivedAt) { return message; } let sendFunc; if (this.get('type') === 'private') { sendFunc = textsecure.messaging.sendExpirationTimerUpdateToNumber; } else { sendFunc = textsecure.messaging.sendExpirationTimerUpdateToGroup; } let profileKey; if (this.get('profileSharing')) { profileKey = storage.get('profileKey'); } const sendOptions = this.getSendOptions(); const promise = sendFunc( this.get('id'), this.get('expireTimer'), message.get('sent_at'), profileKey, sendOptions ); await message.send(this.wrapSend(promise)); return message; }, isSearchable() { return !this.get('left') || !!this.get('lastMessage'); }, async endSession() { if (this.isPrivate()) { const now = Date.now(); const message = this.messageCollection.add({ conversationId: this.id, type: 'outgoing', sent_at: now, received_at: now, destination: this.id, recipients: this.getRecipients(), flags: textsecure.protobuf.DataMessage.Flags.END_SESSION, }); const id = await window.Signal.Data.saveMessage(message.attributes, { Message: Whisper.Message, }); message.set({ id }); const options = this.getSendOptions(); message.send( this.wrapSend( textsecure.messaging.resetSession(this.id, now, options) ) ); } }, async updateGroup(providedGroupUpdate) { let groupUpdate = providedGroupUpdate; if (this.isPrivate()) { throw new Error('Called update group on private conversation'); } if (groupUpdate === undefined) { groupUpdate = this.pick(['name', 'avatar', 'members']); } const now = Date.now(); const message = this.messageCollection.add({ conversationId: this.id, type: 'outgoing', sent_at: now, received_at: now, group_update: groupUpdate, }); const id = await window.Signal.Data.saveMessage(message.attributes, { Message: Whisper.Message, }); message.set({ id }); const options = this.getSendOptions(); message.send( this.wrapSend( textsecure.messaging.updateGroup( this.id, this.get('name'), this.get('avatar'), this.get('members'), options ) ) ); }, async leaveGroup() { const now = Date.now(); if (this.get('type') === 'group') { this.set({ left: true }); await window.Signal.Data.updateConversation(this.id, this.attributes, { Conversation: Whisper.Conversation, }); const message = this.messageCollection.add({ group_update: { left: 'You' }, conversationId: this.id, type: 'outgoing', sent_at: now, received_at: now, }); const id = await window.Signal.Data.saveMessage(message.attributes, { Message: Whisper.Message, }); message.set({ id }); const options = this.getSendOptions(); message.send( this.wrapSend(textsecure.messaging.leaveGroup(this.id, options)) ); } }, async markRead(newestUnreadDate, providedOptions) { const options = providedOptions || {}; _.defaults(options, { sendReadReceipts: true }); const conversationId = this.id; Whisper.Notifications.remove( Whisper.Notifications.where({ conversationId, }) ); let unreadMessages = await this.getUnread(); const oldUnread = unreadMessages.filter( message => message.get('received_at') <= newestUnreadDate ); let read = await Promise.all( _.map(oldUnread, async providedM => { let m = providedM; if (this.messageCollection.get(m.id)) { m = this.messageCollection.get(m.id); } else { window.log.warn( 'Marked a message as read in the database, but ' + 'it was not in messageCollection.' ); } await m.markRead(options.readAt); const errors = m.get('errors'); return { sender: m.get('source'), timestamp: m.get('sent_at'), hasErrors: Boolean(errors && errors.length), }; }) ); // Some messages we're marking read are local notifications with no sender read = _.filter(read, m => Boolean(m.sender)); unreadMessages = unreadMessages.filter(m => Boolean(m.isIncoming())); const unreadCount = unreadMessages.length - read.length; this.set({ unreadCount }); await window.Signal.Data.updateConversation(this.id, this.attributes, { Conversation: Whisper.Conversation, }); // If a message has errors, we don't want to send anything out about it. // read syncs - let's wait for a client that really understands the message // to mark it read. we'll mark our local error read locally, though. // read receipts - here we can run into infinite loops, where each time the // conversation is viewed, another error message shows up for the contact read = read.filter(item => !item.hasErrors); if (read.length && options.sendReadReceipts) { window.log.info(`Sending ${read.length} read receipts`); // Because syncReadMessages sends to our other devices, and sendReadReceipts goes // to a contact, we need accessKeys for both. const prep = ConversationController.prepareForSend(this.ourNumber); const recipientSendOptions = this.getSendOptions(); const sendOptions = Object.assign( {}, prep.sendOptions, recipientSendOptions ); await this.wrapSend( textsecure.messaging.syncReadMessages(read, sendOptions) ); if (storage.get('read-receipt-setting')) { await Promise.all( _.map(_.groupBy(read, 'sender'), async (receipts, sender) => { const timestamps = _.map(receipts, 'timestamp'); await this.wrapSend( textsecure.messaging.sendReadReceipts( sender, timestamps, sendOptions ) ); }) ); } } }, onChangeProfileKey() { if (this.isPrivate()) { this.getProfiles(); } }, getProfiles() { // request all conversation members' keys let ids = []; if (this.isPrivate()) { ids = [this.id]; } else { ids = this.get('members'); } return Promise.all(_.map(ids, this.getProfile)); }, async getProfile(id) { if (!textsecure.messaging) { throw new Error( 'Conversation.getProfile: textsecure.messaging not available' ); } const c = await ConversationController.getOrCreateAndWait(id, 'private'); // Because we're no longer using Backbone-integrated saves, we need to manually // clear the changed fields here so our hasChanged() check is useful. c.changed = {}; try { if (c.get('profileKey') && !c.get('accessKey')) { const profileKeyBuffer = window.Signal.Crypto.base64ToArrayBuffer( c.get('profileKey') ); const buffer = await window.Signal.Crypto.deriveAccessKey( profileKeyBuffer ); c.set({ accessKey: window.Signal.Crypto.arrayBufferToBase64(buffer), }); } const firstProfileFetch = !c.get('hasFetchedProfile'); const accessKey = c.get('accessKey'); let profile; if (c.get('unidentifiedDelivery') || firstProfileFetch) { try { profile = await textsecure.messaging.getProfile(id, { accessKey: accessKey || window.Signal.Crypto.arrayBufferToBase64( window.window.Signal.Crypto.getRandomBytes(16) ), }); } catch (error) { if (error.code === 401 || error.code === 403) { c.set({ unidentifiedDelivery: false, unidentifiedDeliveryUnrestricted: false, }); profile = await textsecure.messaging.getProfile(id); } else { throw error; } } } else { profile = await textsecure.messaging.getProfile(id); } const identityKey = window.Signal.Crypto.base64ToArrayBuffer( profile.identityKey ); const changed = await textsecure.storage.protocol.saveIdentity( `${id}.1`, identityKey, false ); if (changed) { // save identity will close all sessions except for .1, so we // must close that one manually. const address = new libsignal.SignalProtocolAddress(id, 1); window.log.info('closing session for', address.toString()); const sessionCipher = new libsignal.SessionCipher( textsecure.storage.protocol, address ); await sessionCipher.closeOpenSessionForDevice(); } c.set({ hasFetchedProfile: true, }); if ( profile.unrestrictedUnidentifiedAccess && profile.unidentifiedAccess ) { c.set({ unidentifiedDelivery: true, unidentifiedDeliveryUnrestricted: true, }); } else if (accessKey && profile.unidentifiedAccess) { const haveCorrectKey = await window.Signal.Crypto.verifyAccessKey( window.Signal.Crypto.base64ToArrayBuffer(accessKey), window.Signal.Crypto.base64ToArrayBuffer(profile.unidentifiedAccess) ); window.log.info( `Setting unidentifiedDelivery to ${haveCorrectKey} for conversation ${c.idForLogging()}` ); c.set({ unidentifiedDelivery: haveCorrectKey, unidentifiedDeliveryUnrestricted: false, }); } else { c.set({ unidentifiedDelivery: false, unidentifiedDeliveryUnrestricted: false, }); } await c.setProfileName(profile.name); // This might throw if we can't pull the avatar down, so we do it last await c.setProfileAvatar(profile.avatar); } catch (error) { window.log.error( 'getProfile error:', id, error && error.stack ? error.stack : error ); } finally { if (c.hasChanged()) { await window.Signal.Data.updateConversation(id, c.attributes, { Conversation: Whisper.Conversation, }); } } }, async setProfileName(encryptedName) { if (!encryptedName) { return; } const key = this.get('profileKey'); if (!key) { return; } // decode const keyBuffer = window.Signal.Crypto.base64ToArrayBuffer(key); const data = window.Signal.Crypto.base64ToArrayBuffer(encryptedName); // decrypt const decrypted = await textsecure.crypto.decryptProfileName( data, keyBuffer ); // encode const profileName = window.Signal.Crypto.stringFromBytes(decrypted); // set this.set({ profileName }); }, async setProfileAvatar(avatarPath) { if (!avatarPath) { return; } const avatar = await textsecure.messaging.getAvatar(avatarPath); const key = this.get('profileKey'); if (!key) { return; } const keyBuffer = window.Signal.Crypto.base64ToArrayBuffer(key); // decrypt const decrypted = await textsecure.crypto.decryptProfile( avatar, keyBuffer ); // update the conversation avatar only if hash differs if (decrypted) { const newAttributes = await window.Signal.Types.Conversation.maybeUpdateProfileAvatar( this.attributes, decrypted, { writeNewAttachmentData, deleteAttachmentData, } ); this.set(newAttributes); } }, async setProfileKey(profileKey) { // profileKey is a string so we can compare it directly if (this.get('profileKey') !== profileKey) { const profileKeyBuffer = window.Signal.Crypto.base64ToArrayBuffer( profileKey ); const accessKeyBuffer = await window.Signal.Crypto.deriveAccessKey( profileKeyBuffer ); const accessKey = window.Signal.Crypto.arrayBufferToBase64( accessKeyBuffer ); this.set({ profileKey, accessKey }); await window.Signal.Data.updateConversation(this.id, this.attributes, { Conversation: Whisper.Conversation, }); } }, async upgradeMessages(messages) { for (let max = messages.length, i = 0; i < max; i += 1) { const message = messages.at(i); const { attributes } = message; const { schemaVersion } = attributes; if (schemaVersion < Message.CURRENT_SCHEMA_VERSION) { // Yep, we really do want to wait for each of these // eslint-disable-next-line no-await-in-loop const upgradedMessage = await upgradeMessageSchema(attributes); message.set(upgradedMessage); // eslint-disable-next-line no-await-in-loop await window.Signal.Data.saveMessage(upgradedMessage, { Message: Whisper.Message, }); } } }, async fetchMessages() { if (!this.id) { throw new Error('This conversation has no id!'); } if (this.inProgressFetch) { window.log.warn('Attempting to start a parallel fetchMessages() call'); return; } this.inProgressFetch = this.messageCollection.fetchConversation( this.id, undefined, this.get('unreadCount') ); await this.inProgressFetch; try { // We are now doing the work to upgrade messages before considering the load from // the database complete. Note that we do save messages back, so it is a // one-time hit. We do this so we have guarantees about message structure. await this.upgradeMessages(this.messageCollection); } catch (error) { window.log.error( 'fetchMessages: failed to upgrade messages', Errors.toLogFormat(error) ); } this.inProgressFetch = null; }, hasMember(number) { return _.contains(this.get('members'), number); }, fetchContacts() { if (this.isPrivate()) { this.contactCollection.reset([this]); return Promise.resolve(); } const members = this.get('members') || []; const promises = members.map(number => ConversationController.getOrCreateAndWait(number, 'private') ); return Promise.all(promises).then(contacts => { _.forEach(contacts, contact => { this.listenTo( contact, 'change:verified', this.onMemberVerifiedChange ); }); this.contactCollection.reset(contacts); }); }, async destroyMessages() { await window.Signal.Data.removeAllMessagesInConversation(this.id, { MessageCollection: Whisper.MessageCollection, }); this.messageCollection.reset([]); this.set({ lastMessage: null, timestamp: null, active_at: null, }); await window.Signal.Data.updateConversation(this.id, this.attributes, { Conversation: Whisper.Conversation, }); }, getName() { if (this.isPrivate()) { return this.get('name'); } return this.get('name') || 'Unknown group'; }, getTitle() { if (this.isPrivate()) { return this.get('name') || this.getNumber(); } return this.get('name') || 'Unknown group'; }, getProfileName() { if (this.isPrivate() && !this.get('name')) { return this.get('profileName'); } return null; }, getDisplayName() { if (!this.isPrivate()) { return this.getTitle(); } const name = this.get('name'); if (name) { return name; } const profileName = this.get('profileName'); if (profileName) { return `${this.getNumber()} ~${profileName}`; } return this.getNumber(); }, getNumber() { if (!this.isPrivate()) { return ''; } const number = this.id; try { const parsedNumber = libphonenumber.parse(number); const regionCode = libphonenumber.getRegionCodeForNumber(parsedNumber); if (regionCode === storage.get('regionCode')) { return libphonenumber.format( parsedNumber, libphonenumber.PhoneNumberFormat.NATIONAL ); } return libphonenumber.format( parsedNumber, libphonenumber.PhoneNumberFormat.INTERNATIONAL ); } catch (e) { return number; } }, isPrivate() { return this.get('type') === 'private'; }, getColor() { if (!this.isPrivate()) { return 'signal-blue'; } const { migrateColor } = Util; return migrateColor(this.get('color')); }, getAvatarPath() { const avatar = this.get('avatar') || this.get('profileAvatar'); if (avatar && avatar.path) { return getAbsoluteAttachmentPath(avatar.path); } return null; }, getAvatar() { const title = this.get('name'); const color = this.getColor(); const avatar = this.get('avatar') || this.get('profileAvatar'); if (avatar && avatar.path) { return { url: getAbsoluteAttachmentPath(avatar.path), color }; } else if (this.isPrivate()) { return { color, content: title ? title.trim()[0] : '#', }; } return { url: 'images/group_default.png', color }; }, getNotificationIcon() { return new Promise(resolve => { const avatar = this.getAvatar(); if (avatar.url) { resolve(avatar.url); } else { resolve(new Whisper.IdenticonSVGView(avatar).getDataUrl()); } }); }, notify(message) { if (!message.isIncoming()) { return Promise.resolve(); } const conversationId = this.id; return ConversationController.getOrCreateAndWait( message.get('source'), 'private' ).then(sender => sender.getNotificationIcon().then(iconUrl => { const messageJSON = message.toJSON(); const messageSentAt = messageJSON.sent_at; const messageId = message.id; const isExpiringMessage = Message.hasExpiration(messageJSON); window.log.info('Add notification', { conversationId: this.idForLogging(), isExpiringMessage, messageSentAt, }); Whisper.Notifications.add({ conversationId, iconUrl, isExpiringMessage, message: message.getNotificationText(), messageId, messageSentAt, title: sender.getTitle(), }); }) ); }, }); Whisper.ConversationCollection = Backbone.Collection.extend({ database: Whisper.Database, storeName: 'conversations', model: Whisper.Conversation, comparator(m) { return -m.get('timestamp'); }, async destroyAll() { await Promise.all( this.models.map(conversation => window.Signal.Data.removeConversation(conversation.id, { Conversation: Whisper.Conversation, }) ) ); this.reset([]); }, async search(providedQuery) { let query = providedQuery.trim().toLowerCase(); query = query.replace(/[+-.()]*/g, ''); if (query.length === 0) { return; } const collection = await window.Signal.Data.searchConversations(query, { ConversationCollection: Whisper.ConversationCollection, }); this.reset(collection.models); }, }); Whisper.Conversation.COLORS = COLORS.concat(['grey', 'default']).join(' '); })();