diff --git a/background.html b/background.html index ada17196fbfd..cd25b7d83802 100644 --- a/background.html +++ b/background.html @@ -348,7 +348,6 @@ - diff --git a/js/background.js b/js/background.js index 2ab80c420b71..9b6aa63d4d6b 100644 --- a/js/background.js +++ b/js/background.js @@ -25,14 +25,24 @@ wait: 500, maxSize: 500, processBatch: async items => { - const bySource = _.groupBy(items, item => item.source || item.sourceUuid); - const sources = Object.keys(bySource); + const byConversationId = _.groupBy(items, item => + ConversationController.ensureContactIds({ + e164: item.source, + uuid: item.sourceUuid, + }) + ); + const ids = Object.keys(byConversationId); - for (let i = 0, max = sources.length; i < max; i += 1) { - const source = sources[i]; - const timestamps = bySource[source].map(item => item.timestamp); + for (let i = 0, max = ids.length; i < max; i += 1) { + const conversationId = ids[i]; + const timestamps = byConversationId[conversationId].map( + item => item.timestamp + ); + + const c = ConversationController.get(conversationId); + const uuid = c.get('uuid'); + const e164 = c.get('e164'); - const c = ConversationController.get(source); c.queueJob(async () => { try { const { wrap, sendOptions } = ConversationController.prepareForSend( @@ -41,15 +51,15 @@ // eslint-disable-next-line no-await-in-loop await wrap( textsecure.messaging.sendDeliveryReceipt( - c.get('e164'), - c.get('uuid'), + e164, + uuid, timestamps, sendOptions ) ); } catch (error) { window.log.error( - `Failed to send delivery receipt to ${source} for timestamps ${timestamps}:`, + `Failed to send delivery receipt to ${e164}/${uuid} for timestamps ${timestamps}:`, error && error.stack ? error.stack : error ); } @@ -577,6 +587,7 @@ } }); + window.Signal.conversationControllerStart(); try { await Promise.all([ ConversationController.load(), @@ -584,6 +595,7 @@ Signal.Emojis.load(), textsecure.storage.protocol.hydrateCaches(), ]); + await ConversationController.checkForConflicts(); } catch (error) { window.log.error( 'background.js: ConversationController failed to load:', @@ -1759,6 +1771,7 @@ const deviceId = textsecure.storage.user.getDeviceId(); + // If we didn't capture a UUID on registration, go get it from the server if (!textsecure.storage.user.getUuid()) { const server = WebAPI.connect({ username: OLD_USERNAME, @@ -1954,6 +1967,7 @@ const senderId = ConversationController.ensureContactIds({ e164: sender, uuid: senderUuid, + highTrust: true, }); const conversation = ConversationController.get(groupId || senderId); const ourId = ConversationController.getOurConversationId(); @@ -2047,6 +2061,7 @@ const detailsId = ConversationController.ensureContactIds({ e164: details.number, uuid: details.uuid, + highTrust: true, }); const conversation = ConversationController.get(detailsId); let activeAt = conversation.get('active_at'); @@ -2236,12 +2251,10 @@ return; } - const sourceE164 = textsecure.storage.user.getNumber(); - const sourceUuid = textsecure.storage.user.getUuid(); const receivedAt = Date.now(); await conversation.updateExpirationTimer( expireTimer, - sourceE164 || sourceUuid, + ConversationController.getOurConversationId(), receivedAt, { fromSync: true, @@ -2256,10 +2269,16 @@ }); // Matches event data from `libtextsecure` `MessageReceiver::handleSentMessage`: - const getDescriptorForSent = ({ message, destination }) => + const getDescriptorForSent = ({ message, destination, destinationUuid }) => message.group ? getGroupDescriptor(message.group) - : { type: Message.PRIVATE, id: destination }; + : { + type: Message.PRIVATE, + id: ConversationController.ensureContactIds({ + e164: destination, + uuid: destinationUuid, + }), + }; // Matches event data from `libtextsecure` `MessageReceiver::handleDataMessage`: const getDescriptorForReceived = ({ message, source, sourceUuid }) => @@ -2270,6 +2289,7 @@ id: ConversationController.ensureContactIds({ e164: source, uuid: sourceUuid, + highTrust: true, }), }; @@ -2280,13 +2300,12 @@ messageDescriptor, }) { const profileKey = data.message.profileKey.toString('base64'); - const sender = await ConversationController.getOrCreateAndWait( - messageDescriptor.id, - 'private' - ); + const sender = await ConversationController.get(messageDescriptor.id); - // Will do the save for us - await sender.setProfileKey(profileKey); + if (sender) { + // Will do the save for us + await sender.setProfileKey(profileKey); + } return confirm(); } @@ -2357,9 +2376,12 @@ } async function onProfileKeyUpdate({ data, confirm }) { - const conversation = ConversationController.get( - data.source || data.sourceUuid - ); + const conversationId = ConversationController.ensureContactIds({ + e164: data.source, + uuid: data.sourceUuid, + highTrust: true, + }); + const conversation = ConversationController.get(conversationId); if (!conversation) { window.log.error( @@ -2397,11 +2419,8 @@ messageDescriptor, }) { // First set profileSharing = true for the conversation we sent to - const { id, type } = messageDescriptor; - const conversation = await ConversationController.getOrCreateAndWait( - id, - type - ); + const { id } = messageDescriptor; + const conversation = await ConversationController.get(id); conversation.enableProfileSharing(); window.Signal.Data.updateConversation(conversation.attributes); @@ -2417,7 +2436,7 @@ return confirm(); } - function createSentMessage(data) { + function createSentMessage(data, descriptor) { const now = Date.now(); let sentTo = []; @@ -2430,6 +2449,11 @@ data.unidentifiedDeliveries = unidentified.map(item => item.destination); } + const isGroup = descriptor.type === Message.GROUP; + const conversationId = isGroup + ? ConversationController.ensureGroup(descriptor.id) + : descriptor.id; + return new Whisper.Message({ source: textsecure.storage.user.getNumber(), sourceUuid: textsecure.storage.user.getUuid(), @@ -2438,7 +2462,7 @@ serverTimestamp: data.serverTimestamp, sent_to: sentTo, received_at: now, - conversationId: data.destination, + conversationId, type: 'outgoing', sent: true, unidentifiedDeliveries: data.unidentifiedDeliveries || [], @@ -2468,7 +2492,7 @@ }); } - const message = createSentMessage(data); + const message = createSentMessage(data, messageDescriptor); if (data.message.reaction) { const { reaction } = data.message; @@ -2502,12 +2526,7 @@ return Promise.resolve(); } - ConversationController.getOrCreate( - messageDescriptor.id, - messageDescriptor.type - ); // Don't wait for handleDataMessage, as it has its own per-conversation queueing - message.handleDataMessage(data.message, event.confirm, { data, }); @@ -2520,12 +2539,10 @@ const fromContactId = ConversationController.ensureContactIds({ e164: data.source, uuid: data.sourceUuid, + highTrust: true, }); - // Determine if this message is in a group const isGroup = descriptor.type === Message.GROUP; - - // Determine the conversationId this message belongs to const conversationId = isGroup ? ConversationController.ensureGroup(descriptor.id, { addedBy: fromContactId, @@ -2651,10 +2668,7 @@ }); const conversationId = message.get('conversationId'); - const conversation = ConversationController.getOrCreate( - conversationId, - 'private' - ); + const conversation = ConversationController.get(conversationId); // This matches the queueing behavior used in Message.handleDataMessage conversation.queueJob(async () => { @@ -2791,9 +2805,13 @@ function onReadReceipt(ev) { const readAt = ev.timestamp; - const { timestamp } = ev.read; - const reader = ConversationController.getConversationId(ev.read.reader); - window.log.info('read receipt', reader, timestamp); + const { timestamp, source, sourceUuid } = ev.read; + const reader = ConversationController.ensureContactIds({ + e164: source, + uuid: sourceUuid, + highTrust: true, + }); + window.log.info('read receipt', timestamp, source, sourceUuid, reader); ev.confirm(); @@ -2879,7 +2897,11 @@ ev.viaContactSync ? 'via contact sync' : '' ); - const verifiedId = ConversationController.ensureContactIds({ e164, uuid }); + const verifiedId = ConversationController.ensureContactIds({ + e164, + uuid, + highTrust: true, + }); const contact = await ConversationController.get(verifiedId, 'private'); const options = { viaSyncMessage: true, @@ -2899,20 +2921,23 @@ function onDeliveryReceipt(ev) { const { deliveryReceipt } = ev; const { sourceUuid, source } = deliveryReceipt; - const identifier = source || sourceUuid; window.log.info( 'delivery receipt from', - `${identifier}.${deliveryReceipt.sourceDevice}`, + `${source} ${sourceUuid} ${deliveryReceipt.sourceDevice}`, deliveryReceipt.timestamp ); ev.confirm(); - const deliveredTo = ConversationController.getConversationId(identifier); + const deliveredTo = ConversationController.ensureContactIds({ + e164: source, + uuid: sourceUuid, + highTrust: true, + }); if (!deliveredTo) { - window.log.info('no conversation for identifier', identifier); + window.log.info('no conversation for', source, sourceUuid); return; } diff --git a/js/conversation_controller.js b/js/conversation_controller.js deleted file mode 100644 index ad447ffb98ee..000000000000 --- a/js/conversation_controller.js +++ /dev/null @@ -1,353 +0,0 @@ -/* global _, Whisper, Backbone, storage, textsecure */ - -/* eslint-disable more/no-then */ - -// eslint-disable-next-line func-names -(function() { - 'use strict'; - - window.Whisper = window.Whisper || {}; - - const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; - - const conversations = new Whisper.ConversationCollection(); - const inboxCollection = new (Backbone.Collection.extend({ - initialize() { - this.listenTo(conversations, 'add change:active_at', this.addActive); - this.listenTo(conversations, 'reset', () => this.reset([])); - - this.on( - 'add remove change:unreadCount', - _.debounce(this.updateUnreadCount.bind(this), 1000) - ); - }, - addActive(model) { - if (model.get('active_at')) { - this.add(model); - } else { - this.remove(model); - } - }, - updateUnreadCount() { - const newUnreadCount = _.reduce( - this.map(m => m.get('unreadCount')), - (item, memo) => item + memo, - 0 - ); - storage.put('unreadCount', newUnreadCount); - - if (newUnreadCount > 0) { - window.setBadgeCount(newUnreadCount); - window.document.title = `${window.getTitle()} (${newUnreadCount})`; - } else { - window.setBadgeCount(0); - window.document.title = window.getTitle(); - } - window.updateTrayIcon(newUnreadCount); - }, - }))(); - - window.getInboxCollection = () => inboxCollection; - window.getConversations = () => conversations; - - window.ConversationController = { - get(id) { - if (!this._initialFetchComplete) { - throw new Error( - 'ConversationController.get() needs complete initial fetch' - ); - } - - return conversations.get(id); - }, - // Needed for some model setup which happens during the initial fetch() call below - getUnsafe(id) { - return conversations.get(id); - }, - dangerouslyCreateAndAdd(attributes) { - return conversations.add(attributes); - }, - getOrCreate(identifier, type, additionalInitialProps = {}) { - if (typeof identifier !== 'string') { - throw new TypeError("'id' must be a string"); - } - - if (type !== 'private' && type !== 'group') { - throw new TypeError( - `'type' must be 'private' or 'group'; got: '${type}'` - ); - } - - if (!this._initialFetchComplete) { - throw new Error( - 'ConversationController.get() needs complete initial fetch' - ); - } - - let conversation = conversations.get(identifier); - if (conversation) { - return conversation; - } - - const id = window.getGuid(); - - if (type === 'group') { - conversation = conversations.add({ - id, - uuid: null, - e164: null, - groupId: identifier, - type, - version: 2, - ...additionalInitialProps, - }); - } else if (window.isValidGuid(identifier)) { - conversation = conversations.add({ - id, - uuid: identifier, - e164: null, - groupId: null, - type, - version: 2, - ...additionalInitialProps, - }); - } else { - conversation = conversations.add({ - id, - uuid: null, - e164: identifier, - groupId: null, - type, - version: 2, - ...additionalInitialProps, - }); - } - - const create = async () => { - if (!conversation.isValid()) { - const validationError = conversation.validationError || {}; - window.log.error( - 'Contact is not valid. Not saving, but adding to collection:', - conversation.idForLogging(), - validationError.stack - ); - - return conversation; - } - - try { - await window.Signal.Data.saveConversation(conversation.attributes, { - Conversation: Whisper.Conversation, - }); - } catch (error) { - window.log.error( - 'Conversation save failed! ', - identifier, - type, - 'Error:', - error && error.stack ? error.stack : error - ); - throw error; - } - - return conversation; - }; - - conversation.initialPromise = create(); - - return conversation; - }, - getOrCreateAndWait(id, type, additionalInitialProps = {}) { - return this._initialPromise.then(() => { - const conversation = this.getOrCreate(id, type, additionalInitialProps); - - if (conversation) { - return conversation.initialPromise.then(() => conversation); - } - - return Promise.reject( - new Error('getOrCreateAndWait: did not get conversation') - ); - }); - }, - getConversationId(address) { - if (!address) { - return null; - } - - const [id] = textsecure.utils.unencodeNumber(address); - const conv = this.get(id); - - if (conv) { - return conv.get('id'); - } - - return null; - }, - getOurConversationId() { - const e164 = textsecure.storage.user.getNumber(); - const uuid = textsecure.storage.user.getUuid(); - return this.ensureContactIds({ e164, uuid }); - }, - /** - * Given a UUID and/or an E164, resolves to a string representing the local - * database of the given contact. If a conversation is found it is updated - * to have the given UUID and E164. If a conversation is not found, this - * function creates a conversation with the given UUID and E164. If the - * conversation * is found in the local database it is updated. - * - * This function also additionally checks for mismatched e164/uuid pairs out - * of abundance of caution. - */ - ensureContactIds({ e164, uuid }) { - // Check for at least one parameter being provided. This is necessary - // because this path can be called on startup to resolve our own ID before - // our phone number or UUID are known. The existing behavior in these - // cases can handle a returned `undefined` id, so we do that. - if (!e164 && !uuid) { - return undefined; - } - - const lowerUuid = uuid ? uuid.toLowerCase() : undefined; - - const convoE164 = this.get(e164); - const convoUuid = this.get(lowerUuid); - - // Check for mismatched UUID and E164 - if ( - convoE164 && - convoUuid && - convoE164.get('id') !== convoUuid.get('id') - ) { - window.log.warn('Received a message with a mismatched UUID and E164.'); - } - - const convo = convoUuid || convoE164; - - const idOrIdentifier = convo ? convo.get('id') : e164 || lowerUuid; - - const finalConversation = this.getOrCreate(idOrIdentifier, 'private'); - finalConversation.updateE164(e164); - finalConversation.updateUuid(lowerUuid); - - return finalConversation.get('id'); - }, - /** - * Given a groupId and optional additional initialization properties, - * ensures the existence of a group conversation and returns a string - * representing the local database ID of the group conversation. - */ - ensureGroup(groupId, additionalInitProps = {}) { - return this.getOrCreate(groupId, 'group', additionalInitProps).get('id'); - }, - /** - * Given certain metadata about a message (an identifier of who wrote the - * message and the sent_at timestamp of the message) returns the - * conversation the message belongs to OR null if a conversation isn't - * found. - * @param {string} targetFrom The E164, UUID, or Conversation ID of the message author - * @param {number} targetTimestamp The sent_at timestamp of the target message - */ - async getConversationForTargetMessage(targetFrom, targetTimestamp) { - const targetFromId = this.getConversationId(targetFrom); - - const messages = await window.Signal.Data.getMessagesBySentAt( - targetTimestamp, - { - MessageCollection: Whisper.MessageCollection, - } - ); - const targetMessage = messages.find(m => { - const contact = m.getContact(); - - if (!contact) { - return false; - } - - const mcid = contact.get('id'); - return mcid === targetFromId; - }); - - if (targetMessage) { - return targetMessage.getConversation(); - } - - return null; - }, - prepareForSend(id, options) { - // id is any valid conversation identifier - const conversation = this.get(id); - const sendOptions = conversation - ? conversation.getSendOptions(options) - : null; - const wrap = conversation - ? conversation.wrapSend.bind(conversation) - : promise => promise; - - return { wrap, sendOptions }; - }, - async getAllGroupsInvolvingId(conversationId) { - const groups = await window.Signal.Data.getAllGroupsInvolvingId( - conversationId, - { - ConversationCollection: Whisper.ConversationCollection, - } - ); - return groups.map(group => conversations.add(group)); - }, - loadPromise() { - return this._initialPromise; - }, - reset() { - this._initialPromise = Promise.resolve(); - this._initialFetchComplete = false; - conversations.reset([]); - }, - async load() { - window.log.info('ConversationController: starting initial fetch'); - - if (conversations.length) { - throw new Error('ConversationController: Already loaded!'); - } - - const load = async () => { - try { - const collection = await window.Signal.Data.getAllConversations({ - ConversationCollection: Whisper.ConversationCollection, - }); - - conversations.add(collection.models); - - this._initialFetchComplete = true; - await Promise.all( - conversations.map(async conversation => { - if (!conversation.get('lastMessage')) { - await conversation.updateLastMessage(); - } - - // In case a too-large draft was saved to the database - const draft = conversation.get('draft'); - if (draft && draft.length > MAX_MESSAGE_BODY_LENGTH) { - this.model.set({ - draft: draft.slice(0, MAX_MESSAGE_BODY_LENGTH), - }); - window.Signal.Data.updateConversation(conversation.attributes); - } - }) - ); - window.log.info('ConversationController: done with initial fetch'); - } catch (error) { - window.log.error( - 'ConversationController: initial fetch failed', - error && error.stack ? error.stack : error - ); - throw error; - } - }; - - this._initialPromise = load(); - - return this._initialPromise; - }, - }; -})(); diff --git a/js/delivery_receipts.js b/js/delivery_receipts.js index 144db2363f12..8b19e41c8885 100644 --- a/js/delivery_receipts.js +++ b/js/delivery_receipts.js @@ -30,11 +30,10 @@ this.remove(receipts); return receipts; }, - async getTargetMessage(source, messages) { + async getTargetMessage(sourceId, messages) { if (messages.length === 0) { return null; } - const sourceId = ConversationController.getConversationId(source); const message = messages.find( item => !item.isIncoming() && sourceId === item.get('conversationId') ); diff --git a/js/message_requests.js b/js/message_requests.js index d2c4cb97e75a..7816c83a1182 100644 --- a/js/message_requests.js +++ b/js/message_requests.js @@ -15,7 +15,7 @@ forConversation(conversation) { if (conversation.get('e164')) { const syncByE164 = this.findWhere({ - e164: conversation.get('e164'), + threadE164: conversation.get('e164'), }); if (syncByE164) { window.log.info( @@ -30,7 +30,7 @@ if (conversation.get('uuid')) { const syncByUuid = this.findWhere({ - uuid: conversation.get('uuid'), + threadUuid: conversation.get('uuid'), }); if (syncByUuid) { window.log.info( @@ -45,7 +45,7 @@ if (conversation.get('groupId')) { const syncByGroupId = this.findWhere({ - uuid: conversation.get('groupId'), + groupId: conversation.get('groupId'), }); if (syncByGroupId) { window.log.info( @@ -65,12 +65,19 @@ const threadE164 = sync.get('threadE164'); const threadUuid = sync.get('threadUuid'); const groupId = sync.get('groupId'); - const identifier = threadE164 || threadUuid || groupId; - const conversation = ConversationController.get(identifier); + + const conversation = groupId + ? ConversationController.get(groupId) + : ConversationController.get( + ConversationController.ensureContactIds({ + e164: threadE164, + uuid: threadUuid, + }) + ); if (!conversation) { window.log( - `Received message request response for unknown conversation: ${identifier}` + `Received message request response for unknown conversation: ${groupId} ${threadUuid} ${threadE164}` ); return; } diff --git a/js/models/conversations.js b/js/models/conversations.js index aaafd7f0bc60..0c016d9c07ba 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -85,6 +85,12 @@ return `group(${groupId})`; }, + // This is one of the few times that we want to collapse our uuid/e164 pair down into + // just one bit of data. If we have a UUID, we'll send using it. + getSendTarget() { + return this.get('uuid') || this.get('e164'); + }, + handleMessageError(message, errors) { this.trigger('messageError', message, errors); }, @@ -318,9 +324,8 @@ } const groupId = !this.isPrivate() ? this.get('groupId') : null; - const maybeRecipientId = this.get('uuid') || this.get('e164'); - const recipientId = this.isPrivate() ? maybeRecipientId : null; const groupNumbers = this.getRecipients(); + const recipientId = this.isPrivate() ? this.getSendTarget() : null; const sendOptions = this.getSendOptions(); @@ -395,10 +400,10 @@ async onNewMessage(message) { // Clear typing indicator for a given contact if we receive a message from them - const identifier = message.get - ? `${message.get('source')}.${message.get('sourceDevice')}` - : `${message.source}.${message.sourceDevice}`; - this.clearContactTypingTimer(identifier); + const deviceId = message.get + ? `${message.get('conversationId')}.${message.get('sourceDevice')}` + : `${message.conversationId}.${message.sourceDevice}`; + this.clearContactTypingTimer(deviceId); this.debouncedUpdateLastMessage(); }, @@ -582,7 +587,13 @@ m => !m.hasErrors() && m.isIncoming() ); const receiptSpecs = readMessages.map(m => ({ - sender: m.get('source') || m.get('sourceUuid'), + senderE164: m.get('source'), + senderUuid: m.get('sourceUuid'), + senderId: ConversationController.get({ + e164: m.get('source'), + uuid: m.get('sourceUuid'), + lowTrust: true, + }), timestamp: m.get('sent_at'), hasErrors: m.hasErrors(), })); @@ -1167,14 +1178,12 @@ getRecipients() { if (this.isPrivate()) { - return [this.get('uuid') || this.get('e164')]; + return [this.getSendTarget()]; } - const me = ConversationController.getConversationId( - textsecure.storage.user.getUuid() || textsecure.storage.user.getNumber() - ); + const me = ConversationController.getOurConversationId(); return _.without(this.get('members'), me).map(memberId => { const c = ConversationController.get(memberId); - return c.get('uuid') || c.get('e164'); + return c.getSendTarget(); }); }, @@ -1331,11 +1340,7 @@ const reactionModel = Whisper.Reactions.add({ ...outgoingReaction, - fromId: - this.ourNumber || - this.ourUuid || - textsecure.storage.user.getNumber() || - textsecure.storage.user.getUuid(), + fromId: ConversationController.getOurConversationId(), timestamp, fromSync: true, }); @@ -1446,9 +1451,7 @@ async sendProfileKeyUpdate() { const id = this.get('id'); - const recipients = this.isPrivate() - ? [this.get('uuid') || this.get('e164')] - : this.getRecipients(); + const recipients = this.getRecipients(); if (!this.get('profileSharing')) { window.log.error( 'Attempted to send profileKeyUpdate to conversation without profileSharing enabled', @@ -1477,7 +1480,7 @@ const { clearUnreadMetrics } = window.reduxActions.conversations; clearUnreadMetrics(this.id); - const destination = this.get('uuid') || this.get('e164'); + const destination = this.getSendTarget(); const expireTimer = this.get('expireTimer'); const recipients = this.getRecipients(); @@ -1549,7 +1552,7 @@ ).map(contact => { const error = new Error('Network is not available'); error.name = 'SendMessageNetworkError'; - error.identifier = contact.get('uuid') || contact.get('e164'); + error.identifier = contact.get('id'); return error; }); await message.saveErrors(errors); @@ -1658,8 +1661,8 @@ async handleMessageSendResult(failoverIdentifiers, unidentifiedDeliveries) { await Promise.all( - (failoverIdentifiers || []).map(async number => { - const conversation = ConversationController.get(number); + (failoverIdentifiers || []).map(async identifier => { + const conversation = ConversationController.get(identifier); if ( conversation && @@ -1677,8 +1680,8 @@ ); await Promise.all( - (unidentifiedDeliveries || []).map(async number => { - const conversation = ConversationController.get(number); + (unidentifiedDeliveries || []).map(async identifier => { + const conversation = ConversationController.get(identifier); if ( conversation && @@ -1722,17 +1725,10 @@ getSendMetadata(options = {}) { const { syncMessage, disableMeCheck } = options; - if (!this.ourNumber && !this.ourUuid) { - return null; - } - // START: this code has an Expiration date of ~2018/11/21 // We don't want to enable unidentified delivery for send unless it is // also enabled for our own account. - const myId = ConversationController.ensureContactIds({ - e164: this.ourNumber, - uuid: this.ourUuid, - }); + const myId = ConversationController.getOurConversationId(); const me = ConversationController.get(myId); if ( !disableMeCheck && @@ -1903,10 +1899,7 @@ source, }); - source = - source || - textsecure.storage.user.getNumber() || - textsecure.storage.user.getUuid(); + source = source || ConversationController.getOurConversationId(); // 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. @@ -1933,7 +1926,7 @@ }); if (this.isPrivate()) { - model.set({ destination: this.get('uuid') || this.get('e164') }); + model.set({ destination: this.getSendTarget() }); } if (model.isOutgoing()) { model.set({ recipients: this.getRecipients() }); @@ -1963,7 +1956,7 @@ const flags = textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE; const dataMessage = await textsecure.messaging.getMessageProto( - this.get('uuid') || this.get('e164'), + this.getSendTarget(), null, [], null, @@ -1980,7 +1973,7 @@ if (this.get('type') === 'private') { promise = textsecure.messaging.sendExpirationTimerUpdateToIdentifier( - this.get('uuid') || this.get('e164'), + this.getSendTarget(), expireTimer, message.get('sent_at'), profileKey, @@ -2180,7 +2173,12 @@ await m.markRead(options.readAt); return { - sender: m.get('source') || m.get('sourceUuid'), + senderE164: m.get('source'), + senderUuid: m.get('sourceUuid'), + senderId: ConversationController.ensureContactIds({ + e164: m.get('source'), + uuid: m.get('sourceUuid'), + }), timestamp: m.get('sent_at'), hasErrors: m.hasErrors(), }; @@ -2188,7 +2186,7 @@ ); // Some messages we're marking read are local notifications with no sender - read = _.filter(read, m => Boolean(m.sender)); + read = _.filter(read, m => Boolean(m.senderId)); unreadMessages = unreadMessages.filter(m => Boolean(m.isIncoming())); const unreadCount = unreadMessages.length - read.length; @@ -2206,8 +2204,10 @@ window.log.info(`Sending ${read.length} read syncs`); // Because syncReadMessages sends to our other devices, and sendReadReceipts goes // to a contact, we need accessKeys for both. - const { sendOptions } = ConversationController.prepareForSend( - this.ourUuid || this.ourNumber, + const { + sendOptions, + } = ConversationController.prepareForSend( + ConversationController.getOurConversationId(), { syncMessage: true } ); await this.wrapSend( @@ -2222,12 +2222,12 @@ if (storage.get('read-receipt-setting') && this.getAccepted()) { window.log.info(`Sending ${items.length} read receipts`); const convoSendOptions = this.getSendOptions(); - const receiptsBySender = _.groupBy(items, 'sender'); + const receiptsBySender = _.groupBy(items, 'senderId'); await Promise.all( - _.map(receiptsBySender, async (receipts, identifier) => { + _.map(receiptsBySender, async (receipts, senderId) => { const timestamps = _.map(receipts, 'timestamp'); - const c = ConversationController.get(identifier); + const c = ConversationController.get(senderId); await this.wrapSend( textsecure.messaging.sendReadReceipts( c.get('e164'), @@ -2249,28 +2249,33 @@ getProfiles() { // request all conversation members' keys - let ids = []; + let conversations = []; if (this.isPrivate()) { - ids = [this.get('uuid') || this.get('e164')]; + conversations = [this]; } else { - ids = this.get('members') - .map(id => { - const c = ConversationController.get(id); - return c ? c.get('uuid') || c.get('e164') : null; - }) + conversations = this.get('members') + .map(id => ConversationController.get(id)) .filter(Boolean); } - return Promise.all(_.map(ids, this.getProfile)); + return Promise.all( + _.map(conversations, conversation => { + this.getProfile(conversation.get('uuid'), conversation.get('e164')); + }) + ); }, - async getProfile(id) { + async getProfile(providedUuid, providedE164) { if (!textsecure.messaging) { throw new Error( 'Conversation.getProfile: textsecure.messaging not available' ); } - const c = ConversationController.getOrCreate(id, 'private'); + const id = ConversationController.ensureContactIds({ + uuid: providedUuid, + e164: providedE164, + }); + const c = ConversationController.get(id); const { generateProfileKeyCredentialRequest, getClientZkProfileOperations, @@ -2294,6 +2299,7 @@ const profileKey = c.get('profileKey'); const uuid = c.get('uuid'); + const identifier = c.getSendTarget(); const profileKeyVersionHex = c.get('profileKeyVersion'); const existingProfileKeyCredential = c.get('profileKeyCredential'); @@ -2317,11 +2323,11 @@ const sendMetadata = c.getSendMetadata({ disableMeCheck: true }) || {}; const getInfo = - sendMetadata[c.get('e164')] || sendMetadata[c.get('uuid')] || {}; + sendMetadata[c.get('uuid')] || sendMetadata[c.get('e164')] || {}; if (getInfo.accessKey) { try { - profile = await textsecure.messaging.getProfile(id, { + profile = await textsecure.messaging.getProfile(identifier, { accessKey: getInfo.accessKey, profileKeyVersion: profileKeyVersionHex, profileKeyCredentialRequest: profileKeyCredentialRequestHex, @@ -2332,7 +2338,7 @@ `Setting sealedSender to DISABLED for conversation ${c.idForLogging()}` ); c.set({ sealedSender: SEALED_SENDER.DISABLED }); - profile = await textsecure.messaging.getProfile(id, { + profile = await textsecure.messaging.getProfile(identifier, { profileKeyVersion: profileKeyVersionHex, profileKeyCredentialRequest: profileKeyCredentialRequestHex, }); @@ -2341,7 +2347,7 @@ } } } else { - profile = await textsecure.messaging.getProfile(id, { + profile = await textsecure.messaging.getProfile(identifier, { profileKeyVersion: profileKeyVersionHex, profileKeyCredentialRequest: profileKeyCredentialRequestHex, }); @@ -2349,14 +2355,14 @@ const identityKey = base64ToArrayBuffer(profile.identityKey); const changed = await textsecure.storage.protocol.saveIdentity( - `${id}.1`, + `${identifier}.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); + const address = new libsignal.SignalProtocolAddress(identifier, 1); window.log.info('closing session for', address.toString()); const sessionCipher = new libsignal.SessionCipher( textsecure.storage.protocol, @@ -2421,7 +2427,7 @@ if (error.code !== 403 && error.code !== 404) { window.log.warn( 'getProfile failure:', - id, + c.idForLogging(), error && error.stack ? error.stack : error ); } else { @@ -2435,7 +2441,7 @@ } catch (error) { window.log.warn( 'getProfile decryption failure:', - id, + c.idForLogging(), error && error.stack ? error.stack : error ); await c.dropProfileKey(); @@ -2780,10 +2786,9 @@ const conversationId = this.id; - const sender = await ConversationController.getOrCreateAndWait( - reaction ? reaction.get('fromId') : message.get('source'), - 'private' - ); + const sender = reaction + ? ConversationController.get(reaction.get('fromId')) + : message.getContact(); const iconUrl = await sender.getNotificationIcon(); @@ -2805,42 +2810,33 @@ }, notifyTyping(options = {}) { - const { - isTyping, - sender, - senderUuid, - senderId, - isMe, - senderDevice, - } = options; + const { isTyping, senderId, isMe, senderDevice } = options; // We don't do anything with typing messages from our other devices if (isMe) { return; } - const identifier = `${sender}.${senderDevice}`; + const deviceId = `${senderId}.${senderDevice}`; this.contactTypingTimers = this.contactTypingTimers || {}; - const record = this.contactTypingTimers[identifier]; + const record = this.contactTypingTimers[deviceId]; if (record) { clearTimeout(record.timer); } if (isTyping) { - this.contactTypingTimers[identifier] = this.contactTypingTimers[ - identifier + this.contactTypingTimers[deviceId] = this.contactTypingTimers[ + deviceId ] || { timestamp: Date.now(), - sender, senderId, - senderUuid, senderDevice, }; - this.contactTypingTimers[identifier].timer = setTimeout( - this.clearContactTypingTimer.bind(this, identifier), + this.contactTypingTimers[deviceId].timer = setTimeout( + this.clearContactTypingTimer.bind(this, deviceId), 15 * 1000 ); if (!record) { @@ -2848,7 +2844,7 @@ this.trigger('change', this); } } else { - delete this.contactTypingTimers[identifier]; + delete this.contactTypingTimers[deviceId]; if (record) { // User was previously typing, and is no longer. State change! this.trigger('change', this); @@ -2856,13 +2852,13 @@ } }, - clearContactTypingTimer(identifier) { + clearContactTypingTimer(deviceId) { this.contactTypingTimers = this.contactTypingTimers || {}; - const record = this.contactTypingTimers[identifier]; + const record = this.contactTypingTimers[deviceId]; if (record) { clearTimeout(record.timer); - delete this.contactTypingTimers[identifier]; + delete this.contactTypingTimers[deviceId]; // User was previously typing, but timed out or we received message. State change! this.trigger('change', this); @@ -2879,9 +2875,9 @@ * than just their id. */ initialize() { - this._byE164 = {}; - this._byUuid = {}; - this._byGroupId = {}; + this._byE164 = Object.create(null); + this._byUuid = Object.create(null); + this._byGroupId = Object.create(null); this.on('idUpdated', (model, idProp, oldValue) => { if (oldValue) { if (idProp === 'e164') { @@ -2908,9 +2904,9 @@ reset(...args) { Backbone.Collection.prototype.reset.apply(this, args); - this._byE164 = {}; - this._byUuid = {}; - this._byGroupId = {}; + this._byE164 = Object.create(null); + this._byUuid = Object.create(null); + this._byGroupId = Object.create(null); }, add(...models) { @@ -2918,12 +2914,22 @@ [].concat(res).forEach(model => { const e164 = model.get('e164'); if (e164) { - this._byE164[e164] = model; + const existing = this._byE164[e164]; + + // Prefer the contact with both e164 and uuid + if (!existing || (existing && !existing.get('uuid'))) { + this._byE164[e164] = model; + } } const uuid = model.get('uuid'); if (uuid) { - this._byUuid[uuid] = model; + const existing = this._byUuid[uuid]; + + // Prefer the contact with both e164 and uuid + if (!existing || (existing && !existing.get('e164'))) { + this._byUuid[uuid] = model; + } } const groupId = model.get('groupId'); diff --git a/js/models/messages.js b/js/models/messages.js index 9fe04af502e8..abc776f92c0a 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -193,30 +193,20 @@ // Other top-level prop-generation getPropsForSearchResult() { - const sourceE164 = this.getSource(); - const sourceUuid = this.getSourceUuid(); - const fromContact = this.findAndFormatContact(sourceE164 || sourceUuid); + const ourId = ConversationController.getOurConversationId(); + const sourceId = this.getContactId(); + const fromContact = this.findAndFormatContact(sourceId); - if ( - (sourceE164 && sourceE164 === this.OUR_NUMBER) || - (sourceUuid && sourceUuid === this.OUR_UUID) - ) { + if (ourId === sourceId) { fromContact.isMe = true; } const convo = this.getConversation(); - let to = convo ? this.findAndFormatContact(convo.get('id')) : {}; + const to = convo ? this.findAndFormatContact(convo.get('id')) : {}; - if (convo && convo.isMe()) { + if (to && convo && convo.isMe()) { to.isMe = true; - } else if ( - (sourceE164 && convo && sourceE164 === convo.get('e164')) || - (sourceUuid && convo && sourceUuid === convo.get('uuid')) - ) { - to = { - isMe: true, - }; } return { @@ -237,10 +227,10 @@ const unidentifiedLookup = ( this.get('unidentifiedDeliveries') || [] - ).reduce((accumulator, uuidOrE164) => { + ).reduce((accumulator, identifier) => { // eslint-disable-next-line no-param-reassign accumulator[ - ConversationController.getConversationId(uuidOrE164) + ConversationController.getConversationId(identifier) ] = true; return accumulator; }, Object.create(null)); @@ -249,7 +239,7 @@ // Older messages don't have the recipients included on the message, so we fall // back to the conversation's current recipients const conversationIds = this.isIncoming() - ? [this.getContact().get('id')] + ? [this.getContactId()] : _.union( (this.get('sent_to') || []).map(id => ConversationController.getConversationId(id) @@ -379,11 +369,11 @@ getPropsForUnsupportedMessage() { const requiredVersion = this.get('requiredProtocolVersion'); const canProcessNow = this.CURRENT_PROTOCOL_VERSION >= requiredVersion; - const phoneNumber = this.getSource(); + const sourceId = this.getContactId(); return { canProcessNow, - contact: this.findAndFormatContact(phoneNumber), + contact: this.findAndFormatContact(sourceId), }; }, getPropsForTimerNotification() { @@ -396,8 +386,14 @@ const timespan = Whisper.ExpirationTimerOptions.getName(expireTimer || 0); const disabled = !expireTimer; + const sourceId = ConversationController.ensureContactIds({ + e164: source, + uuid: sourceUuid, + }); + const ourId = ConversationController.getOurConversationId(); + const basicProps = { - ...this.findAndFormatContact(source), + ...this.findAndFormatContact(sourceId), type: 'fromOther', timespan, disabled, @@ -408,7 +404,7 @@ ...basicProps, type: 'fromSync', }; - } else if (source === this.OUR_NUMBER || sourceUuid === this.OUR_UUID) { + } else if (sourceId && sourceId === ourId) { return { ...basicProps, type: 'fromMe', @@ -430,12 +426,12 @@ getPropsForVerificationNotification() { const type = this.get('verified') ? 'markVerified' : 'markNotVerified'; const isLocal = this.get('local'); - const phoneNumber = this.get('verifiedChanged'); + const identifier = this.get('verifiedChanged'); return { type, isLocal, - contact: this.findAndFormatContact(phoneNumber), + contact: this.findAndFormatContact(identifier), }; }, getPropsForGroupNotification() { @@ -460,7 +456,7 @@ Array.isArray(groupUpdate.joined) ? groupUpdate.joined : [groupUpdate.joined], - phoneNumber => this.findAndFormatContact(phoneNumber) + identifier => this.findAndFormatContact(identifier) ), }); } @@ -477,7 +473,7 @@ Array.isArray(groupUpdate.left) ? groupUpdate.left : [groupUpdate.left], - phoneNumber => this.findAndFormatContact(phoneNumber) + identifier => this.findAndFormatContact(identifier) ), }); } @@ -495,9 +491,8 @@ }); } - const sourceE164 = this.getSource(); - const sourceUuid = this.getSourceUuid(); - const from = this.findAndFormatContact(sourceE164 || sourceUuid); + const sourceId = this.getContactId(); + const from = this.findAndFormatContact(sourceId); return { from, @@ -537,10 +532,9 @@ .map(attachment => this.getPropsForAttachment(attachment)); }, getPropsForMessage() { - const sourceE164 = this.getSource(); - const sourceUuid = this.getSourceUuid(); - const contact = this.findAndFormatContact(sourceE164 || sourceUuid); - const contactModel = this.findContact(sourceE164 || sourceUuid); + const sourceId = this.getContactId(); + const contact = this.findAndFormatContact(sourceId); + const contactModel = this.findContact(sourceId); const authorColor = contactModel ? contactModel.getColor() : null; const authorAvatarPath = contactModel @@ -774,7 +768,13 @@ referencedMessageNotFound, } = quote; const contact = - author && ConversationController.get(author || authorUuid); + (author || authorUuid) && + ConversationController.get( + ConversationController.ensureContactIds({ + e164: author, + uuid: authorUuid, + }) + ); const authorColor = contact ? contact.getColor() : 'grey'; const authorPhoneNumber = format(author, { @@ -810,17 +810,18 @@ const e164 = conversation.get('e164'); const uuid = conversation.get('uuid'); + const conversationId = conversation.get('id'); const readBy = this.get('read_by') || []; - if (includesAny(readBy, identifier, e164, uuid)) { + if (includesAny(readBy, conversationId, e164, uuid)) { return 'read'; } const deliveredTo = this.get('delivered_to') || []; - if (includesAny(deliveredTo, identifier, e164, uuid)) { + if (includesAny(deliveredTo, conversationId, e164, uuid)) { return 'delivered'; } const sentTo = this.get('sent_to') || []; - if (includesAny(sentTo, identifier, e164, uuid)) { + if (includesAny(sentTo, conversationId, e164, uuid)) { return 'sent'; } @@ -1220,19 +1221,22 @@ return this.OUR_UUID; }, - getContact() { + getContactId() { const source = this.getSource(); const sourceUuid = this.getSourceUuid(); if (!source && !sourceUuid) { - return null; + return ConversationController.getOurConversationId(); } - const contactId = ConversationController.ensureContactIds({ + return ConversationController.ensureContactIds({ e164: source, uuid: sourceUuid, }); - return ConversationController.get(contactId, 'private'); + }, + getContact() { + const id = this.getContactId(); + return ConversationController.get(id); }, isOutgoing() { return this.get('type') === 'outgoing'; @@ -1395,7 +1399,7 @@ let recipients = _.intersection(intendedRecipients, currentRecipients); recipients = _.without(recipients, successfulRecipients).map(id => { const c = ConversationController.get(id); - return c.get('uuid') || c.get('e164'); + return c.getSendTarget(); }); if (!recipients.length) { @@ -1699,7 +1703,7 @@ try { this.set({ // These are the same as a normal send() - sent_to: [conv.get('uuid') || conv.get('e164')], + sent_to: [conv.getSendTarget()], sent: true, expirationStartTimestamp: Date.now(), }); @@ -1709,8 +1713,8 @@ unidentifiedDeliveries: result ? result.unidentifiedDeliveries : null, // These are unique to a Note to Self message - immediately read/delivered - delivered_to: [this.OUR_UUID || this.OUR_NUMBER], - read_by: [this.OUR_UUID || this.OUR_NUMBER], + delivered_to: [ConversationController.getOurConversationId()], + read_by: [ConversationController.getOurConversationId()], }); } catch (result) { const errors = (result && result.errors) || [ @@ -2004,20 +2008,20 @@ return message; } - const { attachments, id, author } = quote; + const { attachments, id, author, authorUuid } = quote; const firstAttachment = attachments[0]; + const authorConversationId = ConversationController.ensureContactIds({ + e164: author, + uuid: authorUuid, + }); const collection = await window.Signal.Data.getMessagesBySentAt(id, { MessageCollection: Whisper.MessageCollection, }); const found = collection.find(item => { - const messageAuthor = item.getContact(); + const messageAuthorId = item.getContactId(); - return ( - messageAuthor && - ConversationController.getConversationId(author) === - messageAuthor.get('id') - ); + return authorConversationId === messageAuthorId; }); if (!found) { @@ -2119,10 +2123,7 @@ const source = message.get('source'); const sourceUuid = message.get('sourceUuid'); const type = message.get('type'); - let conversationId = message.get('conversationId'); - if (initialMessage.group) { - conversationId = initialMessage.group.id; - } + const conversationId = message.get('conversationId'); const GROUP_TYPES = textsecure.protobuf.GroupContext.Type; const conversation = ConversationController.get(conversationId); @@ -2392,10 +2393,13 @@ if (conversation.get('left')) { window.log.warn('re-added to a left group'); attributes.left = false; - conversation.set({ addedBy: message.getContact().get('id') }); + conversation.set({ addedBy: message.getContactId() }); } } else if (dataMessage.group.type === GROUP_TYPES.QUIT) { - const sender = ConversationController.get(source || sourceUuid); + const sender = ConversationController.ensureContactIds({ + e164: source, + uuid: sourceUuid, + }); const inGroup = Boolean( sender && (conversation.get('members') || []).includes(sender.id) @@ -2453,6 +2457,7 @@ message.set({ expirationTimerUpdate: { source, + sourceUuid, expireTimer: dataMessage.expireTimer, }, }); @@ -2567,9 +2572,7 @@ e164: source, uuid: sourceUuid, }); - ConversationController.get(localId, 'private').setProfileKey( - profileKey - ); + ConversationController.get(localId).setProfileKey(profileKey); } } diff --git a/js/modules/refresh_sender_certificate.js b/js/modules/refresh_sender_certificate.js index 8f1433278bb0..316af56eb9e6 100644 --- a/js/modules/refresh_sender_certificate.js +++ b/js/modules/refresh_sender_certificate.js @@ -15,15 +15,8 @@ let scheduleNext = null; // do not support unidentified delivery. function refreshOurProfile() { window.log.info('refreshOurProfile'); - const ourNumber = textsecure.storage.user.getNumber(); - const ourUuid = textsecure.storage.user.getUuid(); - const ourId = ConversationController.ensureContactIds({ - e164: ourNumber, - uuid: ourUuid, - }); - const conversation = ConversationController.get(ourId, 'private'); - conversation.updateUuid(ourUuid); - conversation.updateE164(ourNumber); + const ourId = ConversationController.getOurConversationId(); + const conversation = ConversationController.get(ourId); conversation.getProfiles(); } diff --git a/js/modules/signal.js b/js/modules/signal.js index 94aac80df597..fcb804f269c0 100644 --- a/js/modules/signal.js +++ b/js/modules/signal.js @@ -3,6 +3,9 @@ const { bindActionCreators } = require('redux'); const Backbone = require('../../ts/backbone'); const Crypto = require('../../ts/Crypto'); +const { + start: conversationControllerStart, +} = require('../../ts/ConversationController'); const Data = require('../../ts/sql/Client').default; const Emojis = require('./emojis'); const EmojiLib = require('../../ts/components/emoji/lib'); @@ -357,6 +360,7 @@ exports.setup = (options = {}) => { Backbone, Components, Crypto, + conversationControllerStart, Data, Emojis, EmojiLib, diff --git a/js/reactions.js b/js/reactions.js index 254a744bbde5..36ebc282e3ab 100644 --- a/js/reactions.js +++ b/js/reactions.js @@ -28,9 +28,10 @@ const reactionsBySource = this.filter(re => { const mcid = message.get('conversationId'); - const recid = ConversationController.getConversationId( - re.get('targetAuthorE164') || re.get('targetAuthorUuid') - ); + const recid = ConversationController.ensureContactIds({ + e164: re.get('targetAuthorE164'), + uuid: re.get('targetAuthorUuid'), + }); const mTime = message.get('sent_at'); const rTime = re.get('targetTimestamp'); return mcid === recid && mTime === rTime; @@ -47,9 +48,10 @@ async onReaction(reaction) { try { const targetConversation = await ConversationController.getConversationForTargetMessage( - // Do not use ensureContactIds here since maliciously malformed - // reactions from clients could cause issues - reaction.get('targetAuthorE164') || reaction.get('targetAuthorUuid'), + ConversationController.ensureContactIds({ + e164: reaction.get('targetAuthorE164'), + uuid: reaction.get('targetAuthorUuid'), + }), reaction.get('targetTimestamp') ); if (!targetConversation) { @@ -85,10 +87,10 @@ } const mcid = contact.get('id'); - const recid = ConversationController.getConversationId( - reaction.get('targetAuthorE164') || - reaction.get('targetAuthorUuid') - ); + const recid = ConversationController.ensureContactIds({ + e164: reaction.get('targetAuthorE164'), + uuid: reaction.get('targetAuthorUuid'), + }); return mcid === recid; }); diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 7436cadb3c0b..288965a64816 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -2058,7 +2058,7 @@ await contact.setApproved(); } - message.resend(contact.get('uuid') || contact.get('e164')); + message.resend(contact.getSendTarget()); }, }); diff --git a/js/views/inbox_view.js b/js/views/inbox_view.js index b66c554b186d..f28a4a50fb19 100644 --- a/js/views/inbox_view.js +++ b/js/views/inbox_view.js @@ -84,6 +84,13 @@ model: { window: options.window }, }); + Whisper.events.on('refreshConversation', ({ oldId, newId }) => { + const convo = this.conversation_stack.lastConversation; + if (convo && convo.get('id') === oldId) { + this.conversation_stack.open(newId); + } + }); + if (!options.initialLoadComplete) { this.appLoadingScreen = new Whisper.AppLoadingScreen(); this.appLoadingScreen.render(); diff --git a/libtextsecure/test/index.html b/libtextsecure/test/index.html index 137d001a95b1..0836f1df6f8b 100644 --- a/libtextsecure/test/index.html +++ b/libtextsecure/test/index.html @@ -30,7 +30,6 @@ - @@ -51,6 +50,7 @@ diff --git a/package.json b/package.json index 999e4397b4f9..c6739d5f3183 100644 --- a/package.json +++ b/package.json @@ -64,8 +64,6 @@ "dependencies": { "@journeyapps/sqlcipher": "https://github.com/scottnonnenberg-signal/node-sqlcipher.git#b10f232fac62ba7f8775c9e086bb5558fe7d948b", "@sindresorhus/is": "0.8.0", - "@types/node-fetch": "2.5.5", - "@types/websocket": "1.0.0", "array-move": "2.1.0", "backbone": "1.3.3", "blob-util": "1.3.0", @@ -172,13 +170,14 @@ "@types/got": "9.4.1", "@types/history": "4.7.2", "@types/html-webpack-plugin": "3.2.1", - "@types/jquery": "3.3.29", + "@types/jquery": "3.5.0", "@types/js-yaml": "3.12.0", "@types/linkify-it": "2.1.0", "@types/lodash": "4.14.106", "@types/memoizee": "0.4.2", "@types/mkdirp": "0.5.2", "@types/mocha": "5.0.0", + "@types/node-fetch": "2.5.5", "@types/normalize-path": "3.0.0", "@types/pify": "3.0.2", "@types/qs": "6.5.1", @@ -197,9 +196,11 @@ "@types/storybook__addon-actions": "3.4.3", "@types/storybook__addon-knobs": "5.0.3", "@types/storybook__react": "4.0.2", + "@types/underscore": "1.10.3", "@types/uuid": "3.4.4", "@types/webpack": "4.39.0", "@types/webpack-dev-server": "3.1.7", + "@types/websocket": "1.0.0", "arraybuffer-loader": "1.0.3", "asar": "0.14.0", "babel-core": "7.0.0-bridge.0", diff --git a/test/index.html b/test/index.html index 8c86624f819d..cd238ca0a085 100644 --- a/test/index.html +++ b/test/index.html @@ -345,7 +345,6 @@ - @@ -393,6 +392,7 @@ diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts new file mode 100644 index 000000000000..3ca18dcb5097 --- /dev/null +++ b/ts/ConversationController.ts @@ -0,0 +1,675 @@ +const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; + +import { debounce, reduce, uniq, without } from 'lodash'; +import dataInterface from './sql/Client'; +import { + ConversationModelCollectionType, + ConversationModelType, + ConversationTypeType, +} from './model-types.d'; +import { SendOptionsType } from './textsecure/SendMessage'; + +const { + getAllConversations, + getAllGroupsInvolvingId, + getMessagesBySentAt, + migrateConversationMessages, + removeConversation, + saveConversation, + updateConversation, +} = dataInterface; + +// We have to run this in background.js, after all backbone models and collections on +// Whisper.* have been created. Once those are in typescript we can use more reasonable +// require statements for referencing these things, giving us more flexibility here. +export function start() { + const conversations = new window.Whisper.ConversationCollection(); + + // This class is entirely designed to keep the app title, badge and tray icon updated. + // In the future it could listen to redux changes and do its updates there. + const inboxCollection = new (window.Backbone.Collection.extend({ + initialize() { + this.listenTo(conversations, 'add change:active_at', this.addActive); + this.listenTo(conversations, 'reset', () => this.reset([])); + + this.on( + 'add remove change:unreadCount', + debounce(this.updateUnreadCount.bind(this), 1000) + ); + }, + addActive(model: ConversationModelType) { + if (model.get('active_at')) { + this.add(model); + } else { + this.remove(model); + } + }, + updateUnreadCount() { + const newUnreadCount = reduce( + this.map((m: ConversationModelType) => m.get('unreadCount')), + (item: number, memo: number) => (item || 0) + memo, + 0 + ); + window.storage.put('unreadCount', newUnreadCount); + + if (newUnreadCount > 0) { + window.setBadgeCount(newUnreadCount); + window.document.title = `${window.getTitle()} (${newUnreadCount})`; + } else { + window.setBadgeCount(0); + window.document.title = window.getTitle(); + } + window.updateTrayIcon(newUnreadCount); + }, + }))(); + + window.getInboxCollection = () => inboxCollection; + window.getConversations = () => conversations; + window.ConversationController = new ConversationController(conversations); +} + +export class ConversationController { + _initialFetchComplete: boolean | undefined; + _initialPromise: Promise = Promise.resolve(); + _conversations: ConversationModelCollectionType; + + constructor(conversations?: ConversationModelCollectionType) { + if (!conversations) { + throw new Error('ConversationController: need conversation collection!'); + } + + this._conversations = conversations; + } + + get(id?: string | null): ConversationModelType | undefined { + if (!this._initialFetchComplete) { + throw new Error( + 'ConversationController.get() needs complete initial fetch' + ); + } + + // This function takes null just fine. Backbone typings are too restrictive. + return this._conversations.get(id as string); + } + // Needed for some model setup which happens during the initial fetch() call below + getUnsafe(id: string) { + return this._conversations.get(id); + } + dangerouslyCreateAndAdd(attributes: Partial) { + return this._conversations.add(attributes); + } + getOrCreate( + identifier: string, + type: ConversationTypeType, + additionalInitialProps = {} + ) { + if (typeof identifier !== 'string') { + throw new TypeError("'id' must be a string"); + } + + if (type !== 'private' && type !== 'group') { + throw new TypeError( + `'type' must be 'private' or 'group'; got: '${type}'` + ); + } + + if (!this._initialFetchComplete) { + throw new Error( + 'ConversationController.get() needs complete initial fetch' + ); + } + + let conversation = this._conversations.get(identifier); + if (conversation) { + return conversation; + } + + const id = window.getGuid(); + + if (type === 'group') { + conversation = this._conversations.add({ + id, + uuid: null, + e164: null, + groupId: identifier, + type, + version: 2, + ...additionalInitialProps, + }); + } else if (window.isValidGuid(identifier)) { + conversation = this._conversations.add({ + id, + uuid: identifier, + e164: null, + groupId: null, + type, + version: 2, + ...additionalInitialProps, + }); + } else { + conversation = this._conversations.add({ + id, + uuid: null, + e164: identifier, + groupId: null, + type, + version: 2, + ...additionalInitialProps, + }); + } + + const create = async () => { + if (!conversation.isValid()) { + const validationError = conversation.validationError || {}; + window.log.error( + 'Contact is not valid. Not saving, but adding to collection:', + conversation.idForLogging(), + validationError.stack + ); + + return conversation; + } + + try { + await saveConversation(conversation.attributes); + } catch (error) { + window.log.error( + 'Conversation save failed! ', + identifier, + type, + 'Error:', + error && error.stack ? error.stack : error + ); + throw error; + } + + return conversation; + }; + + conversation.initialPromise = create(); + + return conversation; + } + async getOrCreateAndWait( + id: string, + type: ConversationTypeType, + additionalInitialProps = {} + ) { + return this._initialPromise.then(async () => { + const conversation = this.getOrCreate(id, type, additionalInitialProps); + + if (conversation) { + return conversation.initialPromise.then(() => conversation); + } + + return Promise.reject( + new Error('getOrCreateAndWait: did not get conversation') + ); + }); + } + getConversationId(address: string) { + if (!address) { + return null; + } + + const [id] = window.textsecure.utils.unencodeNumber(address); + const conv = this.get(id); + + if (conv) { + return conv.get('id'); + } + + return null; + } + getOurConversationId() { + const e164 = window.textsecure.storage.user.getNumber(); + const uuid = window.textsecure.storage.user.getUuid(); + return this.ensureContactIds({ e164, uuid, highTrust: true }); + } + /** + * Given a UUID and/or an E164, resolves to a string representing the local + * database id of the given contact. It may create new contacts, and it may merge + * contacts. + * + * lowTrust = uuid/e164 pairing came from source like GroupV1 member list + * highTrust = uuid/e164 pairing came from source like CDS + */ + // tslint:disable-next-line cyclomatic-complexity max-func-body-length + ensureContactIds({ + e164, + uuid, + highTrust, + }: { + e164?: string; + uuid?: string; + highTrust?: boolean; + }) { + // Check for at least one parameter being provided. This is necessary + // because this path can be called on startup to resolve our own ID before + // our phone number or UUID are known. The existing behavior in these + // cases can handle a returned `undefined` id, so we do that. + const normalizedUuid = uuid ? uuid.toLowerCase() : undefined; + const identifier = normalizedUuid || e164; + + if ((!e164 && !uuid) || !identifier) { + return undefined; + } + + const convoE164 = this.get(e164); + const convoUuid = this.get(normalizedUuid); + + // 1. Handle no match at all + if (!convoE164 && !convoUuid) { + window.log.info( + 'ensureContactIds: Creating new contact, no matches found' + ); + const newConvo = this.getOrCreate(identifier, 'private'); + if (highTrust && e164) { + newConvo.updateE164(e164); + } + if (normalizedUuid) { + newConvo.updateUuid(normalizedUuid); + } + if (highTrust && e164 && normalizedUuid) { + updateConversation(newConvo.attributes); + } + + return newConvo.get('id'); + + // 2. Handle match on only E164 + } else if (convoE164 && !convoUuid) { + const haveUuid = Boolean(normalizedUuid); + window.log.info( + `ensureContactIds: e164-only match found (have UUID: ${haveUuid})` + ); + // If we are only searching based on e164 anyway, then return the first result + if (!normalizedUuid) { + return convoE164.get('id'); + } + + // Fill in the UUID for an e164-only contact + if (normalizedUuid && !convoE164.get('uuid')) { + if (highTrust) { + window.log.info('ensureContactIds: Adding UUID to e164-only match'); + convoE164.updateUuid(normalizedUuid); + updateConversation(convoE164.attributes); + } + return convoE164.get('id'); + } + + window.log.info( + 'ensureContactIds: e164 already had UUID, creating a new contact' + ); + // If existing e164 match already has UUID, create a new contact... + const newConvo = this.getOrCreate(normalizedUuid, 'private'); + + if (highTrust) { + window.log.info( + 'ensureContactIds: Moving e164 from old contact to new' + ); + + // Remove the e164 from the old contact... + convoE164.set({ e164: undefined }); + updateConversation(convoE164.attributes); + + // ...and add it to the new one. + newConvo.updateE164(e164); + updateConversation(newConvo.attributes); + } + + return newConvo.get('id'); + + // 3. Handle match on only UUID + } else if (!convoE164 && convoUuid) { + window.log.info( + `ensureContactIds: UUID-only match found (have e164: ${Boolean(e164)})` + ); + if (e164 && highTrust) { + convoUuid.updateE164(e164); + updateConversation(convoUuid.attributes); + } + return convoUuid.get('id'); + } + + // For some reason, TypeScript doesn't believe that we can trust that these two values + // are truthy by this point. So we'll throw if we get there. + if (!convoE164 || !convoUuid) { + throw new Error('ensureContactIds: convoE164 or convoUuid are falsey!'); + } + + // Now, we know that we have a match for both e164 and uuid checks + + if (convoE164 === convoUuid) { + return convoUuid.get('id'); + } + + if (highTrust) { + // Conflict: If e164 match already has a UUID, we remove its e164. + if (convoE164.get('uuid') && convoE164.get('uuid') !== normalizedUuid) { + window.log.info( + 'ensureContactIds: e164 match had different UUID than incoming pair, removing its e164.' + ); + + // Remove the e164 from the old contact... + convoE164.set({ e164: undefined }); + updateConversation(convoE164.attributes); + + // ...and add it to the new one. + convoUuid.updateE164(e164); + updateConversation(convoUuid.attributes); + + return convoUuid.get('id'); + } + + window.log.warn( + `ensureContactIds: Found a split contact - UUID ${normalizedUuid} and E164 ${e164}. Merging.` + ); + + // Conflict: If e164 match has no UUID, we merge. We prefer the UUID match. + // Note: no await here, we want to keep this function synchronous + this.combineContacts(convoUuid, convoE164) + .then(() => { + // If the old conversation was currently displayed, we load the new one + window.Whisper.events.trigger('refreshConversation', { + newId: convoUuid.get('id'), + oldId: convoE164.get('id'), + }); + }) + .catch(error => { + const errorText = error && error.stack ? error.stack : error; + window.log.warn( + `ensureContactIds error combining contacts: ${errorText}` + ); + }); + } + + return convoUuid.get('id'); + } + async checkForConflicts() { + window.log.info('checkForConflicts: starting...'); + const byUuid = Object.create(null); + const byE164 = Object.create(null); + + // We iterate from the oldest conversations to the newest. This allows us, in a + // conflict case, to keep the one with activity the most recently. + const models = [...this._conversations.models.reverse()]; + + const max = models.length; + for (let i = 0; i < max; i += 1) { + const conversation = models[i]; + const uuid = conversation.get('uuid'); + const e164 = conversation.get('e164'); + + if (uuid) { + const existing = byUuid[uuid]; + if (!existing) { + byUuid[uuid] = conversation; + } else { + window.log.warn( + `checkForConflicts: Found conflict with uuid ${uuid}` + ); + + // Keep the newer one if it has an e164, otherwise keep existing + if (conversation.get('e164')) { + // Keep new one + // eslint-disable-next-line no-await-in-loop + await this.combineContacts(conversation, existing); + byUuid[uuid] = conversation; + } else { + // Keep existing - note that this applies if neither had an e164 + // eslint-disable-next-line no-await-in-loop + await this.combineContacts(existing, conversation); + } + } + } + + if (e164) { + const existing = byE164[e164]; + if (!existing) { + byE164[e164] = conversation; + } else { + // If we have two contacts with the same e164 but different truthy UUIDs, then + // we'll delete the e164 on the older one + if ( + conversation.get('uuid') && + existing.get('uuid') && + conversation.get('uuid') !== existing.get('uuid') + ) { + window.log.warn( + `checkForConflicts: Found two matches on e164 ${e164} with different truthy UUIDs. Dropping e164 on older.` + ); + + existing.set({ e164: undefined }); + updateConversation(existing.attributes); + + byE164[e164] = conversation; + + // eslint-disable-next-line no-continue + continue; + } + + window.log.warn( + `checkForConflicts: Found conflict with e164 ${e164}` + ); + + // Keep the newer one if it has a UUID, otherwise keep existing + if (conversation.get('uuid')) { + // Keep new one + // eslint-disable-next-line no-await-in-loop + await this.combineContacts(conversation, existing); + byE164[e164] = conversation; + } else { + // Keep existing - note that this applies if neither had a UUID + // eslint-disable-next-line no-await-in-loop + await this.combineContacts(existing, conversation); + } + } + } + } + + window.log.info('checkForConflicts: complete!'); + } + async combineContacts( + current: ConversationModelType, + obsolete: ConversationModelType + ) { + const obsoleteId = obsolete.get('id'); + const currentId = current.get('id'); + window.log.warn('combineContacts: Combining two conversations', { + obsolete: obsoleteId, + current: currentId, + }); + + if (!current.get('profileKey') && obsolete.get('profileKey')) { + window.log.warn( + 'combineContacts: Copying profile key from old to new contact' + ); + + await current.setProfileKey(obsolete.get('profileKey')); + } + + window.log.warn( + 'combineContacts: Delete all sessions tied to old conversationId' + ); + const deviceIds = await window.textsecure.storage.protocol.getDeviceIds( + obsoleteId + ); + await Promise.all( + deviceIds.map(async deviceId => { + await window.textsecure.storage.protocol.removeSession( + `${obsoleteId}.${deviceId}` + ); + }) + ); + + window.log.warn( + 'combineContacts: Delete all identity information tied to old conversationId' + ); + await window.textsecure.storage.protocol.removeIdentityKey(obsoleteId); + + window.log.warn( + 'combineContacts: Ensure that all V1 groups have new conversationId instead of old' + ); + const groups = await this.getAllGroupsInvolvingId(obsoleteId); + groups.forEach(group => { + const members = group.get('members'); + const withoutObsolete = without(members, obsoleteId); + const currentAdded = uniq([...withoutObsolete, currentId]); + + group.set({ + members: currentAdded, + }); + updateConversation(group.attributes); + }); + + // Note: we explicitly don't want to update V2 groups + + window.log.warn( + 'combineContacts: Delete the obsolete conversation from the database' + ); + await removeConversation(obsoleteId, { + Conversation: window.Whisper.Conversation, + }); + + window.log.warn('combineContacts: Update messages table'); + await migrateConversationMessages(obsoleteId, currentId); + + window.log.warn( + 'combineContacts: Eliminate old conversation from ConversationController lookups' + ); + this._conversations.remove(obsolete); + this.regenerateLookups(); + + window.log.warn('combineContacts: Complete!', { + obsolete: obsoleteId, + current: currentId, + }); + } + regenerateLookups() { + const models = [...this._conversations.models]; + this.reset(); + this._conversations.add(models); + + // We force the initial fetch to be true + this._initialFetchComplete = true; + } + /** + * Given a groupId and optional additional initialization properties, + * ensures the existence of a group conversation and returns a string + * representing the local database ID of the group conversation. + */ + ensureGroup(groupId: string, additionalInitProps = {}) { + return this.getOrCreate(groupId, 'group', additionalInitProps).get('id'); + } + /** + * Given certain metadata about a message (an identifier of who wrote the + * message and the sent_at timestamp of the message) returns the + * conversation the message belongs to OR null if a conversation isn't + * found. + */ + async getConversationForTargetMessage( + targetFromId: string, + targetTimestamp: number + ) { + const messages = await getMessagesBySentAt(targetTimestamp, { + MessageCollection: window.Whisper.MessageCollection, + }); + const targetMessage = messages.find(m => { + const contact = m.getContact(); + + if (!contact) { + return false; + } + + const mcid = contact.get('id'); + return mcid === targetFromId; + }); + + if (targetMessage) { + return targetMessage.getConversation(); + } + + return null; + } + prepareForSend( + id: string, + options?: any + ): { + wrap: (promise: Promise) => Promise; + sendOptions: SendOptionsType | undefined; + } { + // id is any valid conversation identifier + const conversation = this.get(id); + const sendOptions = conversation + ? conversation.getSendOptions(options) + : undefined; + const wrap = conversation + ? conversation.wrapSend.bind(conversation) + : async (promise: Promise) => promise; + + return { wrap, sendOptions }; + } + async getAllGroupsInvolvingId( + conversationId: string + ): Promise> { + const groups = await getAllGroupsInvolvingId(conversationId, { + ConversationCollection: window.Whisper.ConversationCollection, + }); + return groups.map(group => this._conversations.add(group)); + } + async loadPromise() { + return this._initialPromise; + } + reset() { + this._initialPromise = Promise.resolve(); + this._initialFetchComplete = false; + this._conversations.reset([]); + } + async load() { + window.log.info('ConversationController: starting initial fetch'); + + if (this._conversations.length) { + throw new Error('ConversationController: Already loaded!'); + } + + const load = async () => { + try { + const collection = await getAllConversations({ + ConversationCollection: window.Whisper.ConversationCollection, + }); + + this._conversations.add(collection.models); + + this._initialFetchComplete = true; + + await Promise.all( + this._conversations.map(async conversation => { + if (!conversation.get('lastMessage')) { + await conversation.updateLastMessage(); + } + + // In case a too-large draft was saved to the database + const draft = conversation.get('draft'); + if (draft && draft.length > MAX_MESSAGE_BODY_LENGTH) { + conversation.set({ + draft: draft.slice(0, MAX_MESSAGE_BODY_LENGTH), + }); + updateConversation(conversation.attributes); + } + }) + ); + window.log.info('ConversationController: done with initial fetch'); + } catch (error) { + window.log.error( + 'ConversationController: initial fetch failed', + error && error.stack ? error.stack : error + ); + throw error; + } + }; + + this._initialPromise = load(); + + return this._initialPromise; + } +} diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index fe3d7a9b41d9..0ecd3cc9eb5d 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -1,7 +1,9 @@ import * as Backbone from 'backbone'; + import { ColorType, LocalizerType } from './types/Util'; -import { SendOptionsType } from './textsecure/SendMessage'; import { ConversationType } from './state/ducks/conversations'; +import { CallingClass, CallHistoryDetailsType } from './services/calling'; +import { SendOptionsType } from './textsecure/SendMessage'; import { SyncMessageClass } from './textsecure.d'; interface ModelAttributesInterface { @@ -74,6 +76,7 @@ declare class ConversationModelType extends Backbone.Model< cachedProps: ConversationType; initialPromise: Promise; + addCallHistory(details: CallHistoryDetailsType): void; applyMessageRequestResponse( response: number, options?: { fromSync: boolean } diff --git a/ts/services/calling.ts b/ts/services/calling.ts index d483c7933ad8..5e932695845a 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -16,7 +16,7 @@ import { CallDetailsType, } from '../state/ducks/calling'; import { CallingMessageClass, EnvelopeClass } from '../textsecure.d'; -import { ConversationType } from '../window.d'; +import { ConversationModelType } from '../model-types.d'; import is from '@sindresorhus/is'; export { @@ -72,7 +72,7 @@ export class CallingClass { } async startOutgoingCall( - conversation: ConversationType, + conversation: ConversationModelType, isVideoCall: boolean ) { if (!this.uxActions) { @@ -218,7 +218,14 @@ export class CallingClass { message: CallingMessageClass ): Promise { const conversation = window.ConversationController.get(remoteUserId); - const sendOptions = conversation ? conversation.getSendOptions() : {}; + const sendOptions = conversation + ? conversation.getSendOptions() + : undefined; + + if (!window.textsecure.messaging) { + window.log.warn('handleOutgoingSignaling() returning false; offline'); + return false; + } try { await window.textsecure.messaging.sendCallingMessage( @@ -292,7 +299,7 @@ export class CallingClass { this.addCallHistoryForAutoEndedIncomingCall(conversation, reason); } - private attachToCall(conversation: ConversationType, call: Call): void { + private attachToCall(conversation: ConversationModelType, call: Call): void { const { uxActions } = this; if (!uxActions) { return; @@ -340,7 +347,7 @@ export class CallingClass { } private getRemoteUserIdFromConversation( - conversation: ConversationType + conversation: ConversationModelType ): UserId | undefined { const recipients = conversation.getRecipients(); if (recipients.length !== 1) { @@ -366,8 +373,12 @@ export class CallingClass { } private async getCallSettings( - conversation: ConversationType + conversation: ConversationModelType ): Promise { + if (!window.textsecure.messaging) { + throw new Error('getCallSettings: offline!'); + } + const iceServerJson = await window.textsecure.messaging.server.getIceServers(); const shouldRelayCalls = Boolean(await window.getAlwaysRelayCalls()); @@ -382,7 +393,7 @@ export class CallingClass { } private getUxCallDetails( - conversation: ConversationType, + conversation: ConversationModelType, call: Call ): CallDetailsType { return { @@ -398,7 +409,7 @@ export class CallingClass { } private addCallHistoryForEndedCall( - conversation: ConversationType, + conversation: ConversationModelType, call: Call, acceptedTime: number | undefined ) { @@ -429,7 +440,7 @@ export class CallingClass { } private addCallHistoryForFailedIncomingCall( - conversation: ConversationType, + conversation: ConversationModelType, call: Call ) { const callHistoryDetails: CallHistoryDetailsType = { @@ -444,7 +455,7 @@ export class CallingClass { } private addCallHistoryForAutoEndedIncomingCall( - conversation: ConversationType, + conversation: ConversationModelType, _reason: CallEndedReason ) { const callHistoryDetails: CallHistoryDetailsType = { diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 3b38ef876080..6e16c5b52851 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -19,12 +19,15 @@ import { arrayBufferToBase64, base64ToArrayBuffer } from '../Crypto'; import { CURRENT_SCHEMA_VERSION } from '../../js/modules/types/message'; import { createBatcher } from '../util/batcher'; +import { + ConversationModelCollectionType, + ConversationModelType, + MessageModelCollectionType, + MessageModelType, +} from '../model-types.d'; + import { AttachmentDownloadJobType, - BackboneConversationCollectionType, - BackboneConversationModelType, - BackboneMessageCollectionType, - BackboneMessageModelType, ClientInterface, ClientJobType, ConversationType, @@ -153,6 +156,7 @@ const dataInterface: ClientInterface = { getOlderMessagesByConversation, getNewerMessagesByConversation, getMessageMetricsForConversation, + migrateConversationMessages, getUnprocessedCount, getAllUnprocessed, @@ -719,7 +723,7 @@ async function saveConversations(array: Array) { async function getConversationById( id: string, - { Conversation }: { Conversation: BackboneConversationModelType } + { Conversation }: { Conversation: typeof ConversationModelType } ) { const data = await channels.getConversationById(id); @@ -749,7 +753,7 @@ async function updateConversations(array: Array) { async function removeConversation( id: string, - { Conversation }: { Conversation: BackboneConversationModelType } + { Conversation }: { Conversation: typeof ConversationModelType } ) { const existing = await getConversationById(id, { Conversation }); @@ -769,8 +773,8 @@ async function _removeConversations(ids: Array) { async function getAllConversations({ ConversationCollection, }: { - ConversationCollection: BackboneConversationCollectionType; -}) { + ConversationCollection: typeof ConversationModelCollectionType; +}): Promise { const conversations = await channels.getAllConversations(); const collection = new ConversationCollection(); @@ -788,7 +792,7 @@ async function getAllConversationIds() { async function getAllPrivateConversations({ ConversationCollection, }: { - ConversationCollection: BackboneConversationCollectionType; + ConversationCollection: typeof ConversationModelCollectionType; }) { const conversations = await channels.getAllPrivateConversations(); @@ -803,7 +807,7 @@ async function getAllGroupsInvolvingId( { ConversationCollection, }: { - ConversationCollection: BackboneConversationCollectionType; + ConversationCollection: typeof ConversationModelCollectionType; } ) { const conversations = await channels.getAllGroupsInvolvingId(id); @@ -861,7 +865,7 @@ async function saveMessage( { forceSave, Message, - }: { forceSave?: boolean; Message: BackboneMessageModelType } + }: { forceSave?: boolean; Message: typeof MessageModelType } ) { const id = await channels.saveMessage(_cleanData(data), { forceSave }); Message.updateTimers(); @@ -878,7 +882,7 @@ async function saveMessages( async function removeMessage( id: string, - { Message }: { Message: BackboneMessageModelType } + { Message }: { Message: typeof MessageModelType } ) { const message = await getMessageById(id, { Message }); @@ -897,7 +901,7 @@ async function _removeMessages(ids: Array) { async function getMessageById( id: string, - { Message }: { Message: BackboneMessageModelType } + { Message }: { Message: typeof MessageModelType } ) { const message = await channels.getMessageById(id); if (!message) { @@ -911,7 +915,7 @@ async function getMessageById( async function _getAllMessages({ MessageCollection, }: { - MessageCollection: BackboneMessageCollectionType; + MessageCollection: typeof MessageModelCollectionType; }) { const messages = await channels._getAllMessages(); @@ -936,7 +940,7 @@ async function getMessageBySender( sourceDevice: string; sent_at: number; }, - { Message }: { Message: BackboneMessageModelType } + { Message }: { Message: typeof MessageModelType } ) { const messages = await channels.getMessageBySender({ source, @@ -953,7 +957,9 @@ async function getMessageBySender( async function getUnreadByConversation( conversationId: string, - { MessageCollection }: { MessageCollection: BackboneMessageCollectionType } + { + MessageCollection, + }: { MessageCollection: typeof MessageModelCollectionType } ) { const messages = await channels.getUnreadByConversation(conversationId); @@ -975,7 +981,7 @@ async function getOlderMessagesByConversation( limit?: number; receivedAt?: number; messageId?: string; - MessageCollection: BackboneMessageCollectionType; + MessageCollection: typeof MessageModelCollectionType; } ) { const messages = await channels.getOlderMessagesByConversation( @@ -998,7 +1004,7 @@ async function getNewerMessagesByConversation( }: { limit?: number; receivedAt?: number; - MessageCollection: BackboneMessageCollectionType; + MessageCollection: typeof MessageModelCollectionType; } ) { const messages = await channels.getNewerMessagesByConversation( @@ -1018,10 +1024,18 @@ async function getMessageMetricsForConversation(conversationId: string) { return result; } +async function migrateConversationMessages( + obsoleteId: string, + currentId: string +) { + await channels.migrateConversationMessages(obsoleteId, currentId); +} async function removeAllMessagesInConversation( conversationId: string, - { MessageCollection }: { MessageCollection: BackboneMessageCollectionType } + { + MessageCollection, + }: { MessageCollection: typeof MessageModelCollectionType } ) { let messages; do { @@ -1036,12 +1050,12 @@ async function removeAllMessagesInConversation( return; } - const ids = messages.map((message: BackboneMessageModelType) => message.id); + const ids = messages.map((message: MessageModelType) => message.id); // Note: It's very important that these models are fully hydrated because // we need to delete all associated on-disk files along with the database delete. await Promise.all( - messages.map((message: BackboneMessageModelType) => message.cleanup()) + messages.map(async (message: MessageModelType) => message.cleanup()) ); await channels.removeMessage(ids); @@ -1050,7 +1064,9 @@ async function removeAllMessagesInConversation( async function getMessagesBySentAt( sentAt: number, - { MessageCollection }: { MessageCollection: BackboneMessageCollectionType } + { + MessageCollection, + }: { MessageCollection: typeof MessageModelCollectionType } ) { const messages = await channels.getMessagesBySentAt(sentAt); @@ -1060,7 +1076,7 @@ async function getMessagesBySentAt( async function getExpiredMessages({ MessageCollection, }: { - MessageCollection: BackboneMessageCollectionType; + MessageCollection: typeof MessageModelCollectionType; }) { const messages = await channels.getExpiredMessages(); @@ -1070,7 +1086,7 @@ async function getExpiredMessages({ async function getOutgoingWithoutExpiresAt({ MessageCollection, }: { - MessageCollection: BackboneMessageCollectionType; + MessageCollection: typeof MessageModelCollectionType; }) { const messages = await channels.getOutgoingWithoutExpiresAt(); @@ -1080,7 +1096,7 @@ async function getOutgoingWithoutExpiresAt({ async function getNextExpiringMessage({ Message, }: { - Message: BackboneMessageModelType; + Message: typeof MessageModelType; }) { const message = await channels.getNextExpiringMessage(); @@ -1094,7 +1110,7 @@ async function getNextExpiringMessage({ async function getNextTapToViewMessageToAgeOut({ Message, }: { - Message: BackboneMessageModelType; + Message: typeof MessageModelType; }) { const message = await channels.getNextTapToViewMessageToAgeOut(); if (!message) { @@ -1106,7 +1122,7 @@ async function getNextTapToViewMessageToAgeOut({ async function getTapToViewMessagesNeedingErase({ MessageCollection, }: { - MessageCollection: BackboneMessageCollectionType; + MessageCollection: typeof MessageModelCollectionType; }) { const messages = await channels.getTapToViewMessagesNeedingErase(); diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 547ff84e58f9..be64a7c708f4 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -17,10 +17,12 @@ export type StickerPackType = any; export type StickerType = any; export type UnprocessedType = any; -export type BackboneConversationModelType = any; -export type BackboneConversationCollectionType = any; -export type BackboneMessageModelType = any; -export type BackboneMessageCollectionType = any; +import { + ConversationModelCollectionType, + ConversationModelType, + MessageModelCollectionType, + MessageModelType, +} from '../model-types.d'; export interface DataInterface { close: () => Promise; @@ -94,6 +96,10 @@ export interface DataInterface { getMessageMetricsForConversation: ( conversationId: string ) => Promise; + migrateConversationMessages: ( + obsoleteId: string, + currentId: string + ) => Promise; getUnprocessedCount: () => Promise; getAllUnprocessed: () => Promise>; @@ -242,33 +248,33 @@ export type ClientInterface = DataInterface & { getAllConversations: ({ ConversationCollection, }: { - ConversationCollection: BackboneConversationCollectionType; - }) => Promise>; + ConversationCollection: typeof ConversationModelCollectionType; + }) => Promise; getAllGroupsInvolvingId: ( id: string, { ConversationCollection, }: { - ConversationCollection: BackboneConversationCollectionType; + ConversationCollection: typeof ConversationModelCollectionType; } - ) => Promise>; + ) => Promise; getAllPrivateConversations: ({ ConversationCollection, }: { - ConversationCollection: BackboneConversationCollectionType; - }) => Promise>; + ConversationCollection: typeof ConversationModelCollectionType; + }) => Promise; getConversationById: ( id: string, - { Conversation }: { Conversation: BackboneConversationModelType } - ) => Promise; + { Conversation }: { Conversation: typeof ConversationModelType } + ) => Promise; getExpiredMessages: ({ MessageCollection, }: { - MessageCollection: BackboneMessageCollectionType; - }) => Promise>; + MessageCollection: typeof MessageModelCollectionType; + }) => Promise; getMessageById: ( id: string, - { Message }: { Message: BackboneMessageModelType } + { Message }: { Message: typeof MessageModelType } ) => Promise; getMessageBySender: ( options: { @@ -277,63 +283,67 @@ export type ClientInterface = DataInterface & { sourceDevice: string; sent_at: number; }, - { Message }: { Message: BackboneMessageModelType } - ) => Promise>; + { Message }: { Message: typeof MessageModelType } + ) => Promise; getMessagesBySentAt: ( sentAt: number, - { MessageCollection }: { MessageCollection: BackboneMessageCollectionType } - ) => Promise>; + { + MessageCollection, + }: { MessageCollection: typeof MessageModelCollectionType } + ) => Promise; getOlderMessagesByConversation: ( conversationId: string, options: { limit?: number; receivedAt?: number; - MessageCollection: BackboneMessageCollectionType; + MessageCollection: typeof MessageModelCollectionType; } - ) => Promise>; + ) => Promise; getNewerMessagesByConversation: ( conversationId: string, options: { limit?: number; receivedAt?: number; - MessageCollection: BackboneMessageCollectionType; + MessageCollection: typeof MessageModelCollectionType; } - ) => Promise>; + ) => Promise; getNextExpiringMessage: ({ Message, }: { - Message: BackboneMessageModelType; - }) => Promise; + Message: typeof MessageModelType; + }) => Promise; getNextTapToViewMessageToAgeOut: ({ Message, }: { - Message: BackboneMessageModelType; - }) => Promise; + Message: typeof MessageModelType; + }) => Promise; getOutgoingWithoutExpiresAt: ({ MessageCollection, }: { - MessageCollection: BackboneMessageCollectionType; - }) => Promise>; + MessageCollection: typeof MessageModelCollectionType; + }) => Promise; getTapToViewMessagesNeedingErase: ({ MessageCollection, }: { - MessageCollection: BackboneMessageCollectionType; - }) => Promise>; + MessageCollection: typeof MessageModelCollectionType; + }) => Promise; getUnreadByConversation: ( conversationId: string, - { MessageCollection }: { MessageCollection: BackboneMessageCollectionType } - ) => Promise>; + { + MessageCollection, + }: { MessageCollection: typeof MessageModelCollectionType } + ) => Promise; removeConversation: ( id: string, - { Conversation }: { Conversation: BackboneConversationModelType } + { Conversation }: { Conversation: typeof ConversationModelType } ) => Promise; removeMessage: ( id: string, - { Message }: { Message: BackboneMessageModelType } + { Message }: { Message: typeof MessageModelType } ) => Promise; saveMessage: ( data: MessageType, - options: { forceSave?: boolean; Message: BackboneMessageModelType } + options: { forceSave?: boolean; Message: typeof MessageModelType } ) => Promise; updateConversation: (data: ConversationType) => void; @@ -342,15 +352,17 @@ export type ClientInterface = DataInterface & { _getAllMessages: ({ MessageCollection, }: { - MessageCollection: BackboneMessageCollectionType; - }) => Promise>; + MessageCollection: typeof MessageModelCollectionType; + }) => Promise; // Client-side only shutdown: () => Promise; removeAllMessagesInConversation: ( conversationId: string, - { MessageCollection }: { MessageCollection: BackboneMessageCollectionType } + { + MessageCollection, + }: { MessageCollection: typeof MessageModelCollectionType } ) => Promise; removeOtherData: () => Promise; cleanupOrphanedAttachments: () => Promise; diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index eaf18aeb373a..ca7c9902a607 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -131,6 +131,7 @@ const dataInterface: ServerInterface = { getOlderMessagesByConversation, getNewerMessagesByConversation, getMessageMetricsForConversation, + migrateConversationMessages, getUnprocessedCount, getAllUnprocessed, @@ -2797,6 +2798,25 @@ async function getMessageMetricsForConversation(conversationId: string) { } getMessageMetricsForConversation.needsSerial = true; +async function migrateConversationMessages( + obsoleteId: string, + currentId: string +) { + const db = getInstance(); + + await db.run( + `UPDATE messages SET + conversationId = $currentId, + json = json_set(json, '$.conversationId', $currentId) + WHERE conversationId = $obsoleteId;`, + { + $obsoleteId: obsoleteId, + $currentId: currentId, + } + ); +} +migrateConversationMessages.needsSerial = true; + async function getMessagesBySentAt(sentAt: number) { const db = getInstance(); const rows = await db.all( diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index bd2a0897acb1..123b8ce1ee9c 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -10,6 +10,8 @@ import EventTarget from './textsecure/EventTarget'; import { ByteBufferClass } from './window.d'; import { SendOptionsType } from './textsecure/SendMessage'; import { WebAPIType } from './textsecure/WebAPI'; +import utils from './textsecure/Helpers'; +import SendMessage from './textsecure/SendMessage'; type AttachmentType = any; @@ -79,31 +81,9 @@ export type TextSecureType = { attachment: AttachmentPointerClass ) => Promise; }; - messaging: { - getStorageCredentials: () => Promise; - getStorageManifest: ( - options: StorageServiceCallOptionsType - ) => Promise; - getStorageRecords: ( - data: ArrayBuffer, - options: StorageServiceCallOptionsType - ) => Promise; - sendStickerPackSync: ( - operations: Array<{ - packId: string; - packKey: string; - installed: boolean; - }>, - options: Object - ) => Promise; - sendCallingMessage: ( - recipientId: string, - callingMessage: CallingMessageClass, - sendOptions: SendOptionsType - ) => Promise; - server: WebAPIType; - }; + messaging?: SendMessage; protobuf: ProtobufCollectionType; + utils: typeof utils; EventTarget: typeof EventTarget; MessageReceiver: typeof MessageReceiver; @@ -150,6 +130,7 @@ export type StorageProtocolType = StorageType & { verifiedStatus: number, publicKey: ArrayBuffer ) => Promise; + removeIdentityKey: (identifier: string) => Promise; saveIdentityWithAttributes: ( number: string, options: IdentityKeyRecord diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts index 0d5d85831b22..32cd6eb6c8a5 100644 --- a/ts/textsecure/AccountManager.ts +++ b/ts/textsecure/AccountManager.ts @@ -696,6 +696,7 @@ export default class AccountManager extends EventTarget { const conversationId = window.ConversationController.ensureContactIds({ e164: number, uuid, + highTrust: true, }); if (!conversationId) { throw new Error('registrationDone: no conversationId!'); diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 059b0276a6da..1247c9767970 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -1058,6 +1058,7 @@ class MessageReceiverInner extends EventTarget { ) { const { destination, + destinationUuid, timestamp, message: msg, expirationStartTimestamp, @@ -1083,12 +1084,13 @@ class MessageReceiverInner extends EventTarget { msg.flags && msg.flags & window.textsecure.protobuf.DataMessage.Flags.END_SESSION ) { - if (!destination) { + const identifier = destination || destinationUuid; + if (!identifier) { throw new Error( 'MessageReceiver.handleSentMessage: Cannot end session with falsey destination' ); } - p = this.handleEndSession(destination); + p = this.handleEndSession(identifier); } return p.then(async () => this.processDecrypted(envelope, msg).then(message => { @@ -1120,6 +1122,7 @@ class MessageReceiverInner extends EventTarget { ev.confirm = this.removeFromCache.bind(this, envelope); ev.data = { destination, + destinationUuid, timestamp: timestamp.toNumber(), serverTimestamp: envelope.serverTimestamp, device: envelope.sourceDevice, @@ -1303,7 +1306,8 @@ class MessageReceiverInner extends EventTarget { ev.timestamp = envelope.timestamp.toNumber(); ev.read = { timestamp: receiptMessage.timestamp[i].toNumber(), - reader: envelope.source || envelope.sourceUuid, + source: envelope.source, + sourceUuid: envelope.sourceUuid, }; results.push(this.dispatchAndWait(ev)); } diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 19c1425e2970..bd3af3c5e41a 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -925,7 +925,7 @@ export default class MessageSender { async sendCallingMessage( recipientId: string, callingMessage: CallingMessageClass, - sendOptions: SendOptionsType + sendOptions?: SendOptionsType ) { const recipients = [recipientId]; const finalTimestamp = Date.now(); @@ -1001,7 +1001,11 @@ export default class MessageSender { ); } async syncReadMessages( - reads: Array<{ sender: string; timestamp: number }>, + reads: Array<{ + senderUuid?: string; + senderE164?: string; + timestamp: number; + }>, options?: SendOptionsType ) { const myNumber = window.textsecure.storage.user.getNumber(); @@ -1013,7 +1017,8 @@ export default class MessageSender { for (let i = 0; i < reads.length; i += 1) { const read = new window.textsecure.protobuf.SyncMessage.Read(); read.timestamp = reads[i].timestamp; - read.sender = reads[i].sender; + read.sender = reads[i].senderE164; + read.senderUuid = reads[i].senderUuid; syncMessage.read.push(read); } @@ -1352,20 +1357,20 @@ export default class MessageSender { proto.flags = window.textsecure.protobuf.DataMessage.Flags.END_SESSION; proto.timestamp = timestamp; - const identifier = e164 || uuid; + const identifier = uuid || e164; const logError = (prefix: string) => (error: Error) => { window.log.error(prefix, error && error.stack ? error.stack : error); throw error; }; - const deleteAllSessions = async (targetNumber: string) => + const deleteAllSessions = async (targetIdentifier: string) => window.textsecure.storage.protocol - .getDeviceIds(targetNumber) + .getDeviceIds(targetIdentifier) .then(async deviceIds => Promise.all( deviceIds.map(async deviceId => { const address = new window.libsignal.SignalProtocolAddress( - targetNumber, + targetIdentifier, deviceId ); window.log.info('deleting sessions for', address.toString()); @@ -1401,7 +1406,7 @@ export default class MessageSender { const myNumber = window.textsecure.storage.user.getNumber(); const myUuid = window.textsecure.storage.user.getUuid(); // We already sent the reset session to our other devices in the code above! - if (e164 === myNumber || uuid === myUuid) { + if ((e164 && e164 === myNumber) || (uuid && uuid === myUuid)) { return sendToContactPromise; } diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index b8c9db7f6a4d..8dddfde816eb 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -6,6 +6,7 @@ import { Agent } from 'https'; import is from '@sindresorhus/is'; import { redactPackId } from '../../js/modules/stickers'; import { getRandomValue } from '../Crypto'; +import MessageSender from './SendMessage'; import PQueue from 'p-queue'; import { v4 as getGuid } from 'uuid'; @@ -13,7 +14,6 @@ import { v4 as getGuid } from 'uuid'; import { StorageServiceCallOptionsType, StorageServiceCredentials, - TextSecureType, } from '../textsecure.d'; // tslint:disable no-bitwise @@ -589,9 +589,9 @@ export type WebAPIType = { getSenderCertificate: (withUuid?: boolean) => Promise; getSticker: (packId: string, stickerId: string) => Promise; getStickerPackManifest: (packId: string) => Promise; - getStorageCredentials: TextSecureType['messaging']['getStorageCredentials']; - getStorageManifest: TextSecureType['messaging']['getStorageManifest']; - getStorageRecords: TextSecureType['messaging']['getStorageRecords']; + getStorageCredentials: MessageSender['getStorageCredentials']; + getStorageManifest: MessageSender['getStorageManifest']; + getStorageRecords: MessageSender['getStorageRecords']; makeProxiedRequest: ( targetUrl: string, options?: ProxiedRequestOptionsType diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index bf189f3ce4b6..7dbbafd90e54 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -160,22 +160,6 @@ "updated": "2018-09-19T21:59:32.770Z", "reasonDetail": "Protected from arbitrary input" }, - { - "rule": "jQuery-load(", - "path": "js/conversation_controller.js", - "line": " async load() {", - "lineNumber": 306, - "reasonCategory": "falseMatch", - "updated": "2020-06-19T18:29:40.067Z" - }, - { - "rule": "jQuery-load(", - "path": "js/conversation_controller.js", - "line": " this._initialPromise = load();", - "lineNumber": 348, - "reasonCategory": "falseMatch", - "updated": "2020-06-19T18:29:40.067Z" - }, { "rule": "jQuery-$(", "path": "js/debug_log_start.js", @@ -223,7 +207,7 @@ "rule": "jQuery-wrap(", "path": "js/models/conversations.js", "line": " await wrap(", - "lineNumber": 652, + "lineNumber": 663, "reasonCategory": "falseMatch", "updated": "2020-06-09T20:26:46.515Z" }, @@ -504,7 +488,7 @@ "rule": "jQuery-prependTo(", "path": "js/views/inbox_view.js", "line": " this.appLoadingScreen.$el.prependTo(this.el);", - "lineNumber": 90, + "lineNumber": 97, "reasonCategory": "usageTrusted", "updated": "2019-10-21T22:30:15.622Z", "reasonDetail": "Known DOM elements" @@ -513,7 +497,7 @@ "rule": "jQuery-appendTo(", "path": "js/views/inbox_view.js", "line": " toast.$el.appendTo(this.$el);", - "lineNumber": 99, + "lineNumber": 106, "reasonCategory": "usageTrusted", "updated": "2020-05-28T17:42:35.329Z", "reasonDetail": "Known DOM elements" @@ -522,7 +506,7 @@ "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.call-manager-placeholder').append(this.callManagerView.el);", - "lineNumber": 121, + "lineNumber": 128, "reasonCategory": "usageTrusted", "updated": "2020-05-28T17:42:35.329Z", "reasonDetail": "" @@ -531,7 +515,7 @@ "rule": "jQuery-append(", "path": "js/views/inbox_view.js", "line": " this.$('.call-manager-placeholder').append(this.callManagerView.el);", - "lineNumber": 121, + "lineNumber": 128, "reasonCategory": "usageTrusted", "updated": "2020-05-28T17:42:35.329Z", "reasonDetail": "" @@ -540,7 +524,7 @@ "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);", - "lineNumber": 132, + "lineNumber": 139, "reasonCategory": "usageTrusted", "updated": "2020-05-28T17:42:35.329Z", "reasonDetail": "Known DOM elements" @@ -549,7 +533,7 @@ "rule": "jQuery-append(", "path": "js/views/inbox_view.js", "line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);", - "lineNumber": 132, + "lineNumber": 139, "reasonCategory": "usageTrusted", "updated": "2020-05-28T17:42:35.329Z", "reasonDetail": "Known DOM elements" @@ -558,7 +542,7 @@ "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " if (e && this.$(e.target).closest('.placeholder').length) {", - "lineNumber": 183, + "lineNumber": 190, "reasonCategory": "usageTrusted", "updated": "2020-05-28T17:42:35.329Z", "reasonDetail": "Known DOM elements" @@ -567,7 +551,7 @@ "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('#header, .gutter').addClass('inactive');", - "lineNumber": 187, + "lineNumber": 194, "reasonCategory": "usageTrusted", "updated": "2020-05-28T17:42:35.329Z", "reasonDetail": "Hardcoded selector" @@ -576,7 +560,7 @@ "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.conversation-stack').addClass('inactive');", - "lineNumber": 191, + "lineNumber": 198, "reasonCategory": "usageTrusted", "updated": "2020-05-28T17:42:35.329Z", "reasonDetail": "Hardcoded selector" @@ -585,7 +569,7 @@ "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.conversation:first .menu').trigger('close');", - "lineNumber": 193, + "lineNumber": 200, "reasonCategory": "usageTrusted", "updated": "2020-05-28T17:42:35.329Z", "reasonDetail": "Hardcoded selector" @@ -594,7 +578,7 @@ "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " if (e && this.$(e.target).closest('.capture-audio').length > 0) {", - "lineNumber": 213, + "lineNumber": 220, "reasonCategory": "usageTrusted", "updated": "2020-05-29T18:29:18.234Z", "reasonDetail": "Known DOM elements" @@ -603,7 +587,7 @@ "rule": "jQuery-$(", "path": "js/views/inbox_view.js", "line": " this.$('.conversation:first .recorder').trigger('close');", - "lineNumber": 216, + "lineNumber": 223, "reasonCategory": "usageTrusted", "updated": "2020-05-29T18:29:18.234Z", "reasonDetail": "Hardcoded selector" @@ -11875,4 +11859,4 @@ "reasonCategory": "falseMatch", "updated": "2020-04-05T23:45:16.746Z" } -] \ No newline at end of file +] diff --git a/ts/util/lint/linter.ts b/ts/util/lint/linter.ts index ad516533517b..fccb4b1ea6ad 100644 --- a/ts/util/lint/linter.ts +++ b/ts/util/lint/linter.ts @@ -60,6 +60,8 @@ const excludedFiles = [ '^ts/Crypto.ts', '^ts/textsecure/MessageReceiver.js', '^ts/textsecure/MessageReceiver.ts', + '^ts/ConversationController.js', + '^ts/ConversationController.ts', // Generated files '^js/components.js', diff --git a/ts/util/storageService.ts b/ts/util/storageService.ts index be4edcd72acb..dc4bc534328a 100644 --- a/ts/util/storageService.ts +++ b/ts/util/storageService.ts @@ -17,7 +17,7 @@ import { ManifestRecordClass, StorageItemClass, } from '../textsecure.d'; -import { ConversationType } from '../window.d'; +import { ConversationModelType } from '../model-types.d'; function fromRecordVerified(verified: number): number { const VERIFIED_ENUM = window.textsecure.storage.protocol.VerifiedStatus; @@ -35,6 +35,11 @@ function fromRecordVerified(verified: number): number { async function fetchManifest(manifestVersion: string) { window.log.info('storageService.fetchManifest'); + + if (!window.textsecure.messaging) { + throw new Error('fetchManifest: We are offline!'); + } + try { const credentials = await window.textsecure.messaging.getStorageCredentials(); window.storage.put('storageCredentials', credentials); @@ -286,6 +291,10 @@ async function processManifest( const storageKeyBase64 = window.storage.get('storageKey'); const storageKey = base64ToArrayBuffer(storageKeyBase64); + if (!window.textsecure.messaging) { + throw new Error('processManifest: We are offline!'); + } + const remoteKeysTypeMap = new Map(); manifest.keys.forEach(key => { remoteKeysTypeMap.set( @@ -296,7 +305,7 @@ async function processManifest( const localKeys = window .getConversations() - .map((conversation: ConversationType) => conversation.get('storageID')) + .map((conversation: ConversationModelType) => conversation.get('storageID')) .filter(Boolean); window.log.info( `storageService.processManifest localKeys.length ${localKeys.length}` diff --git a/ts/window.d.ts b/ts/window.d.ts index 22d9f423ac4c..79c6a5e992a5 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -1,6 +1,14 @@ // Captures the globals put in place by preload.js, background.js and others +import * as Backbone from 'backbone'; +import * as Underscore from 'underscore'; import { Ref } from 'react'; +import { + ConversationModelCollectionType, + ConversationModelType, + MessageModelCollectionType, + MessageModelType, +} from './model-types.d'; import { LibSignalType, SignalProtocolAddressClass, @@ -11,6 +19,7 @@ import { WebAPIConnectType } from './textsecure/WebAPI'; import { CallingClass, CallHistoryDetailsType } from './services/calling'; import * as Crypto from './Crypto'; import { ColorType, LocalizerType } from './types/Util'; +import { ConversationController } from './ConversationController'; import { SendOptionsType } from './textsecure/SendMessage'; import Data from './sql/Client'; @@ -19,18 +28,22 @@ type TaskResultType = any; declare global { interface Window { dcodeIO: DCodeIOType; - getConversations: () => ConversationControllerType; - getExpiration: () => string; - getEnvironment: () => string; - getSocketStatus: () => number; getAlwaysRelayCalls: () => Promise; - getIncomingCallNotification: () => Promise; getCallRingtoneNotification: () => Promise; getCallSystemNotification: () => Promise; - getMediaPermissions: () => Promise; + getConversations: () => ConversationModelCollectionType; + getEnvironment: () => string; + getExpiration: () => string; + getGuid: () => string; + getInboxCollection: () => ConversationModelCollectionType; + getIncomingCallNotification: () => Promise; getMediaCameraPermissions: () => Promise; + getMediaPermissions: () => Promise; + getSocketStatus: () => number; + getTitle: () => string; showCallingPermissionsPopup: (forCamera: boolean) => Promise; i18n: LocalizerType; + isValidGuid: (maybeGuid: string) => boolean; libphonenumber: { util: { getRegionCodeForNumber: (number: string) => string; @@ -46,6 +59,7 @@ declare global { platform: string; restart: () => void; showWindow: () => void; + setBadgeCount: (count: number) => void; storage: { put: (key: string, value: any) => void; remove: (key: string) => void; @@ -55,7 +69,9 @@ declare global { removeBlockedNumber: (number: string) => void; }; textsecure: TextSecureType; + updateTrayIcon: (count: number) => void; + Backbone: typeof Backbone; Signal: { Crypto: typeof Crypto; Data: typeof Data; @@ -69,7 +85,7 @@ declare global { calling: CallingClass; }; }; - ConversationController: ConversationControllerType; + ConversationController: ConversationController; WebAPI: WebAPIConnectType; Whisper: WhisperType; @@ -82,68 +98,6 @@ declare global { } } -export type ConversationAttributes = { - e164?: string | null; - isArchived?: boolean; - profileFamilyName?: string | null; - profileKey?: string | null; - profileName?: string | null; - profileSharing?: boolean; - name?: string; - storageID?: string; - uuid?: string | null; - verified?: number; -}; - -export type ConversationType = { - attributes: ConversationAttributes; - fromRecordVerified: ( - verified: ContactRecordIdentityState - ) => ContactRecordIdentityState; - set: (props: Partial) => void; - updateE164: (e164?: string) => void; - updateUuid: (uuid?: string) => void; - id: string; - get: (key: string) => any; - getAvatarPath(): string | undefined; - getColor(): ColorType | undefined; - getName(): string | undefined; - getNumber(): string; - getProfiles(): Promise>>; - getProfileName(): string | undefined; - getRecipients: () => Array; - getSendOptions(): SendOptionsType; - getTitle(): string; - isVerified(): boolean; - safeGetVerified(): Promise; - getIsAddedByContact(): boolean; - addCallHistory(details: CallHistoryDetailsType): void; - toggleVerified(): Promise; -}; - -export type ConversationControllerType = { - getOrCreateAndWait: ( - identifier: string, - type: 'private' | 'group' - ) => Promise; - getOrCreate: ( - identifier: string, - type: 'private' | 'group' - ) => ConversationType; - getConversationId: (identifier: string) => string | null; - ensureContactIds: (o: { e164?: string; uuid?: string }) => string; - getOurConversationId: () => string | null; - prepareForSend: ( - id: string, - options: Object - ) => { - wrap: (promise: Promise) => Promise; - sendOptions: Object; - }; - get: (identifier: string) => null | ConversationType; - map: (mapFn: (conversation: ConversationType) => any) => any; -}; - export type DCodeIOType = { ByteBuffer: typeof ByteBufferClass; Long: { @@ -212,7 +166,7 @@ export type LoggerType = (...args: Array) => void; export type WhisperType = { events: { - trigger: (name: string, param1: any, param2: any) => void; + trigger: (name: string, param1: any, param2?: any) => void; }; Database: { open: () => Promise; @@ -222,4 +176,8 @@ export type WhisperType = { reject: Function ) => void; }; + ConversationCollection: typeof ConversationModelCollectionType; + Conversation: typeof ConversationModelType; + MessageCollection: typeof MessageModelCollectionType; + Message: typeof MessageModelType; }; diff --git a/tslint.json b/tslint.json index 9e5c769905eb..6c7fcacfdca2 100644 --- a/tslint.json +++ b/tslint.json @@ -12,6 +12,9 @@ // Preferred by Prettier: "arrow-parens": [true, "ban-single-arg-parens"], + // Breaks when we use .extend() to create a Backbone subclass + "no-invalid-this": false, + "import-spacing": false, "indent": [true, "spaces", 2], "interface-name": [true, "never-prefix"], diff --git a/yarn.lock b/yarn.lock index b719754abfe4..708b983f9b25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2232,20 +2232,13 @@ dependencies: "@types/node" "*" -"@types/jquery@*": +"@types/jquery@*", "@types/jquery@3.5.0": version "3.5.0" resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.5.0.tgz#ccb7dfd317d02d4227dd3803c75297d0c10dad68" integrity sha512-C7qQUjpMWDUNYQRTXsP5nbYYwCwwgy84yPgoTT7fPN69NH92wLeCtFaMsWeolJD1AF/6uQw3pYt62rzv83sMmw== dependencies: "@types/sizzle" "*" -"@types/jquery@3.3.29": - version "3.3.29" - resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.3.29.tgz#680a2219ce3c9250483722fccf5570d1e2d08abd" - integrity sha512-FhJvBninYD36v3k6c+bVk1DSZwh7B5Dpb/Pyk3HKVsiohn0nhbefZZ+3JXbWQhFyt0MxSl2jRDdGQPHeOHFXrQ== - dependencies: - "@types/sizzle" "*" - "@types/js-yaml@3.12.0": version "3.12.0" resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-3.12.0.tgz#3494ce97358e2675e24e97a747ec23478eeaf8b6" @@ -2523,6 +2516,11 @@ resolved "https://registry.yarnpkg.com/@types/underscore/-/underscore-1.10.14.tgz#a2a831c72a12deddaef26028d16a5aa48aadbee0" integrity sha512-VE20ZYf38nmOU1lU0wpQBWcGPlskfKK8uU8AN1UIz5PjxT2YM7HTF0iUA85iGJnbQ3tZweqIfQqmLgLMtP27YQ== +"@types/underscore@1.10.3": + version "1.10.3" + resolved "https://registry.yarnpkg.com/@types/underscore/-/underscore-1.10.3.tgz#927ff2b2e516444587fc80fb7845bb5c1806aad2" + integrity sha512-WgNbx0H2QO4ccIk2R1aWkteETuPxSa9OYKXoYujBgc0R4u+d2PWsb9MPpP77H+xqwbCXT+wuEBQ/6fl6s4C0OA== + "@types/uuid@3.4.4": version "3.4.4" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.4.tgz#7af69360fa65ef0decb41fd150bf4ca5c0cefdf5"