From b5df9b40676efd1f5321c00a01f1bbdf633a2286 Mon Sep 17 00:00:00 2001 From: Chris Svenningsen Date: Thu, 24 Sep 2020 13:57:54 -0700 Subject: [PATCH] Migrate messages, conversations, conversation_view, background to TS Co-authored-by: Sidney Keese --- background.html | 7 +- bower.json | 6 - debug_log.html | 1 + debug_log_preload.js | 1 + js/models/conversations.js | 3404 --------------- js/models/messages.js | 3159 -------------- js/views/conversation_view.js | 3297 --------------- loading.html | 1 + loading_preload.js | 1 + permissions_popup.html | 1 + permissions_popup_preload.js | 1 + preload.js | 5 + settings.html | 1 + settings_preload.js | 2 + sticker-creator/index.html | 1 + sticker-creator/preload.js | 1 + test/index.html | 1 + ts/ConversationController.ts | 61 +- ts/backboneJquery.ts | 3 + js/background.js => ts/background.ts | 857 ++-- .../conversation/GroupNotification.tsx | 4 +- .../conversation/TimerNotification.tsx | 8 +- ts/groups.ts | 7 +- ts/model-types.d.ts | 218 +- ts/models/conversations.ts | 3673 +++++++++++++++++ ts/models/messages.ts | 3420 +++++++++++++++ ts/services/calling.ts | 23 +- ts/services/storage.ts | 8 +- ts/services/storageRecordOps.ts | 20 +- ts/sql/Client.ts | 35 +- ts/sql/Interface.ts | 49 +- ts/sql/Server.ts | 6 +- ts/state/ducks/conversations.ts | 29 +- ts/textsecure.d.ts | 19 +- ts/textsecure/SendMessage.ts | 38 +- ts/textsecure/WebAPI.ts | 2 +- ts/types/Util.ts | 4 +- ts/util/deleteForEveryone.ts | 5 +- ts/util/lint/linter.ts | 12 +- ts/views/conversation_view.ts | 3297 +++++++++++++++ ts/window.d.ts | 500 ++- tslint.json | 4 +- 42 files changed, 11676 insertions(+), 10516 deletions(-) delete mode 100644 js/models/conversations.js delete mode 100644 js/models/messages.js delete mode 100644 js/views/conversation_view.js create mode 100644 ts/backboneJquery.ts rename js/background.js => ts/background.ts (76%) create mode 100644 ts/models/conversations.ts create mode 100644 ts/models/messages.ts create mode 100644 ts/views/conversation_view.ts diff --git a/background.html b/background.html index cd25b7d83..92a2e229b 100644 --- a/background.html +++ b/background.html @@ -326,6 +326,7 @@ + @@ -341,8 +342,6 @@ - - @@ -358,7 +357,7 @@ - + @@ -387,6 +386,6 @@ - + diff --git a/bower.json b/bower.json index dbd42ac36..130ef4973 100644 --- a/bower.json +++ b/bower.json @@ -5,7 +5,6 @@ "license": "GPLV3", "private": true, "dependencies": { - "indexeddb-backbonejs-adapter": "*", "mp3lameencoder": "https://github.com/higuma/mp3-lame-encoder-js.git", "protobuf": "~3.8.0", "qrcode": "https://github.com/davidshimjs/qrcodejs.git#1c78ccd71", @@ -18,9 +17,6 @@ "bytebuffer": [ "dist/ByteBufferAB.js" ], - "indexeddb-backbonejs-adapter": [ - "backbone-indexeddb.js" - ], "long": [ "dist/Long.js" ], @@ -49,8 +45,6 @@ "components/protobuf/**/*.js", "node_modules/mustache/mustache.js", "node_modules/underscore/underscore.js", - "node_modules/backbone/backbone.js", - "components/indexeddb-backbonejs-adapter/**/*.js", "components/qrcode/**/*.js", "node_modules/intl-tel-input/build/js/intlTelInput.js", "components/autosize/**/*.js", diff --git a/debug_log.html b/debug_log.html index 9387f33c1..ed8bdb2b4 100644 --- a/debug_log.html +++ b/debug_log.html @@ -51,6 +51,7 @@ {{ toastMessage }} + diff --git a/debug_log_preload.js b/debug_log_preload.js index 401eeda82..7fed9026c 100644 --- a/debug_log_preload.js +++ b/debug_log_preload.js @@ -23,3 +23,4 @@ window.getEnvironment = () => config.environment; require('./js/logging'); window.closeDebugLog = () => ipcRenderer.send('close-debug-log'); +window.Backbone = require('backbone'); diff --git a/js/models/conversations.js b/js/models/conversations.js deleted file mode 100644 index cfa0a2c9d..000000000 --- a/js/models/conversations.js +++ /dev/null @@ -1,3404 +0,0 @@ -/* global - _, - i18n, - Backbone, - libphonenumber, - ConversationController, - MessageController, - libsignal, - storage, - textsecure, - Whisper, - Signal -*/ - -/* eslint-disable more/no-then */ - -// eslint-disable-next-line func-names -(function() { - window.Whisper = window.Whisper || {}; - - const SEALED_SENDER = { - UNKNOWN: 0, - ENABLED: 1, - DISABLED: 2, - UNRESTRICTED: 3, - }; - - const { Services, Util } = window.Signal; - const { Contact, Message } = window.Signal.Types; - const { - deleteAttachmentData, - doesAttachmentExist, - getAbsoluteAttachmentPath, - loadAttachmentData, - readStickerData, - upgradeMessageSchema, - writeNewAttachmentData, - } = window.Signal.Migrations; - const { addStickerPackReference } = window.Signal.Data; - const { - arrayBufferToBase64, - base64ToArrayBuffer, - deriveAccessKey, - getRandomBytes, - stringFromBytes, - verifyAccessKey, - } = window.Signal.Crypto; - - const COLORS = [ - 'red', - 'deep_orange', - 'brown', - 'pink', - 'purple', - 'indigo', - 'blue', - 'teal', - 'green', - 'light_green', - 'blue_grey', - 'ultramarine', - ]; - - Whisper.Conversation = Backbone.Model.extend({ - storeName: 'conversations', - defaults() { - return { - unreadCount: 0, - verified: textsecure.storage.protocol.VerifiedStatus.DEFAULT, - messageCount: 0, - sentMessageCount: 0, - }; - }, - - idForLogging() { - if (this.isPrivate()) { - const uuid = this.get('uuid'); - const e164 = this.get('e164'); - return `${uuid || e164} (${this.id})`; - } - if (this.get('groupVersion') > 1) { - return `groupv2(${this.get('groupId')})`; - } - - const groupId = this.get('groupId'); - return `group(${groupId})`; - }, - - debugID() { - const uuid = this.get('uuid'); - const e164 = this.get('e164'); - const groupId = this.get('groupId'); - return `group(${groupId}), sender(${uuid || e164}), id(${this.id})`; - }, - - // 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); - }, - - getContactCollection() { - const collection = new Backbone.Collection(); - const collator = new Intl.Collator(); - collection.comparator = (left, right) => { - const leftLower = left.getTitle().toLowerCase(); - const rightLower = right.getTitle().toLowerCase(); - return collator.compare(leftLower, rightLower); - }; - return collection; - }, - - initialize(attributes) { - if (window.isValidE164(attributes.id)) { - this.set({ id: window.getGuid(), e164: attributes.id }); - } - - this.ourNumber = textsecure.storage.user.getNumber(); - this.ourUuid = textsecure.storage.user.getUuid(); - this.verifiedEnum = textsecure.storage.protocol.VerifiedStatus; - this.messageRequestEnum = - textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; - - // This may be overridden by ConversationController.getOrCreate, and signify - // our first save to the database. Or first fetch from the database. - this.initialPromise = Promise.resolve(); - - this.contactCollection = this.getContactCollection(); - this.messageCollection = new Whisper.MessageCollection([], { - conversation: this, - }); - - this.messageCollection.on('change:errors', this.handleMessageError, this); - this.messageCollection.on('send-error', this.onMessageError, this); - - this.throttledBumpTyping = _.throttle(this.bumpTyping, 300); - this.debouncedUpdateLastMessage = _.debounce( - this.updateLastMessage.bind(this), - 200 - ); - - this.listenTo( - this.messageCollection, - 'add remove destroy content-changed', - this.debouncedUpdateLastMessage - ); - this.listenTo(this.messageCollection, 'sent', this.updateLastMessage); - this.listenTo( - this.messageCollection, - 'send-error', - this.updateLastMessage - ); - - this.on('newmessage', this.onNewMessage); - this.on('change:profileKey', this.onChangeProfileKey); - - // Listening for out-of-band data updates - this.on('delivered', this.updateAndMerge); - this.on('read', this.updateAndMerge); - this.on('expiration-change', this.updateAndMerge); - this.on('expired', this.onExpired); - - const sealedSender = this.get('sealedSender'); - if (sealedSender === undefined) { - this.set({ sealedSender: SEALED_SENDER.UNKNOWN }); - } - this.unset('unidentifiedDelivery'); - this.unset('unidentifiedDeliveryUnrestricted'); - this.unset('hasFetchedProfile'); - this.unset('tokens'); - - this.typingRefreshTimer = null; - this.typingPauseTimer = null; - - // Keep props ready - this.generateProps = () => { - this.cachedProps = this.getProps(); - }; - this.on('change', this.generateProps); - this.generateProps(); - }, - - isMe() { - const e164 = this.get('e164'); - const uuid = this.get('uuid'); - return ( - (e164 && e164 === this.ourNumber) || (uuid && uuid === this.ourUuid) - ); - }, - - isEverUnregistered() { - return Boolean(this.get('discoveredUnregisteredAt')); - }, - isUnregistered() { - const now = Date.now(); - const sixHoursAgo = now - 1000 * 60 * 60 * 6; - const discoveredUnregisteredAt = this.get('discoveredUnregisteredAt'); - - if (discoveredUnregisteredAt && discoveredUnregisteredAt > sixHoursAgo) { - return true; - } - - return false; - }, - setUnregistered() { - window.log.info( - `Conversation ${this.idForLogging()} is now unregistered` - ); - this.set({ - discoveredUnregisteredAt: Date.now(), - }); - window.Signal.Data.updateConversation(this.attributes); - }, - setRegistered() { - window.log.info( - `Conversation ${this.idForLogging()} is registered once again` - ); - this.set({ - discoveredUnregisteredAt: undefined, - }); - window.Signal.Data.updateConversation(this.attributes); - }, - - isBlocked() { - const uuid = this.get('uuid'); - if (uuid) { - return window.storage.isUuidBlocked(uuid); - } - - const e164 = this.get('e164'); - if (e164) { - return window.storage.isBlocked(e164); - } - - const groupId = this.get('groupId'); - if (groupId) { - return window.storage.isGroupBlocked(groupId); - } - - return false; - }, - - block({ viaStorageServiceSync = false } = {}) { - let blocked = false; - const isBlocked = this.isBlocked(); - - const uuid = this.get('uuid'); - if (uuid) { - window.storage.addBlockedUuid(uuid); - blocked = true; - } - - const e164 = this.get('e164'); - if (e164) { - window.storage.addBlockedNumber(e164); - blocked = true; - } - - const groupId = this.get('groupId'); - if (groupId) { - window.storage.addBlockedGroup(groupId); - blocked = true; - } - - if (!viaStorageServiceSync && !isBlocked && blocked) { - this.captureChange(); - } - }, - - unblock({ viaStorageServiceSync = false } = {}) { - let unblocked = false; - const isBlocked = this.isBlocked(); - - const uuid = this.get('uuid'); - if (uuid) { - window.storage.removeBlockedUuid(uuid); - unblocked = true; - } - - const e164 = this.get('e164'); - if (e164) { - window.storage.removeBlockedNumber(e164); - unblocked = true; - } - - const groupId = this.get('groupId'); - if (groupId) { - window.storage.removeBlockedGroup(groupId); - unblocked = true; - } - - if (!viaStorageServiceSync && isBlocked && unblocked) { - this.captureChange(); - } - - return unblocked; - }, - - enableProfileSharing({ viaStorageServiceSync = false } = {}) { - const before = this.get('profileSharing'); - - this.set({ profileSharing: true }); - - const after = this.get('profileSharing'); - - if (!viaStorageServiceSync && Boolean(before) !== Boolean(after)) { - this.captureChange(); - } - }, - - disableProfileSharing({ viaStorageServiceSync = false } = {}) { - const before = this.get('profileSharing'); - - this.set({ profileSharing: false }); - - const after = this.get('profileSharing'); - - if (!viaStorageServiceSync && Boolean(before) !== Boolean(after)) { - this.captureChange(); - } - }, - - hasDraft() { - const draftAttachments = this.get('draftAttachments') || []; - return ( - this.get('draft') || - this.get('quotedMessageId') || - draftAttachments.length > 0 - ); - }, - - getDraftPreview() { - const draft = this.get('draft'); - if (draft) { - return draft; - } - - const draftAttachments = this.get('draftAttachments') || []; - if (draftAttachments.length > 0) { - return i18n('Conversation--getDraftPreview--attachment'); - } - - const quotedMessageId = this.get('quotedMessageId'); - if (quotedMessageId) { - return i18n('Conversation--getDraftPreview--quote'); - } - - return i18n('Conversation--getDraftPreview--draft'); - }, - - bumpTyping() { - // We don't send typing messages if the setting is disabled - if (!storage.get('typingIndicators')) { - return; - } - - if (!this.typingRefreshTimer) { - const isTyping = true; - this.setTypingRefreshTimer(); - this.sendTypingMessage(isTyping); - } - - this.setTypingPauseTimer(); - }, - - setTypingRefreshTimer() { - if (this.typingRefreshTimer) { - clearTimeout(this.typingRefreshTimer); - } - this.typingRefreshTimer = setTimeout( - this.onTypingRefreshTimeout.bind(this), - 10 * 1000 - ); - }, - - onTypingRefreshTimeout() { - const isTyping = true; - this.sendTypingMessage(isTyping); - - // This timer will continue to reset itself until the pause timer stops it - this.setTypingRefreshTimer(); - }, - - setTypingPauseTimer() { - if (this.typingPauseTimer) { - clearTimeout(this.typingPauseTimer); - } - this.typingPauseTimer = setTimeout( - this.onTypingPauseTimeout.bind(this), - 3 * 1000 - ); - }, - - onTypingPauseTimeout() { - const isTyping = false; - this.sendTypingMessage(isTyping); - - this.clearTypingTimers(); - }, - - clearTypingTimers() { - if (this.typingPauseTimer) { - clearTimeout(this.typingPauseTimer); - this.typingPauseTimer = null; - } - if (this.typingRefreshTimer) { - clearTimeout(this.typingRefreshTimer); - this.typingRefreshTimer = null; - } - }, - - async fetchLatestGroupV2Data() { - if (this.get('groupVersion') !== 2) { - return; - } - - await window.Signal.Groups.waitThenMaybeUpdateGroup({ - conversation: this, - }); - }, - maybeRepairGroupV2(data) { - if ( - this.get('groupVersion') && - this.get('masterKey') && - this.get('secretParams') && - this.get('publicParams') - ) { - return; - } - - window.log.info(`Repairing GroupV2 conversation ${this.idForLogging()}`); - const { masterKey, secretParams, publicParams } = data; - - this.set({ masterKey, secretParams, publicParams, groupVersion: 2 }); - - window.Signal.Data.updateConversation(this.attributes); - }, - getGroupV2Info(groupChange) { - if (this.isPrivate() || this.get('groupVersion') !== 2) { - return null; - } - return { - masterKey: window.Signal.Crypto.base64ToArrayBuffer( - this.get('masterKey') - ), - revision: this.get('revision'), - members: this.getRecipients(), - groupChange, - }; - }, - getGroupV1Info() { - if (this.isPrivate() || this.get('groupVersion') > 0) { - return null; - } - - return { - id: this.get('groupId'), - members: this.getRecipients(), - }; - }, - - sendTypingMessage(isTyping) { - if (!textsecure.messaging) { - return; - } - - // We don't send typing messages to our other devices - if (this.isMe()) { - return; - } - - const recipientId = this.isPrivate() ? this.getSendTarget() : null; - const groupId = !this.isPrivate() ? this.get('groupId') : null; - const groupMembers = this.getRecipients(); - - // We don't send typing messages if our recipients list is empty - if (!this.isPrivate() && !groupMembers.length) { - return; - } - - const sendOptions = this.getSendOptions(); - this.wrapSend( - textsecure.messaging.sendTypingMessage( - { - isTyping, - recipientId, - groupId, - groupMembers, - }, - sendOptions - ) - ); - }, - - async cleanup() { - await window.Signal.Types.Conversation.deleteExternalFiles( - this.attributes, - { - deleteAttachmentData, - } - ); - }, - - async updateAndMerge(message) { - this.debouncedUpdateLastMessage(); - - const mergeMessage = () => { - const existing = this.messageCollection.get(message.id); - if (!existing) { - return; - } - - existing.merge(message.attributes); - }; - - await this.inProgressFetch; - mergeMessage(); - }, - - async onExpired(message) { - this.debouncedUpdateLastMessage(); - - const removeMessage = () => { - const { id } = message; - const existing = this.messageCollection.get(id); - if (!existing) { - return; - } - - window.log.info('Remove expired message from collection', { - sentAt: existing.get('sent_at'), - }); - - this.messageCollection.remove(id); - existing.trigger('expired'); - existing.cleanup(); - - // An expired message only counts as decrementing the message count, not - // the sent message count - this.decrementMessageCount(); - }; - - // If a fetch is in progress, then we need to wait until that's complete to - // do this removal. Otherwise we could remove from messageCollection, then - // the async database fetch could include the removed message. - - await this.inProgressFetch; - removeMessage(); - }, - - async onNewMessage(message) { - const uuid = message.get ? message.get('sourceUuid') : message.sourceUuid; - const e164 = message.get ? message.get('source') : message.source; - const sourceDevice = message.get - ? message.get('sourceDevice') - : message.sourceDevice; - - const sourceId = window.ConversationController.ensureContactIds({ - uuid, - e164, - }); - const typingToken = `${sourceId}.${sourceDevice}`; - - // Clear typing indicator for a given contact if we receive a message from them - this.clearContactTypingTimer(typingToken); - - this.debouncedUpdateLastMessage(); - }, - - // For outgoing messages, we can call this directly. We're already loaded. - addSingleMessage(message) { - const { id } = message; - const existing = this.messageCollection.get(id); - - const model = this.messageCollection.add(message, { merge: true }); - model.setToExpire(); - - if (!existing) { - const { messagesAdded } = window.reduxActions.conversations; - const isNewMessage = true; - messagesAdded( - this.id, - [model.getReduxData()], - isNewMessage, - window.isActive() - ); - } - - return model; - }, - - // For incoming messages, they might arrive while we're in the middle of a bulk fetch - // from the database. We'll wait until that is done to process this newly-arrived - // message. - async addIncomingMessage(message) { - await this.inProgressFetch; - - this.addSingleMessage(message); - }, - - format() { - return this.cachedProps; - }, - getProps() { - // This is to prevent race conditions on startup; Conversation models are created - // but the full ConversationController.load() sequence isn't complete. So, we - // don't cache props on create, but we do later when load() calls generateProps() - // for us. - if (!window.ConversationController.isFetchComplete()) { - return null; - } - - const color = this.getColor(); - - const typingValues = _.values(this.contactTypingTimers || {}); - const typingMostRecent = _.first(_.sortBy(typingValues, 'timestamp')); - const typingContact = typingMostRecent - ? ConversationController.get(typingMostRecent.senderId) - : null; - - const timestamp = this.get('timestamp'); - const draftTimestamp = this.get('draftTimestamp'); - const draftPreview = this.getDraftPreview(); - const draftText = this.get('draft'); - const shouldShowDraft = - this.hasDraft() && draftTimestamp && draftTimestamp >= timestamp; - const inboxPosition = this.get('inbox_position'); - const messageRequestsEnabled = Signal.RemoteConfig.isEnabled( - 'desktop.messageRequests' - ); - - const result = { - id: this.id, - uuid: this.get('uuid'), - e164: this.get('e164'), - - acceptedMessageRequest: this.getAccepted(), - activeAt: this.get('active_at'), - avatarPath: this.getAvatarPath(), - color, - draftPreview, - draftText, - firstName: this.get('profileName'), - inboxPosition, - isAccepted: this.getAccepted(), - isArchived: this.get('isArchived'), - isBlocked: this.isBlocked(), - isMe: this.isMe(), - isVerified: this.isVerified(), - lastMessage: { - status: this.get('lastMessageStatus'), - text: this.get('lastMessage'), - deletedForEveryone: this.get('lastMessageDeletedForEveryone'), - }, - lastUpdated: this.get('timestamp'), - membersCount: this.isPrivate() - ? undefined - : (this.get('membersV2') || this.get('members') || []).length, - messageRequestsEnabled, - muteExpiresAt: this.get('muteExpiresAt'), - name: this.get('name'), - phoneNumber: this.getNumber(), - profileName: this.getProfileName(), - sharedGroupNames: this.get('sharedGroupNames'), - shouldShowDraft, - timestamp, - title: this.getTitle(), - type: this.isPrivate() ? 'direct' : 'group', - typingContact: typingContact ? typingContact.format() : null, - unreadCount: this.get('unreadCount') || 0, - }; - - return result; - }, - - updateE164(e164) { - const oldValue = this.get('e164'); - if (e164 && e164 !== oldValue) { - this.set('e164', e164); - window.Signal.Data.updateConversation(this.attributes); - this.trigger('idUpdated', this, 'e164', oldValue); - } - }, - updateUuid(uuid) { - const oldValue = this.get('uuid'); - if (uuid && uuid !== oldValue) { - this.set('uuid', uuid.toLowerCase()); - window.Signal.Data.updateConversation(this.attributes); - this.trigger('idUpdated', this, 'uuid', oldValue); - } - }, - updateGroupId(groupId) { - const oldValue = this.get('groupId'); - if (groupId && groupId !== oldValue) { - this.set('groupId', groupId); - window.Signal.Data.updateConversation(this.attributes); - this.trigger('idUpdated', this, 'groupId', oldValue); - } - }, - - incrementMessageCount() { - this.set({ - messageCount: (this.get('messageCount') || 0) + 1, - }); - window.Signal.Data.updateConversation(this.attributes); - }, - - decrementMessageCount() { - this.set({ - messageCount: Math.max((this.get('messageCount') || 0) - 1, 0), - }); - window.Signal.Data.updateConversation(this.attributes); - }, - - incrementSentMessageCount() { - this.set({ - messageCount: (this.get('messageCount') || 0) + 1, - sentMessageCount: (this.get('sentMessageCount') || 0) + 1, - }); - window.Signal.Data.updateConversation(this.attributes); - }, - - decrementSentMessageCount() { - this.set({ - messageCount: Math.max((this.get('messageCount') || 0) - 1, 0), - sentMessageCount: Math.max((this.get('sentMessageCount') || 0) - 1, 0), - }); - window.Signal.Data.updateConversation(this.attributes); - }, - - /** - * This function is called when a message request is accepted in order to - * handle sending read receipts and download any pending attachments. - */ - async handleReadAndDownloadAttachments() { - let messages; - do { - const first = messages ? messages.first() : null; - - // eslint-disable-next-line no-await-in-loop - messages = await window.Signal.Data.getOlderMessagesByConversation( - this.get('id'), - { - MessageCollection: Whisper.MessageCollection, - limit: 100, - receivedAt: first ? first.get('received_at') : null, - messageId: first ? first.id : null, - } - ); - - if (!messages.length) { - return; - } - - const readMessages = messages.filter( - m => !m.hasErrors() && m.isIncoming() - ); - const receiptSpecs = readMessages.map(m => ({ - 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(), - })); - // eslint-disable-next-line no-await-in-loop - await this.sendReadReceiptsFor(receiptSpecs); - // eslint-disable-next-line no-await-in-loop - await Promise.all(readMessages.map(m => m.queueAttachmentDownloads())); - } while (messages.length > 0); - }, - - async applyMessageRequestResponse( - response, - { fromSync = false, viaStorageServiceSync = false } = {} - ) { - // Apply message request response locally - this.set({ - messageRequestResponseType: response, - }); - window.Signal.Data.updateConversation(this.attributes); - - if (response === this.messageRequestEnum.ACCEPT) { - this.unblock({ viaStorageServiceSync }); - this.enableProfileSharing({ viaStorageServiceSync }); - - if (!fromSync) { - this.sendProfileKeyUpdate(); - // Locally accepted - await this.handleReadAndDownloadAttachments(); - } - } else if (response === this.messageRequestEnum.BLOCK) { - // Block locally, other devices should block upon receiving the sync message - this.block({ viaStorageServiceSync }); - this.disableProfileSharing({ viaStorageServiceSync }); - } else if (response === this.messageRequestEnum.DELETE) { - // Delete messages locally, other devices should delete upon receiving - // the sync message - this.destroyMessages(); - this.disableProfileSharing({ viaStorageServiceSync }); - this.updateLastMessage(); - if (!fromSync) { - this.trigger('unload', 'deleted from message request'); - } - } else if (response === this.messageRequestEnum.BLOCK_AND_DELETE) { - // Delete messages locally, other devices should delete upon receiving - // the sync message - this.destroyMessages(); - this.disableProfileSharing({ viaStorageServiceSync }); - this.updateLastMessage(); - // Block locally, other devices should block upon receiving the sync message - this.block({ viaStorageServiceSync }); - // Leave group if this was a local action - if (!fromSync) { - this.leaveGroup(); - this.trigger('unload', 'blocked and deleted from message request'); - } - } - }, - - async syncMessageRequestResponse(response) { - // Let this run, no await - this.applyMessageRequestResponse(response); - - const { ourNumber, ourUuid } = this; - const { wrap, sendOptions } = ConversationController.prepareForSend( - ourNumber || ourUuid, - { - syncMessage: true, - } - ); - - await wrap( - textsecure.messaging.syncMessageRequestResponse( - { - threadE164: this.get('e164'), - threadUuid: this.get('uuid'), - groupId: this.get('groupId'), - type: response, - }, - sendOptions - ) - ); - }, - - onMessageError() { - this.updateVerified(); - }, - safeGetVerified() { - const promise = textsecure.storage.protocol.getVerified(this.id); - return promise.catch( - () => textsecure.storage.protocol.VerifiedStatus.DEFAULT - ); - }, - async updateVerified() { - if (this.isPrivate()) { - await this.initialPromise; - const verified = await this.safeGetVerified(); - - if (this.get('verified') !== verified) { - this.set({ verified }); - window.Signal.Data.updateConversation(this.attributes); - } - - return; - } - - this.fetchContacts(); - - await Promise.all( - this.contactCollection.map(async contact => { - if (!contact.isMe()) { - await contact.updateVerified(); - } - }) - ); - - this.onMemberVerifiedChange(); - }, - setVerifiedDefault(options) { - const { DEFAULT } = this.verifiedEnum; - return this.queueJob(() => this._setVerified(DEFAULT, options)); - }, - setVerified(options) { - const { VERIFIED } = this.verifiedEnum; - return this.queueJob(() => this._setVerified(VERIFIED, options)); - }, - setUnverified(options) { - const { UNVERIFIED } = this.verifiedEnum; - return this.queueJob(() => this._setVerified(UNVERIFIED, options)); - }, - async _setVerified(verified, providedOptions) { - const options = providedOptions || {}; - _.defaults(options, { - viaStorageServiceSync: false, - viaSyncMessage: false, - viaContactSync: false, - key: null, - }); - - const { VERIFIED, UNVERIFIED } = this.verifiedEnum; - - if (!this.isPrivate()) { - throw new Error( - 'You cannot verify a group conversation. ' + - 'You must verify individual contacts.' - ); - } - - const beginningVerified = this.get('verified'); - let keyChange; - if (options.viaSyncMessage) { - // handle the incoming key from the sync messages - need different - // behavior if that key doesn't match the current key - keyChange = await textsecure.storage.protocol.processVerifiedMessage( - this.id, - verified, - options.key - ); - } else { - keyChange = await textsecure.storage.protocol.setVerified( - this.id, - verified - ); - } - - this.set({ verified }); - window.Signal.Data.updateConversation(this.attributes); - - if ( - !options.viaStorageServiceSync && - !keyChange && - beginningVerified !== verified - ) { - this.captureChange(); - } - - // Three situations result in a verification notice in the conversation: - // 1) The message came from an explicit verification in another client (not - // a contact sync) - // 2) The verification value received by the contact sync is different - // from what we have on record (and it's not a transition to UNVERIFIED) - // 3) Our local verification status is VERIFIED and it hasn't changed, - // but the key did change (Key1/VERIFIED to Key2/VERIFIED - but we don't - // want to show DEFAULT->DEFAULT or UNVERIFIED->UNVERIFIED) - if ( - !options.viaContactSync || - (beginningVerified !== verified && verified !== UNVERIFIED) || - (keyChange && verified === VERIFIED) - ) { - await this.addVerifiedChange(this.id, verified === VERIFIED, { - local: !options.viaSyncMessage, - }); - } - if (!options.viaSyncMessage) { - await this.sendVerifySyncMessage( - this.get('e164'), - this.get('uuid'), - verified - ); - } - - return keyChange; - }, - sendVerifySyncMessage(e164, uuid, state) { - // Because syncVerification sends a (null) message to the target of the verify and - // a sync message to our own devices, we need to send the accessKeys down for both - // contacts. So we merge their sendOptions. - const { sendOptions } = ConversationController.prepareForSend( - this.ourNumber || this.ourUuid, - { syncMessage: true } - ); - const contactSendOptions = this.getSendOptions(); - const options = { ...sendOptions, ...contactSendOptions }; - - const promise = textsecure.storage.protocol.loadIdentityKey(e164); - return promise.then(key => - this.wrapSend( - textsecure.messaging.syncVerification(e164, uuid, state, key, options) - ) - ); - }, - isVerified() { - if (this.isPrivate()) { - return this.get('verified') === this.verifiedEnum.VERIFIED; - } - if (!this.contactCollection.length) { - return false; - } - - return this.contactCollection.every(contact => { - if (contact.isMe()) { - return true; - } - return contact.isVerified(); - }); - }, - isUnverified() { - if (this.isPrivate()) { - const verified = this.get('verified'); - return ( - verified !== this.verifiedEnum.VERIFIED && - verified !== this.verifiedEnum.DEFAULT - ); - } - if (!this.contactCollection.length) { - return true; - } - - return this.contactCollection.any(contact => { - if (contact.isMe()) { - return false; - } - return contact.isUnverified(); - }); - }, - getUnverified() { - if (this.isPrivate()) { - return this.isUnverified() - ? new Backbone.Collection([this]) - : new Backbone.Collection(); - } - return new Backbone.Collection( - this.contactCollection.filter(contact => { - if (contact.isMe()) { - return false; - } - return contact.isUnverified(); - }) - ); - }, - setApproved() { - if (!this.isPrivate()) { - throw new Error( - 'You cannot set a group conversation as trusted. ' + - 'You must set individual contacts as trusted.' - ); - } - - return textsecure.storage.protocol.setApproval(this.id, true); - }, - safeIsUntrusted() { - return textsecure.storage.protocol - .isUntrusted(this.id) - .catch(() => false); - }, - isUntrusted() { - if (this.isPrivate()) { - return this.safeIsUntrusted(); - } - if (!this.contactCollection.length) { - return Promise.resolve(false); - } - - return Promise.all( - this.contactCollection.map(contact => { - if (contact.isMe()) { - return false; - } - return contact.safeIsUntrusted(); - }) - ).then(results => _.any(results, result => result)); - }, - getUntrusted() { - // This is a bit ugly because isUntrusted() is async. Could do the work to cache - // it locally, but we really only need it for this call. - if (this.isPrivate()) { - return this.isUntrusted().then(untrusted => { - if (untrusted) { - return new Backbone.Collection([this]); - } - - return new Backbone.Collection(); - }); - } - return Promise.all( - this.contactCollection.map(contact => { - if (contact.isMe()) { - return [false, contact]; - } - return Promise.all([contact.isUntrusted(), contact]); - }) - ).then(results => { - const filtered = _.filter(results, result => { - const untrusted = result[0]; - return untrusted; - }); - return new Backbone.Collection( - _.map(filtered, result => { - const contact = result[1]; - return contact; - }) - ); - }); - }, - - getSentMessageCount() { - return this.get('sentMessageCount') || 0; - }, - - getMessageRequestResponseType() { - return this.get('messageRequestResponseType') || 0; - }, - - /** - * Determine if this conversation should be considered "accepted" in terms - * of message requests - */ - getAccepted() { - const messageRequestsEnabled = Signal.RemoteConfig.isEnabled( - 'desktop.messageRequests' - ); - - if (!messageRequestsEnabled) { - return true; - } - - if (this.isMe()) { - return true; - } - - if ( - this.getMessageRequestResponseType() === this.messageRequestEnum.ACCEPT - ) { - return true; - } - - const isFromOrAddedByTrustedContact = this.isFromOrAddedByTrustedContact(); - const hasSentMessages = this.getSentMessageCount() > 0; - const hasMessagesBeforeMessageRequests = - (this.get('messageCountBeforeMessageRequests') || 0) > 0; - const hasNoMessages = (this.get('messageCount') || 0) === 0; - - const isEmptyPrivateConvo = hasNoMessages && this.isPrivate(); - const isEmptyWhitelistedGroup = - hasNoMessages && !this.isPrivate() && this.get('profileSharing'); - - return ( - isFromOrAddedByTrustedContact || - hasSentMessages || - hasMessagesBeforeMessageRequests || - // an empty group is the scenario where we need to rely on - // whether the profile has already been shared or not - isEmptyPrivateConvo || - isEmptyWhitelistedGroup - ); - }, - - onMemberVerifiedChange() { - // If the verified state of a member changes, our aggregate state changes. - // We trigger both events to replicate the behavior of Backbone.Model.set() - this.trigger('change:verified', this); - this.trigger('change', this); - }, - toggleVerified() { - if (this.isVerified()) { - return this.setVerifiedDefault(); - } - return this.setVerified(); - }, - - async addKeyChange(keyChangedId) { - window.log.info( - 'adding key change advisory for', - this.idForLogging(), - keyChangedId, - this.get('timestamp') - ); - - const timestamp = Date.now(); - const message = { - conversationId: this.id, - type: 'keychange', - sent_at: this.get('timestamp'), - received_at: timestamp, - key_changed: keyChangedId, - unread: 1, - }; - - const id = await window.Signal.Data.saveMessage(message, { - Message: Whisper.Message, - }); - const model = MessageController.register( - id, - new Whisper.Message({ - ...message, - id, - }) - ); - - this.trigger('newmessage', model); - }, - async addVerifiedChange(verifiedChangeId, verified, providedOptions) { - const options = providedOptions || {}; - _.defaults(options, { local: true }); - - if (this.isMe()) { - window.log.info( - 'refusing to add verified change advisory for our own number' - ); - return; - } - - const lastMessage = this.get('timestamp') || Date.now(); - - window.log.info( - 'adding verified change advisory for', - this.idForLogging(), - verifiedChangeId, - lastMessage - ); - - const timestamp = Date.now(); - const message = { - conversationId: this.id, - type: 'verified-change', - sent_at: lastMessage, - received_at: timestamp, - verifiedChanged: verifiedChangeId, - verified, - local: options.local, - unread: 1, - }; - - const id = await window.Signal.Data.saveMessage(message, { - Message: Whisper.Message, - }); - const model = MessageController.register( - id, - new Whisper.Message({ - ...message, - id, - }) - ); - - this.trigger('newmessage', model); - - if (this.isPrivate()) { - ConversationController.getAllGroupsInvolvingId(this.id).then(groups => { - _.forEach(groups, group => { - group.addVerifiedChange(this.id, verified, options); - }); - }); - } - }, - - async addCallHistory(callHistoryDetails) { - const { acceptedTime, endedTime, wasDeclined } = callHistoryDetails; - const message = { - conversationId: this.id, - type: 'call-history', - sent_at: endedTime, - received_at: endedTime, - unread: !wasDeclined && !acceptedTime, - callHistoryDetails, - }; - - const id = await window.Signal.Data.saveMessage(message, { - Message: Whisper.Message, - }); - const model = MessageController.register( - id, - new Whisper.Message({ - ...message, - id, - }) - ); - - this.trigger('newmessage', model); - }, - - async addProfileChange(profileChange, conversationId) { - const message = { - conversationId: this.id, - type: 'profile-change', - sent_at: Date.now(), - received_at: Date.now(), - unread: true, - changedId: conversationId || this.id, - profileChange, - }; - - const id = await window.Signal.Data.saveMessage(message, { - Message: Whisper.Message, - }); - const model = MessageController.register( - id, - new Whisper.Message({ - ...message, - id, - }) - ); - - this.trigger('newmessage', model); - - if (this.isPrivate()) { - ConversationController.getAllGroupsInvolvingId(this.id).then(groups => { - _.forEach(groups, group => { - group.addProfileChange(profileChange, this.id); - }); - }); - } - }, - - async onReadMessage(message, readAt) { - // We mark as read everything older than this message - to clean up old stuff - // still marked unread in the database. If the user generally doesn't read in - // the desktop app, so the desktop app only gets read syncs, we can very - // easily end up with messages never marked as read (our previous early read - // sync handling, read syncs never sent because app was offline) - - // We queue it because we often get a whole lot of read syncs at once, and - // their markRead calls could very easily overlap given the async pull from DB. - - // Lastly, we don't send read syncs for any message marked read due to a read - // sync. That's a notification explosion we don't need. - return this.queueJob(() => - this.markRead(message.get('received_at'), { - sendReadReceipts: false, - readAt, - }) - ); - }, - - getUnread() { - return window.Signal.Data.getUnreadByConversation(this.id, { - MessageCollection: Whisper.MessageCollection, - }); - }, - - validate(attributes = this.attributes) { - const required = ['type']; - const missing = _.filter(required, attr => !attributes[attr]); - if (missing.length) { - return `Conversation must have ${missing}`; - } - - if (attributes.type !== 'private' && attributes.type !== 'group') { - return `Invalid conversation type: ${attributes.type}`; - } - - const atLeastOneOf = ['e164', 'uuid', 'groupId']; - const hasAtLeastOneOf = - _.filter(atLeastOneOf, attr => attributes[attr]).length > 0; - - if (!hasAtLeastOneOf) { - return 'Missing one of e164, uuid, or groupId'; - } - - const error = this.validateNumber() || this.validateUuid(); - - if (error) { - return error; - } - - return null; - }, - - validateNumber() { - if (this.isPrivate() && this.get('e164')) { - const regionCode = storage.get('regionCode'); - const number = libphonenumber.util.parseNumber( - this.get('e164'), - regionCode - ); - if (number.isValidNumber) { - this.set({ e164: number.e164 }); - return null; - } - - return number.error || 'Invalid phone number'; - } - - return null; - }, - - validateUuid() { - if (this.isPrivate() && this.get('uuid')) { - if (window.isValidGuid(this.get('uuid'))) { - return null; - } - - return 'Invalid UUID'; - } - - return null; - }, - - queueJob(callback) { - this.jobQueue = this.jobQueue || new window.PQueue({ concurrency: 1 }); - - const taskWithTimeout = textsecure.createTaskWithTimeout( - callback, - `conversation ${this.idForLogging()}` - ); - - return this.jobQueue.add(taskWithTimeout); - }, - - getMembers() { - if (this.isPrivate()) { - return [this]; - } - - if (this.get('membersV2')) { - return _.compact( - this.get('membersV2').map(member => { - const c = ConversationController.get(member.conversationId); - - // In groups we won't sent to contacts we believe are unregistered - if (c && c.isUnregistered()) { - return null; - } - - return c; - }) - ); - } - - if (this.get('members')) { - return _.compact( - this.get('members').map(id => { - const c = ConversationController.get(id); - - // In groups we won't sent to contacts we believe are unregistered - if (c && c.isUnregistered()) { - return null; - } - - return c; - }) - ); - } - - window.log.warn( - 'getMembers: Group conversation had neither membersV2 nor members' - ); - - return []; - }, - - getMemberIds() { - const members = this.getMembers(); - return members.map(member => member.id); - }, - - getRecipients() { - const members = this.getMembers(); - - // Eliminate our - return _.compact( - members.map(member => (member.isMe() ? null : member.getSendTarget())) - ); - }, - - async getQuoteAttachment(attachments, preview, sticker) { - if (attachments && attachments.length) { - return Promise.all( - attachments - .filter( - attachment => - attachment && - attachment.contentType && - !attachment.pending && - !attachment.error - ) - .slice(0, 1) - .map(async attachment => { - const { fileName, thumbnail, contentType } = attachment; - - return { - contentType, - // Our protos library complains about this field being undefined, so we - // force it to null - fileName: fileName || null, - thumbnail: thumbnail - ? { - ...(await loadAttachmentData(thumbnail)), - objectUrl: getAbsoluteAttachmentPath(thumbnail.path), - } - : null, - }; - }) - ); - } - - if (preview && preview.length) { - return Promise.all( - preview - .filter(item => item && item.image) - .slice(0, 1) - .map(async attachment => { - const { image } = attachment; - const { contentType } = image; - - return { - contentType, - // Our protos library complains about this field being undefined, so we - // force it to null - fileName: null, - thumbnail: image - ? { - ...(await loadAttachmentData(image)), - objectUrl: getAbsoluteAttachmentPath(image.path), - } - : null, - }; - }) - ); - } - - if (sticker && sticker.data && sticker.data.path) { - const { path, contentType } = sticker.data; - - return [ - { - contentType, - // Our protos library complains about this field being undefined, so we - // force it to null - fileName: null, - thumbnail: { - ...(await loadAttachmentData(sticker.data)), - objectUrl: getAbsoluteAttachmentPath(path), - }, - }, - ]; - } - - return []; - }, - - async makeQuote(quotedMessage) { - const { getName } = Contact; - const contact = quotedMessage.getContact(); - const attachments = quotedMessage.get('attachments'); - const preview = quotedMessage.get('preview'); - const sticker = quotedMessage.get('sticker'); - - const body = quotedMessage.get('body'); - const embeddedContact = quotedMessage.get('contact'); - const embeddedContactName = - embeddedContact && embeddedContact.length > 0 - ? getName(embeddedContact[0]) - : ''; - - return { - author: contact.get('e164'), - authorUuid: contact.get('uuid'), - bodyRanges: quotedMessage.get('bodyRanges'), - id: quotedMessage.get('sent_at'), - text: body || embeddedContactName, - attachments: quotedMessage.isTapToView() - ? [{ contentType: 'image/jpeg', fileName: null }] - : await this.getQuoteAttachment(attachments, preview, sticker), - }; - }, - - async sendStickerMessage(packId, stickerId) { - const packData = window.Signal.Stickers.getStickerPack(packId); - const stickerData = window.Signal.Stickers.getSticker(packId, stickerId); - if (!stickerData || !packData) { - window.log.warn( - `Attempted to send nonexistent (${packId}, ${stickerId}) sticker!` - ); - return; - } - - const { key } = packData; - const { path, width, height } = stickerData; - const arrayBuffer = await readStickerData(path); - - const sticker = { - packId, - stickerId, - packKey: key, - data: { - size: arrayBuffer.byteLength, - data: arrayBuffer, - contentType: 'image/webp', - width, - height, - }, - }; - - this.sendMessage(null, [], null, [], sticker); - window.reduxActions.stickers.useSticker(packId, stickerId); - }, - - /** - * Sends a reaction message - * @param {object} reaction - The reaction to send - * @param {string} reaction.emoji - The emoji to react with - * @param {boolean} [reaction.remove] - Set to `true` if we are removing a - * reaction with the given emoji - * @param {object} target - The target of the reaction - * @param {string} [target.targetAuthorE164] - The E164 address of the target - * message's author - * @param {string} [target.targetAuthorUuid] - The UUID address of the target - * message's author - * @param {number} target.targetTimestamp - The sent_at timestamp of the - * target message - */ - async sendReactionMessage(reaction, target) { - const timestamp = Date.now(); - const outgoingReaction = { ...reaction, ...target }; - const expireTimer = this.get('expireTimer'); - - const reactionModel = Whisper.Reactions.add({ - ...outgoingReaction, - fromId: ConversationController.getOurConversationId(), - timestamp, - fromSync: true, - }); - Whisper.Reactions.onReaction(reactionModel); - - const destination = this.getSendTarget(); - const recipients = this.getRecipients(); - - let profileKey; - if (this.get('profileSharing')) { - profileKey = storage.get('profileKey'); - } - - return this.queueJob(async () => { - window.log.info( - 'Sending reaction to conversation', - this.idForLogging(), - 'with timestamp', - timestamp - ); - - const attributes = { - id: window.getGuid(), - type: 'outgoing', - conversationId: this.get('id'), - sent_at: timestamp, - received_at: timestamp, - recipients, - reaction: outgoingReaction, - }; - - if (this.isPrivate()) { - attributes.destination = destination; - } - - // We are only creating this model so we can use its sync message - // sending functionality. It will not be saved to the datbase. - const message = new Whisper.Message(attributes); - - // We're offline! - if (!textsecure.messaging) { - throw new Error('Cannot send reaction while offline!'); - } - - // Special-case the self-send case - we send only a sync message - if (this.isMe()) { - const dataMessage = await textsecure.messaging.getMessageProto( - destination, - null, // body - null, // attachments - null, // quote - null, // preview - null, // sticker - outgoingReaction, - timestamp, - expireTimer, - profileKey - ); - return message.sendSyncMessageOnly(dataMessage); - } - - const options = this.getSendOptions(); - - const promise = (() => { - if (this.isPrivate()) { - return textsecure.messaging.sendMessageToIdentifier( - destination, - null, // body - null, // attachments - null, // quote - null, // preview - null, // sticker - outgoingReaction, - timestamp, - expireTimer, - profileKey, - options - ); - } - - return textsecure.messaging.sendMessageToGroup( - { - groupV1: this.getGroupV1Info(), - groupV2: this.getGroupV2Info(), - reaction: outgoingReaction, - timestamp, - expireTimer, - profileKey, - }, - options - ); - })(); - - return message.send(this.wrapSend(promise)); - }).catch(error => { - window.log.error('Error sending reaction', reaction, target, error); - - const reverseReaction = reactionModel.clone(); - reverseReaction.set('remove', !reverseReaction.get('remove')); - Whisper.Reactions.onReaction(reverseReaction); - - throw error; - }); - }, - - async sendProfileKeyUpdate() { - const id = this.get('id'); - const recipients = this.getRecipients(); - if (!this.get('profileSharing')) { - window.log.error( - 'Attempted to send profileKeyUpdate to conversation without profileSharing enabled', - id, - recipients - ); - return; - } - window.log.info( - 'Sending profileKeyUpdate to conversation', - id, - recipients - ); - const profileKey = storage.get('profileKey'); - await textsecure.messaging.sendProfileKeyUpdate( - profileKey, - recipients, - this.getSendOptions(), - this.get('groupId') - ); - }, - - sendMessage(body, attachments, quote, preview, sticker) { - this.clearTypingTimers(); - - const { clearUnreadMetrics } = window.reduxActions.conversations; - clearUnreadMetrics(this.id); - - const destination = this.getSendTarget(); - const expireTimer = this.get('expireTimer'); - const recipients = this.getRecipients(); - - let profileKey; - if (this.get('profileSharing')) { - profileKey = storage.get('profileKey'); - } - - this.queueJob(async () => { - const now = Date.now(); - - window.log.info( - 'Sending message to conversation', - this.idForLogging(), - 'with timestamp', - now - ); - - // Here we move attachments to disk - const messageWithSchema = await upgradeMessageSchema({ - type: 'outgoing', - body, - conversationId: this.id, - quote, - preview, - attachments, - sent_at: now, - received_at: now, - expireTimer, - recipients, - sticker, - }); - - if (this.isPrivate()) { - messageWithSchema.destination = destination; - } - const attributes = { - ...messageWithSchema, - id: window.getGuid(), - }; - - const model = this.addSingleMessage(attributes); - if (sticker) { - await addStickerPackReference(model.id, sticker.packId); - } - const message = MessageController.register(model.id, model); - await window.Signal.Data.saveMessage(message.attributes, { - forceSave: true, - Message: Whisper.Message, - }); - - this.set({ - lastMessage: model.getNotificationText(), - lastMessageStatus: 'sending', - active_at: now, - timestamp: now, - isArchived: false, - draft: null, - draftTimestamp: null, - }); - this.incrementSentMessageCount(); - window.Signal.Data.updateConversation(this.attributes); - - // We're offline! - if (!textsecure.messaging) { - const errors = (this.contactCollection.length - ? this.contactCollection - : [this] - ).map(contact => { - const error = new Error('Network is not available'); - error.name = 'SendMessageNetworkError'; - error.identifier = contact.get('id'); - return error; - }); - await message.saveErrors(errors); - return null; - } - - const attachmentsWithData = await Promise.all( - messageWithSchema.attachments.map(loadAttachmentData) - ); - - const { - body: messageBody, - attachments: finalAttachments, - } = Whisper.Message.getLongMessageAttachment({ - body, - attachments: attachmentsWithData, - now, - }); - - // Special-case the self-send case - we send only a sync message - if (this.isMe()) { - const dataMessage = await textsecure.messaging.getMessageProto( - destination, - messageBody, - finalAttachments, - quote, - preview, - sticker, - null, // reaction - now, - expireTimer, - profileKey - ); - return message.sendSyncMessageOnly(dataMessage); - } - - const conversationType = this.get('type'); - const options = this.getSendOptions(); - - let promise; - if (conversationType === Message.GROUP) { - promise = textsecure.messaging.sendMessageToGroup( - { - attachments: finalAttachments, - expireTimer, - groupV1: this.getGroupV1Info(), - groupV2: this.getGroupV2Info(), - messageText: messageBody, - preview, - profileKey, - quote, - sticker, - timestamp: now, - }, - options - ); - } else { - promise = textsecure.messaging.sendMessageToIdentifier( - destination, - messageBody, - finalAttachments, - quote, - preview, - sticker, - null, // reaction - now, - expireTimer, - profileKey, - options - ); - } - - return message.send(this.wrapSend(promise)); - }); - }, - - wrapSend(promise) { - return promise.then( - async result => { - // success - if (result) { - await this.handleMessageSendResult( - result.failoverIdentifiers, - result.unidentifiedDeliveries, - result.discoveredIdentifierPairs - ); - } - return result; - }, - async result => { - // failure - if (result) { - await this.handleMessageSendResult( - result.failoverIdentifiers, - result.unidentifiedDeliveries, - result.discoveredIdentifierPairs - ); - } - throw result; - } - ); - }, - - async handleMessageSendResult( - failoverIdentifiers, - unidentifiedDeliveries, - discoveredIdentifierPairs - ) { - discoveredIdentifierPairs.forEach(item => { - const { uuid, e164 } = item; - window.ConversationController.ensureContactIds({ - uuid, - e164, - highTrust: true, - }); - }); - - await Promise.all( - (failoverIdentifiers || []).map(async identifier => { - const conversation = ConversationController.get(identifier); - - if ( - conversation && - conversation.get('sealedSender') !== SEALED_SENDER.DISABLED - ) { - window.log.info( - `Setting sealedSender to DISABLED for conversation ${conversation.idForLogging()}` - ); - conversation.set({ - sealedSender: SEALED_SENDER.DISABLED, - }); - window.Signal.Data.updateConversation(conversation.attributes); - } - }) - ); - - await Promise.all( - (unidentifiedDeliveries || []).map(async identifier => { - const conversation = ConversationController.get(identifier); - - if ( - conversation && - conversation.get('sealedSender') === SEALED_SENDER.UNKNOWN - ) { - if (conversation.get('accessKey')) { - window.log.info( - `Setting sealedSender to ENABLED for conversation ${conversation.idForLogging()}` - ); - conversation.set({ - sealedSender: SEALED_SENDER.ENABLED, - }); - } else { - window.log.info( - `Setting sealedSender to UNRESTRICTED for conversation ${conversation.idForLogging()}` - ); - conversation.set({ - sealedSender: SEALED_SENDER.UNRESTRICTED, - }); - } - window.Signal.Data.updateConversation(conversation.attributes); - } - }) - ); - }, - - getSendOptions(options = {}) { - const senderCertificate = storage.get('senderCertificate'); - const sendMetadata = this.getSendMetadata(options); - - return { - senderCertificate, - sendMetadata, - }; - }, - - getUuidCapable() { - return Boolean(_.property('uuid')(this.get('capabilities'))); - }, - - getSendMetadata(options = {}) { - const { syncMessage, disableMeCheck } = options; - - // 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.getOurConversationId(); - const me = ConversationController.get(myId); - if ( - !disableMeCheck && - me.get('sealedSender') === SEALED_SENDER.DISABLED - ) { - return null; - } - // END - - if (!this.isPrivate()) { - const infoArray = this.contactCollection.map(conversation => - conversation.getSendMetadata(options) - ); - return Object.assign({}, ...infoArray); - } - - const accessKey = this.get('accessKey'); - const sealedSender = this.get('sealedSender'); - const uuidCapable = this.getUuidCapable(); - - // We never send sync messages as sealed sender - if (syncMessage && this.isMe()) { - return null; - } - - const e164 = this.get('e164'); - const uuid = this.get('uuid'); - - // If we've never fetched user's profile, we default to what we have - if (sealedSender === SEALED_SENDER.UNKNOWN) { - const info = { - accessKey: accessKey || arrayBufferToBase64(getRandomBytes(16)), - // Indicates that a client is capable of receiving uuid-only messages. - // Not used yet. - uuidCapable, - }; - return { - ...(e164 ? { [e164]: info } : {}), - ...(uuid ? { [uuid]: info } : {}), - }; - } - - if (sealedSender === SEALED_SENDER.DISABLED) { - return null; - } - - const info = { - accessKey: - accessKey && sealedSender === SEALED_SENDER.ENABLED - ? accessKey - : arrayBufferToBase64(getRandomBytes(16)), - // Indicates that a client is capable of receiving uuid-only messages. - // Not used yet. - uuidCapable, - }; - - return { - ...(e164 ? { [e164]: info } : {}), - ...(uuid ? { [uuid]: info } : {}), - }; - }, - - // Is this someone who is a contact, or are we sharing our profile with them? - // Or is the person who added us to this group a contact or are we sharing profile - // with them? - isFromOrAddedByTrustedContact() { - if (this.isPrivate()) { - return Boolean(this.get('name')) || this.get('profileSharing'); - } - - const addedBy = this.get('addedBy'); - if (!addedBy) { - return false; - } - - const conv = ConversationController.get(addedBy); - if (!conv) { - return false; - } - - return Boolean(conv.get('name')) || conv.get('profileSharing'); - }, - - async updateLastMessage() { - if (!this.id) { - return; - } - - const [previewMessage, activityMessage] = await Promise.all([ - window.Signal.Data.getLastConversationPreview(this.id, { - Message: Whisper.Message, - }), - window.Signal.Data.getLastConversationActivity(this.id, { - Message: Whisper.Message, - }), - ]); - - if ( - this.hasDraft() && - this.get('draftTimestamp') && - (!previewMessage || - previewMessage.get('sent_at') < this.get('draftTimestamp')) - ) { - return; - } - - const currentTimestamp = this.get('timestamp') || null; - const timestamp = activityMessage - ? activityMessage.get('sent_at') || - activityMessage.get('received_at') || - currentTimestamp - : currentTimestamp; - - this.set({ - lastMessage: - (previewMessage ? previewMessage.getNotificationText() : '') || '', - lastMessageStatus: - (previewMessage ? previewMessage.getMessagePropStatus() : null) || - null, - timestamp, - lastMessageDeletedForEveryone: previewMessage - ? previewMessage.deletedForEveryone - : false, - }); - - window.Signal.Data.updateConversation(this.attributes); - }, - - setArchived(isArchived) { - const before = this.get('isArchived'); - - this.set({ isArchived }); - window.Signal.Data.updateConversation(this.attributes); - - const after = this.get('isArchived'); - - if (Boolean(before) !== Boolean(after)) { - this.captureChange(); - } - }, - - async updateExpirationTimerInGroupV2(seconds) { - // Make change on the server - const actions = window.Signal.Groups.buildDisappearingMessagesTimerChange( - { - expireTimer: seconds || 0, - group: this.attributes, - } - ); - let signedGroupChange; - try { - signedGroupChange = await window.Signal.Groups.uploadGroupChange({ - actions, - group: this.attributes, - serverPublicParamsBase64: window.getServerPublicParams(), - }); - } catch (error) { - // Get latest GroupV2 data, since we ran into trouble updating it - this.fetchLatestGroupV2Data(); - throw error; - } - - // Update local conversation - this.set({ - expireTimer: seconds || 0, - revision: actions.version, - }); - window.Signal.Data.updateConversation(this.attributes); - - // Create local notification - const timestamp = Date.now(); - const id = window.getGuid(); - const message = MessageController.register( - id, - new Whisper.Message({ - id, - conversationId: this.id, - sent_at: timestamp, - received_at: timestamp, - flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, - expirationTimerUpdate: { - expireTimer: seconds, - sourceUuid: this.ourUuid, - }, - }) - ); - await window.Signal.Data.saveMessage(message.attributes, { - Message: Whisper.Message, - forceSave: true, - }); - this.trigger('newmessage', message); - - // Send message to all group members - const profileKey = this.get('profileSharing') - ? storage.get('profileKey') - : undefined; - const sendOptions = this.getSendOptions(); - const promise = textsecure.messaging.sendMessageToGroup( - { - groupV2: this.getGroupV2Info(signedGroupChange.toArrayBuffer()), - timestamp, - profileKey, - }, - sendOptions - ); - - message.send(promise); - }, - - async updateExpirationTimer( - providedExpireTimer, - providedSource, - receivedAt, - options = {} - ) { - if (this.get('groupVersion') === 2) { - if (providedSource || receivedAt) { - throw new Error( - 'updateExpirationTimer: GroupV2 timers are not updated this way' - ); - } - await this.updateExpirationTimerInGroupV2(providedExpireTimer); - return false; - } - - let expireTimer = providedExpireTimer; - let source = providedSource; - if (this.get('left')) { - return false; - } - - _.defaults(options, { fromSync: false, fromGroupUpdate: false }); - - if (!expireTimer) { - expireTimer = null; - } - if ( - this.get('expireTimer') === expireTimer || - (!expireTimer && !this.get('expireTimer')) - ) { - return null; - } - - window.log.info("Update conversation 'expireTimer'", { - id: this.idForLogging(), - expireTimer, - source, - }); - - source = source || 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. - const timestamp = (receivedAt || Date.now()) - 1; - - this.set({ expireTimer }); - window.Signal.Data.updateConversation(this.attributes); - - const model = new Whisper.Message({ - // Even though this isn't reflected to the user, we want to place the last seen - // indicator above it. We set it to 'unread' to trigger that placement. - unread: 1, - conversationId: this.id, - // No type; 'incoming' messages are specially treated by conversation.markRead() - sent_at: timestamp, - received_at: timestamp, - flags: textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, - expirationTimerUpdate: { - expireTimer, - source, - fromSync: options.fromSync, - fromGroupUpdate: options.fromGroupUpdate, - }, - }); - - if (this.isPrivate()) { - model.set({ destination: this.getSendTarget() }); - } - if (model.isOutgoing()) { - model.set({ recipients: this.getRecipients() }); - } - const id = await window.Signal.Data.saveMessage(model.attributes, { - Message: Whisper.Message, - }); - - model.set({ id }); - - const message = MessageController.register(id, model); - this.addSingleMessage(message); - - // if change was made remotely, don't send it to the number/group - if (receivedAt) { - return message; - } - - let profileKey; - if (this.get('profileSharing')) { - profileKey = storage.get('profileKey'); - } - const sendOptions = this.getSendOptions(); - let promise; - - if (this.isMe()) { - const flags = - textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE; - const dataMessage = await textsecure.messaging.getMessageProto( - this.getSendTarget(), - null, // body - [], // attachments - null, // quote - [], // preview - null, // sticker - null, // reaction - message.get('sent_at'), - expireTimer, - profileKey, - flags - ); - return message.sendSyncMessageOnly(dataMessage); - } - - if (this.get('type') === 'private') { - promise = textsecure.messaging.sendExpirationTimerUpdateToIdentifier( - this.getSendTarget(), - expireTimer, - message.get('sent_at'), - profileKey, - sendOptions - ); - } else { - promise = textsecure.messaging.sendExpirationTimerUpdateToGroup( - this.get('groupId'), - this.getRecipients(), - expireTimer, - message.get('sent_at'), - profileKey, - sendOptions - ); - } - - await message.send(this.wrapSend(promise)); - - return message; - }, - - async addMessageHistoryDisclaimer() { - const lastMessage = this.messageCollection.last(); - if ( - lastMessage && - lastMessage.get('type') === 'message-history-unsynced' - ) { - // We do not need another message history disclaimer - return lastMessage; - } - - const timestamp = Date.now(); - - const model = new Whisper.Message({ - type: 'message-history-unsynced', - // Even though this isn't reflected to the user, we want to place the last seen - // indicator above it. We set it to 'unread' to trigger that placement. - unread: 1, - conversationId: this.id, - // No type; 'incoming' messages are specially treated by conversation.markRead() - sent_at: timestamp, - received_at: timestamp, - }); - - if (this.isPrivate()) { - model.set({ destination: this.id }); - } - if (model.isOutgoing()) { - model.set({ recipients: this.getRecipients() }); - } - const id = await window.Signal.Data.saveMessage(model.attributes, { - Message: Whisper.Message, - }); - - model.set({ id }); - - const message = MessageController.register(id, model); - this.addSingleMessage(message); - - return message; - }, - - isSearchable() { - return !this.get('left'); - }, - - async endSession() { - if (this.isPrivate()) { - const now = Date.now(); - const model = new Whisper.Message({ - conversationId: this.id, - type: 'outgoing', - sent_at: now, - received_at: now, - destination: this.get('e164'), - destinationUuid: this.get('uuid'), - recipients: this.getRecipients(), - flags: textsecure.protobuf.DataMessage.Flags.END_SESSION, - }); - - const id = await window.Signal.Data.saveMessage(model.attributes, { - Message: Whisper.Message, - }); - model.set({ id }); - - const message = MessageController.register(model.id, model); - this.addSingleMessage(message); - - const options = this.getSendOptions(); - message.send( - this.wrapSend( - textsecure.messaging.resetSession( - this.get('uuid'), - this.get('e164'), - now, - options - ) - ) - ); - } - }, - - async markRead(newestUnreadDate, providedOptions) { - const options = providedOptions || {}; - _.defaults(options, { sendReadReceipts: true }); - - const conversationId = this.id; - Whisper.Notifications.removeBy({ conversationId }); - - let unreadMessages = await this.getUnread(); - const oldUnread = unreadMessages.filter( - message => message.get('received_at') <= newestUnreadDate - ); - - let read = await Promise.all( - _.map(oldUnread, async providedM => { - const m = MessageController.register(providedM.id, providedM); - - // Note that this will update the message in the database - await m.markRead(options.readAt); - - return { - 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(), - }; - }) - ); - - // Some messages we're marking read are local notifications with no sender - read = _.filter(read, m => Boolean(m.senderId)); - unreadMessages = unreadMessages.filter(m => Boolean(m.isIncoming())); - - const unreadCount = unreadMessages.length - read.length; - this.set({ unreadCount }); - window.Signal.Data.updateConversation(this.attributes); - - // If a message has errors, we don't want to send anything out about it. - // read syncs - let's wait for a client that really understands the message - // to mark it read. we'll mark our local error read locally, though. - // read receipts - here we can run into infinite loops, where each time the - // conversation is viewed, another error message shows up for the contact - read = read.filter(item => !item.hasErrors); - - if (read.length && options.sendReadReceipts) { - window.log.info(`Sending ${read.length} read syncs`); - // Because syncReadMessages sends to our other devices, and sendReadReceipts goes - // to a contact, we need accessKeys for both. - const { - sendOptions, - } = ConversationController.prepareForSend( - ConversationController.getOurConversationId(), - { syncMessage: true } - ); - await this.wrapSend( - textsecure.messaging.syncReadMessages(read, sendOptions) - ); - await this.sendReadReceiptsFor(read); - } - }, - - async sendReadReceiptsFor(items) { - // Only send read receipts for accepted conversations - if (storage.get('read-receipt-setting') && this.getAccepted()) { - window.log.info(`Sending ${items.length} read receipts`); - const convoSendOptions = this.getSendOptions(); - const receiptsBySender = _.groupBy(items, 'senderId'); - - await Promise.all( - _.map(receiptsBySender, async (receipts, senderId) => { - const timestamps = _.map(receipts, 'timestamp'); - const c = ConversationController.get(senderId); - await this.wrapSend( - textsecure.messaging.sendReadReceipts( - c.get('e164'), - c.get('uuid'), - timestamps, - convoSendOptions - ) - ); - }) - ); - } - }, - - // This is an expensive operation we use to populate the message request hero row. It - // shows groups the current user has in common with this potential new contact. - async updateSharedGroups() { - if (!this.isPrivate()) { - return; - } - if (this.isMe()) { - return; - } - - const ourGroups = await ConversationController.getAllGroupsInvolvingId( - ConversationController.getOurConversationId() - ); - const theirGroups = await ConversationController.getAllGroupsInvolvingId( - this.id - ); - - const sharedGroups = _.intersection(ourGroups, theirGroups); - const sharedGroupNames = sharedGroups.map(conversation => - conversation.getTitle() - ); - - this.set({ sharedGroupNames }); - }, - - onChangeProfileKey() { - if (this.isPrivate()) { - this.getProfiles(); - } - }, - - getProfiles() { - // request all conversation members' keys - const conversations = this.getMembers(); - return Promise.all( - _.map(conversations, conversation => { - this.getProfile(conversation.get('uuid'), conversation.get('e164')); - }) - ); - }, - - async getProfile(providedUuid, providedE164) { - if (!textsecure.messaging) { - throw new Error( - 'Conversation.getProfile: textsecure.messaging not available' - ); - } - - const id = ConversationController.ensureContactIds({ - uuid: providedUuid, - e164: providedE164, - }); - const c = ConversationController.get(id); - const { - generateProfileKeyCredentialRequest, - getClientZkProfileOperations, - handleProfileKeyCredential, - } = Util.zkgroup; - - const clientZkProfileCipher = getClientZkProfileOperations( - window.getServerPublicParams() - ); - - let profile; - - try { - await Promise.all([ - c.deriveAccessKeyIfNeeded(), - c.deriveProfileKeyVersionIfNeeded(), - ]); - - const profileKey = c.get('profileKey'); - const uuid = c.get('uuid'); - const identifier = c.getSendTarget(); - const profileKeyVersionHex = c.get('profileKeyVersion'); - const existingProfileKeyCredential = c.get('profileKeyCredential'); - - const weHaveVersion = Boolean( - profileKey && uuid && profileKeyVersionHex - ); - let profileKeyCredentialRequestHex; - let profileCredentialRequestContext; - - if (weHaveVersion && !existingProfileKeyCredential) { - window.log.info('Generating request...'); - ({ - requestHex: profileKeyCredentialRequestHex, - context: profileCredentialRequestContext, - } = generateProfileKeyCredentialRequest( - clientZkProfileCipher, - uuid, - profileKey - )); - } - - const sendMetadata = c.getSendMetadata({ disableMeCheck: true }) || {}; - const getInfo = - sendMetadata[c.get('uuid')] || sendMetadata[c.get('e164')] || {}; - - if (getInfo.accessKey) { - try { - profile = await textsecure.messaging.getProfile(identifier, { - accessKey: getInfo.accessKey, - profileKeyVersion: profileKeyVersionHex, - profileKeyCredentialRequest: profileKeyCredentialRequestHex, - }); - } catch (error) { - if (error.code === 401 || error.code === 403) { - window.log.info( - `Setting sealedSender to DISABLED for conversation ${c.idForLogging()}` - ); - c.set({ sealedSender: SEALED_SENDER.DISABLED }); - profile = await textsecure.messaging.getProfile(identifier, { - profileKeyVersion: profileKeyVersionHex, - profileKeyCredentialRequest: profileKeyCredentialRequestHex, - }); - } else { - throw error; - } - } - } else { - profile = await textsecure.messaging.getProfile(identifier, { - profileKeyVersion: profileKeyVersionHex, - profileKeyCredentialRequest: profileKeyCredentialRequestHex, - }); - } - - const identityKey = base64ToArrayBuffer(profile.identityKey); - const changed = await textsecure.storage.protocol.saveIdentity( - `${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(identifier, 1); - window.log.info('closing session for', address.toString()); - const sessionCipher = new libsignal.SessionCipher( - textsecure.storage.protocol, - address - ); - await sessionCipher.closeOpenSessionForDevice(); - } - - const accessKey = c.get('accessKey'); - if ( - profile.unrestrictedUnidentifiedAccess && - profile.unidentifiedAccess - ) { - window.log.info( - `Setting sealedSender to UNRESTRICTED for conversation ${c.idForLogging()}` - ); - c.set({ - sealedSender: SEALED_SENDER.UNRESTRICTED, - }); - } else if (accessKey && profile.unidentifiedAccess) { - const haveCorrectKey = await verifyAccessKey( - base64ToArrayBuffer(accessKey), - base64ToArrayBuffer(profile.unidentifiedAccess) - ); - - if (haveCorrectKey) { - window.log.info( - `Setting sealedSender to ENABLED for conversation ${c.idForLogging()}` - ); - c.set({ - sealedSender: SEALED_SENDER.ENABLED, - }); - } else { - window.log.info( - `Setting sealedSender to DISABLED for conversation ${c.idForLogging()}` - ); - c.set({ - sealedSender: SEALED_SENDER.DISABLED, - }); - } - } else { - window.log.info( - `Setting sealedSender to DISABLED for conversation ${c.idForLogging()}` - ); - c.set({ - sealedSender: SEALED_SENDER.DISABLED, - }); - } - - if (profile.capabilities) { - c.set({ capabilities: profile.capabilities }); - } - if (profileCredentialRequestContext && profile.credential) { - const profileKeyCredential = handleProfileKeyCredential( - clientZkProfileCipher, - profileCredentialRequestContext, - profile.credential - ); - c.set({ profileKeyCredential }); - } - } catch (error) { - if (error.code !== 403 && error.code !== 404) { - window.log.warn( - 'getProfile failure:', - c.idForLogging(), - error && error.stack ? error.stack : error - ); - } else { - await c.dropProfileKey(); - } - return; - } - - try { - await c.setEncryptedProfileName(profile.name); - } catch (error) { - window.log.warn( - 'getProfile decryption failure:', - c.idForLogging(), - error && error.stack ? error.stack : error - ); - await c.dropProfileKey(); - } - - try { - await c.setProfileAvatar(profile.avatar); - } catch (error) { - if (error.code === 403 || error.code === 404) { - window.log.info( - `Clearing profile avatar for conversation ${c.idForLogging()}` - ); - c.set({ - profileAvatar: null, - }); - } - } - - window.Signal.Data.updateConversation(c.attributes); - }, - async setEncryptedProfileName(encryptedName) { - if (!encryptedName) { - return; - } - const key = this.get('profileKey'); - if (!key) { - return; - } - - // decode - const keyBuffer = base64ToArrayBuffer(key); - const data = base64ToArrayBuffer(encryptedName); - - // decrypt - const { given, family } = await textsecure.crypto.decryptProfileName( - data, - keyBuffer - ); - - // encode - const profileName = given ? stringFromBytes(given) : null; - const profileFamilyName = family ? stringFromBytes(family) : null; - - // set then check for changes - const oldName = this.getProfileName(); - const hadPreviousName = Boolean(oldName); - this.set({ profileName, profileFamilyName }); - - const newName = this.getProfileName(); - - // Note that we compare the combined names to ensure that we don't present the exact - // same before/after string, even if someone is moving from just first name to - // first/last name in their profile data. - const nameChanged = oldName !== newName; - - if (!this.isMe() && hadPreviousName && nameChanged) { - const change = { - type: 'name', - oldName, - newName, - }; - - await this.addProfileChange(change); - } - }, - async setProfileAvatar(avatarPath) { - if (!avatarPath) { - return; - } - - if (this.isMe()) { - window.storage.put('avatarUrl', avatarPath); - } - - const avatar = await textsecure.messaging.getAvatar(avatarPath); - const key = this.get('profileKey'); - if (!key) { - return; - } - const keyBuffer = base64ToArrayBuffer(key); - - // decrypt - const decrypted = await textsecure.crypto.decryptProfile( - avatar, - keyBuffer - ); - - // update the conversation avatar only if hash differs - if (decrypted) { - const newAttributes = await window.Signal.Types.Conversation.maybeUpdateProfileAvatar( - this.attributes, - decrypted, - { - writeNewAttachmentData, - deleteAttachmentData, - doesAttachmentExist, - } - ); - this.set(newAttributes); - } - }, - async setProfileKey(profileKey, { viaStorageServiceSync = false } = {}) { - // profileKey is a string so we can compare it directly - if (this.get('profileKey') !== profileKey) { - window.log.info( - `Setting sealedSender to UNKNOWN for conversation ${this.idForLogging()}` - ); - this.set({ - profileKey, - profileKeyVersion: null, - profileKeyCredential: null, - accessKey: null, - sealedSender: SEALED_SENDER.UNKNOWN, - }); - - if (!viaStorageServiceSync) { - this.captureChange(); - } - - await Promise.all([ - this.deriveAccessKeyIfNeeded(), - this.deriveProfileKeyVersionIfNeeded(), - ]); - - window.Signal.Data.updateConversation(this.attributes, { - Conversation: Whisper.Conversation, - }); - } - }, - async dropProfileKey() { - if (this.get('profileKey')) { - window.log.info( - `Dropping profileKey, setting sealedSender to UNKNOWN for conversation ${this.idForLogging()}` - ); - const profileAvatar = this.get('profileAvatar'); - if (profileAvatar && profileAvatar.path) { - await deleteAttachmentData(profileAvatar.path); - } - - this.set({ - profileKey: null, - profileKeyVersion: null, - profileKeyCredential: null, - accessKey: null, - profileName: null, - profileFamilyName: null, - profileAvatar: null, - sealedSender: SEALED_SENDER.UNKNOWN, - }); - - window.Signal.Data.updateConversation(this.attributes); - } - }, - - async deriveAccessKeyIfNeeded() { - const profileKey = this.get('profileKey'); - if (!profileKey) { - return; - } - if (this.get('accessKey')) { - return; - } - - const profileKeyBuffer = base64ToArrayBuffer(profileKey); - const accessKeyBuffer = await deriveAccessKey(profileKeyBuffer); - const accessKey = arrayBufferToBase64(accessKeyBuffer); - this.set({ accessKey }); - }, - async deriveProfileKeyVersionIfNeeded() { - const profileKey = this.get('profileKey'); - if (!profileKey) { - return; - } - - const uuid = this.get('uuid'); - if (!uuid || this.get('profileKeyVersion')) { - return; - } - - const profileKeyVersion = Util.zkgroup.deriveProfileKeyVersion( - profileKey, - uuid - ); - - this.set({ profileKeyVersion }); - }, - - hasMember(identifier) { - const id = ConversationController.getConversationId(identifier); - const memberIds = this.getMemberIds(); - - return _.contains(memberIds, id); - }, - fetchContacts() { - if (this.isPrivate()) { - this.contactCollection.reset([this]); - } - const members = this.getMembers(); - _.forEach(members, member => { - this.listenTo(member, 'change:verified', this.onMemberVerifiedChange); - }); - - this.contactCollection.reset(members); - }, - - async destroyMessages() { - this.messageCollection.reset([]); - - this.set({ - lastMessage: null, - timestamp: null, - active_at: null, - }); - window.Signal.Data.updateConversation(this.attributes); - - await window.Signal.Data.removeAllMessagesInConversation(this.id, { - MessageCollection: Whisper.MessageCollection, - }); - }, - - getTitle() { - if (this.isPrivate()) { - return ( - this.get('name') || - this.getProfileName() || - this.getNumber() || - i18n('unknownContact') - ); - } - return this.get('name') || i18n('unknownGroup'); - }, - - getProfileName() { - if (this.isPrivate()) { - return Util.combineNames( - this.get('profileName'), - this.get('profileFamilyName') - ); - } - return null; - }, - - getNumber() { - if (!this.isPrivate()) { - return ''; - } - const number = this.get('e164'); - try { - const parsedNumber = libphonenumber.parse(number); - const regionCode = libphonenumber.getRegionCodeForNumber(parsedNumber); - if (regionCode === storage.get('regionCode')) { - return libphonenumber.format( - parsedNumber, - libphonenumber.PhoneNumberFormat.NATIONAL - ); - } - return libphonenumber.format( - parsedNumber, - libphonenumber.PhoneNumberFormat.INTERNATIONAL - ); - } catch (e) { - return number; - } - }, - - getInitials(name) { - if (!name) { - return null; - } - - const cleaned = name.replace(/[^A-Za-z\s]+/g, '').replace(/\s+/g, ' '); - const parts = cleaned.split(' '); - const initials = parts.map(part => part.trim()[0]); - if (!initials.length) { - return null; - } - - return initials.slice(0, 2).join(''); - }, - - isPrivate() { - return this.get('type') === 'private'; - }, - - getColor() { - if (!this.isPrivate()) { - return 'signal-blue'; - } - - const { migrateColor } = Util; - return migrateColor(this.get('color')); - }, - getAvatarPath() { - const avatar = this.isMe() - ? this.get('profileAvatar') || this.get('avatar') - : this.get('avatar') || this.get('profileAvatar'); - - if (avatar && avatar.path) { - return getAbsoluteAttachmentPath(avatar.path); - } - - return null; - }, - canChangeTimer() { - if (this.isPrivate()) { - return true; - } - - if (this.get('groupVersion') !== 2) { - return true; - } - - const accessControlEnum = - textsecure.protobuf.AccessControl.AccessRequired; - const accessControl = this.get('accessControl'); - const canAnyoneChangeTimer = - accessControl && - (accessControl.attributes === accessControlEnum.ANY || - accessControl.attributes === accessControlEnum.MEMBER); - if (canAnyoneChangeTimer) { - return true; - } - - const memberEnum = textsecure.protobuf.Member.Role; - const members = this.get('membersV2') || []; - const myId = ConversationController.getConversationId( - textsecure.storage.user.getUuid() || textsecure.storage.user.getNumber() - ); - const me = members.find(item => item.conversationId === myId); - if (!me) { - return false; - } - - const isAdministrator = me.role === memberEnum.ADMINISTRATOR; - if (isAdministrator) { - return true; - } - - return false; - }, - - // Set of items to captureChanges on: - // [-] uuid - // [-] e164 - // [X] profileKey - // [-] identityKey - // [X] verified! - // [-] profileName - // [-] profileFamilyName - // [X] blocked - // [X] whitelisted - // [X] archived - captureChange() { - if (!window.Signal.RemoteConfig.isEnabled('desktop.storageWrite')) { - window.log.info( - 'conversation.captureChange: Returning early; desktop.storageWrite is falsey' - ); - - return; - } - - window.log.info( - `storageService[captureChange] marking ${this.debugID()} as needing sync` - ); - this.set({ needsStorageServiceSync: true }); - - this.queueJob(() => { - Services.storageServiceUploadJob(); - }); - }, - - isMuted() { - return ( - this.get('muteExpiresAt') && Date.now() < this.get('muteExpiresAt') - ); - }, - - async notify(message, reaction) { - if (this.isMuted()) { - return; - } - - if (!message.isIncoming() && !reaction) { - return; - } - - const conversationId = this.id; - - const sender = reaction - ? ConversationController.get(reaction.get('fromId')) - : message.getContact(); - const senderName = sender ? sender.getTitle() : i18n('unknownContact'); - const senderTitle = this.isPrivate() - ? senderName - : i18n('notificationSenderInGroup', { - sender: senderName, - group: this.getTitle(), - }); - - let notificationIconUrl; - const avatar = this.get('avatar') || this.get('profileAvatar'); - if (avatar && avatar.path) { - notificationIconUrl = getAbsoluteAttachmentPath(avatar.path); - } else if (this.isPrivate()) { - notificationIconUrl = await new Whisper.IdenticonSVGView({ - color: this.getColor(), - content: this.getInitials(this.get('name')) || '#', - }).getDataUrl(); - } else { - // Not technically needed, but helps us be explicit: we don't show an icon for a - // group that doesn't have an icon. - notificationIconUrl = undefined; - } - - const messageJSON = message.toJSON(); - const messageId = message.id; - const isExpiringMessage = Message.hasExpiration(messageJSON); - - Whisper.Notifications.add({ - senderTitle, - conversationId, - notificationIconUrl, - isExpiringMessage, - message: message.getNotificationText(), - messageId, - reaction: reaction ? reaction.toJSON() : null, - }); - }, - - notifyTyping(options = {}) { - const { isTyping, senderId, isMe, senderDevice } = options; - - // We don't do anything with typing messages from our other devices - if (isMe) { - return; - } - - const typingToken = `${senderId}.${senderDevice}`; - - this.contactTypingTimers = this.contactTypingTimers || {}; - const record = this.contactTypingTimers[typingToken]; - - if (record) { - clearTimeout(record.timer); - } - - if (isTyping) { - this.contactTypingTimers[typingToken] = this.contactTypingTimers[ - typingToken - ] || { - timestamp: Date.now(), - senderId, - senderDevice, - }; - - this.contactTypingTimers[typingToken].timer = setTimeout( - this.clearContactTypingTimer.bind(this, typingToken), - 15 * 1000 - ); - if (!record) { - // User was not previously typing before. State change! - this.trigger('change', this); - } - } else { - delete this.contactTypingTimers[typingToken]; - if (record) { - // User was previously typing, and is no longer. State change! - this.trigger('change', this); - } - } - }, - - clearContactTypingTimer(typingToken) { - this.contactTypingTimers = this.contactTypingTimers || {}; - const record = this.contactTypingTimers[typingToken]; - - if (record) { - clearTimeout(record.timer); - delete this.contactTypingTimers[typingToken]; - - // User was previously typing, but timed out or we received message. State change! - this.trigger('change', this); - } - }, - }); - - Whisper.ConversationCollection = Backbone.Collection.extend({ - model: Whisper.Conversation, - - /** - * Backbone defines a `_byId` field. Here we set up additional `_byE164`, - * `_byUuid`, and `_byGroupId` fields so we can track conversations by more - * than just their id. - */ - initialize() { - this.eraseLookups(); - this.on('idUpdated', (model, idProp, oldValue) => { - if (oldValue) { - if (idProp === 'e164') { - delete this._byE164[oldValue]; - } - if (idProp === 'uuid') { - delete this._byUuid[oldValue]; - } - if (idProp === 'groupId') { - delete this._byGroupid[oldValue]; - } - } - if (model.get('e164')) { - this._byE164[model.get('e164')] = model; - } - if (model.get('uuid')) { - this._byUuid[model.get('uuid')] = model; - } - if (model.get('groupId')) { - this._byGroupid[model.get('groupId')] = model; - } - }); - }, - - reset(...args) { - Backbone.Collection.prototype.reset.apply(this, args); - this.resetLookups(); - }, - - resetLookups() { - this.eraseLookups(); - this.generateLookups(this.models); - }, - - generateLookups(models) { - models.forEach(model => { - const e164 = model.get('e164'); - if (e164) { - 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) { - 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'); - if (groupId) { - this._byGroupId[groupId] = model; - } - }); - }, - - eraseLookups() { - this._byE164 = Object.create(null); - this._byUuid = Object.create(null); - this._byGroupId = Object.create(null); - }, - - add(...models) { - const result = Backbone.Collection.prototype.add.apply(this, models); - - this.generateLookups(Array.isArray(result) ? result.slice(0) : [result]); - - return result; - }, - - /** - * Backbone collections have a `_byId` field that `get` defers to. Here, we - * override `get` to first access our custom `_byE164`, `_byUuid`, and - * `_byGroupId` functions, followed by falling back to the original - * Backbone implementation. - */ - get(id) { - return ( - this._byE164[id] || - this._byE164[`+${id}`] || - this._byUuid[id] || - this._byGroupId[id] || - Backbone.Collection.prototype.get.call(this, id) - ); - }, - - comparator(m) { - return -m.get('timestamp'); - }, - }); - - Whisper.Conversation.COLORS = COLORS.concat(['grey', 'default']).join(' '); - - // This is a wrapper model used to display group members in the member list view, within - // the world of backbone, but layering another bit of group-specific data top of base - // conversation data. - Whisper.GroupMemberConversation = Backbone.Model.extend({ - initialize(attributes) { - const { conversation, isAdmin } = attributes; - - if (!conversation) { - throw new Error( - 'GroupMemberConversation.initialze: conversation required!' - ); - } - if (!_.isBoolean(isAdmin)) { - throw new Error('GroupMemberConversation.initialze: isAdmin required!'); - } - - // If our underlying conversation changes, we change too - this.listenTo(conversation, 'change', () => { - this.trigger('change', this); - }); - - this.conversation = conversation; - this.isAdmin = isAdmin; - }, - - format() { - return { - ...this.conversation.format(), - isAdmin: this.isAdmin, - }; - }, - - get(...params) { - return this.conversation.get(...params); - }, - - getTitle() { - return this.conversation.getTitle(); - }, - - isMe() { - return this.conversation.isMe(); - }, - }); - - // We need a custom collection here to get the sorting we need - Whisper.GroupConversationCollection = Backbone.Collection.extend({ - model: Whisper.GroupMemberConversation, - - initialize() { - this.collator = new Intl.Collator(); - }, - - comparator(left, right) { - if (left.isAdmin && !right.isAdmin) { - return -1; - } - if (!left.isAdmin && right.isAdmin) { - return 1; - } - - const leftLower = left.getTitle().toLowerCase(); - const rightLower = right.getTitle().toLowerCase(); - return this.collator.compare(leftLower, rightLower); - }, - }); -})(); diff --git a/js/models/messages.js b/js/models/messages.js deleted file mode 100644 index 3e2bc44fe..000000000 --- a/js/models/messages.js +++ /dev/null @@ -1,3159 +0,0 @@ -/* global - _, - Backbone, - storage, - filesize, - ConversationController, - MessageController, - getAccountManager, - i18n, - Signal, - textsecure, - Whisper -*/ - -/* eslint-disable more/no-then */ - -// eslint-disable-next-line func-names -(function() { - window.Whisper = window.Whisper || {}; - - const { - Message: TypedMessage, - Attachment, - MIME, - Contact, - PhoneNumber, - Errors, - } = Signal.Types; - const { - deleteExternalMessageFiles, - getAbsoluteAttachmentPath, - loadAttachmentData, - loadQuoteData, - loadPreviewData, - loadStickerData, - upgradeMessageSchema, - } = window.Signal.Migrations; - const { - copyStickerToAttachments, - deletePackReference, - savePackMetadata, - getStickerPackStatus, - } = window.Signal.Stickers; - const { GoogleChrome, getTextWithMentions } = window.Signal.Util; - - const { addStickerPackReference, getMessageBySender } = window.Signal.Data; - const { bytesFromString } = window.Signal.Crypto; - const PLACEHOLDER_CONTACT = { - title: i18n('unknownContact'), - }; - - window.AccountCache = Object.create(null); - window.AccountJobs = Object.create(null); - - window.doesAccountCheckJobExist = number => - Boolean(window.AccountJobs[number]); - window.checkForSignalAccount = number => { - if (window.AccountJobs[number]) { - return window.AccountJobs[number]; - } - - let job; - if (textsecure.messaging) { - // eslint-disable-next-line more/no-then - job = textsecure.messaging - .getProfile(number) - .then(() => { - window.AccountCache[number] = true; - }) - .catch(() => { - window.AccountCache[number] = false; - }); - } else { - // We're offline! - job = Promise.resolve().then(() => { - window.AccountCache[number] = false; - }); - } - - window.AccountJobs[number] = job; - - return job; - }; - - window.isSignalAccountCheckComplete = number => - window.AccountCache[number] !== undefined; - window.hasSignalAccount = number => window.AccountCache[number]; - - const includesAny = (haystack, ...needles) => - needles.some(needle => haystack.includes(needle)); - - window.Whisper.Message = Backbone.Model.extend({ - initialize(attributes) { - if (_.isObject(attributes)) { - this.set( - TypedMessage.initializeSchemaVersion({ - message: attributes, - logger: window.log, - }) - ); - } - - this.CURRENT_PROTOCOL_VERSION = - textsecure.protobuf.DataMessage.ProtocolVersion.CURRENT; - this.INITIAL_PROTOCOL_VERSION = - textsecure.protobuf.DataMessage.ProtocolVersion.INITIAL; - this.OUR_NUMBER = textsecure.storage.user.getNumber(); - this.OUR_UUID = textsecure.storage.user.getUuid(); - - this.on('destroy', this.onDestroy); - this.on('change:expirationStartTimestamp', this.setToExpire); - this.on('change:expireTimer', this.setToExpire); - this.on('unload', this.unload); - this.on('expired', this.onExpired); - this.setToExpire(); - - this.on('change', this.notifyRedux); - }, - - notifyRedux() { - const { messageChanged } = window.reduxActions.conversations; - - if (messageChanged) { - const conversationId = this.get('conversationId'); - // Note: The clone is important for triggering a re-run of selectors - messageChanged(this.id, conversationId, this.getReduxData()); - } - }, - - getReduxData() { - const contact = this.getPropsForEmbeddedContact(); - - return { - ...this.attributes, - // We need this in the reducer to detect if the message's height has changed - hasSignalAccount: contact ? Boolean(contact.signalAccount) : null, - }; - }, - - isNormalBubble() { - return ( - !this.isCallHistory() && - !this.isEndSession() && - !this.isExpirationTimerUpdate() && - !this.isGroupUpdate() && - !this.isGroupV2Change() && - !this.isKeyChange() && - !this.isMessageHistoryUnsynced() && - !this.isProfileChange() && - !this.isUnsupportedMessage() && - !this.isVerifiedChange() - ); - }, - - // Top-level prop generation for the message bubble - getPropsForBubble() { - if (this.isUnsupportedMessage()) { - return { - type: 'unsupportedMessage', - data: this.getPropsForUnsupportedMessage(), - }; - } - if (this.isGroupV2Change()) { - return { - type: 'groupV2Change', - data: this.getPropsForGroupV2Change(), - }; - } - if (this.isMessageHistoryUnsynced()) { - return { - type: 'linkNotification', - data: null, - }; - } - if (this.isExpirationTimerUpdate()) { - return { - type: 'timerNotification', - data: this.getPropsForTimerNotification(), - }; - } - if (this.isKeyChange()) { - return { - type: 'safetyNumberNotification', - data: this.getPropsForSafetyNumberNotification(), - }; - } - if (this.isVerifiedChange()) { - return { - type: 'verificationNotification', - data: this.getPropsForVerificationNotification(), - }; - } - if (this.isGroupUpdate()) { - return { - type: 'groupNotification', - data: this.getPropsForGroupNotification(), - }; - } - if (this.isEndSession()) { - return { - type: 'resetSessionNotification', - data: this.getPropsForResetSessionNotification(), - }; - } - if (this.isCallHistory()) { - return { - type: 'callHistory', - data: this.getPropsForCallHistory(), - }; - } - if (this.isProfileChange()) { - return { - type: 'profileChange', - data: this.getPropsForProfileChange(), - }; - } - - return { - type: 'message', - data: this.getPropsForMessage(), - }; - }, - - // Other top-level prop-generation - getPropsForSearchResult() { - const sourceId = this.getContactId(); - const from = this.findAndFormatContact(sourceId); - - const conversationId = this.get('conversationId'); - const to = this.findAndFormatContact(conversationId); - - return { - from, - to, - - isSelected: this.isSelected, - - id: this.id, - conversationId, - sentAt: this.get('sent_at'), - snippet: this.get('snippet'), - }; - }, - getPropsForMessageDetail() { - const newIdentity = i18n('newIdentity'); - const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError'; - - const unidentifiedLookup = ( - this.get('unidentifiedDeliveries') || [] - ).reduce((accumulator, identifier) => { - // eslint-disable-next-line no-param-reassign - accumulator[ - ConversationController.getConversationId(identifier) - ] = true; - return accumulator; - }, Object.create(null)); - - // We include numbers we didn't successfully send to so we can display errors. - // 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.getContactId()] - : _.union( - (this.get('sent_to') || []).map(id => - ConversationController.getConversationId(id) - ), - ( - this.get('recipients') || this.getConversation().getRecipients() - ).map(id => ConversationController.getConversationId(id)) - ); - - // This will make the error message for outgoing key errors a bit nicer - const allErrors = (this.get('errors') || []).map(error => { - if (error.name === OUTGOING_KEY_ERROR) { - // eslint-disable-next-line no-param-reassign - error.message = newIdentity; - } - - return error; - }); - - // If an error has a specific number it's associated with, we'll show it next to - // that contact. Otherwise, it will be a standalone entry. - const errors = _.reject(allErrors, error => - Boolean(error.identifer || error.number) - ); - const errorsGroupedById = _.groupBy(allErrors, 'number'); - const finalContacts = (conversationIds || []).map(id => { - const errorsForContact = errorsGroupedById[id]; - const isOutgoingKeyError = Boolean( - _.find(errorsForContact, error => error.name === OUTGOING_KEY_ERROR) - ); - const isUnidentifiedDelivery = - storage.get('unidentifiedDeliveryIndicators') && - this.isUnidentifiedDelivery(id, unidentifiedLookup); - - return { - ...this.findAndFormatContact(id), - - status: this.getStatus(id), - errors: errorsForContact, - isOutgoingKeyError, - isUnidentifiedDelivery, - onSendAnyway: () => - this.trigger('force-send', { contactId: id, messageId: this.id }), - onShowSafetyNumber: () => this.trigger('show-identity', id), - }; - }); - - // The prefix created here ensures that contacts with errors are listed - // first; otherwise it's alphabetical - const sortedContacts = _.sortBy( - finalContacts, - contact => `${contact.errors ? '0' : '1'}${contact.title}` - ); - - return { - sentAt: this.get('sent_at'), - receivedAt: this.get('received_at'), - message: { - ...this.getPropsForMessage(), - disableMenu: true, - disableScroll: true, - // To ensure that group avatar doesn't show up - conversationType: 'direct', - downloadNewVersion: () => { - this.trigger('download-new-version'); - }, - deleteMessage: messageId => { - this.trigger('delete', messageId); - }, - showVisualAttachment: options => { - this.trigger('show-visual-attachment', options); - }, - displayTapToViewMessage: messageId => { - this.trigger('display-tap-to-view-message', messageId); - }, - openLink: url => { - this.trigger('navigate-to', url); - }, - reactWith: emoji => { - this.trigger('react-with', emoji); - }, - }, - errors, - contacts: sortedContacts, - }; - }, - - // Bucketing messages - isUnsupportedMessage() { - const versionAtReceive = this.get('supportedVersionAtReceive'); - const requiredVersion = this.get('requiredProtocolVersion'); - - return ( - _.isNumber(versionAtReceive) && - _.isNumber(requiredVersion) && - versionAtReceive < requiredVersion - ); - }, - isGroupV2Change() { - return Boolean(this.get('groupV2Change')); - }, - isExpirationTimerUpdate() { - const flag = - textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE; - // eslint-disable-next-line no-bitwise - return Boolean(this.get('flags') & flag); - }, - isKeyChange() { - return this.get('type') === 'keychange'; - }, - isVerifiedChange() { - return this.get('type') === 'verified-change'; - }, - isMessageHistoryUnsynced() { - return this.get('type') === 'message-history-unsynced'; - }, - isGroupUpdate() { - return !!this.get('group_update'); - }, - isEndSession() { - const flag = textsecure.protobuf.DataMessage.Flags.END_SESSION; - // eslint-disable-next-line no-bitwise - return !!(this.get('flags') & flag); - }, - isCallHistory() { - return this.get('type') === 'call-history'; - }, - isProfileChange() { - return this.get('type') === 'profile-change'; - }, - - // Props for each message type - getPropsForUnsupportedMessage() { - const requiredVersion = this.get('requiredProtocolVersion'); - const canProcessNow = this.CURRENT_PROTOCOL_VERSION >= requiredVersion; - const sourceId = this.getContactId(); - - return { - canProcessNow, - contact: this.findAndFormatContact(sourceId), - }; - }, - getPropsForGroupV2Change() { - const { protobuf } = window.textsecure; - - return { - AccessControlEnum: protobuf.AccessControl.AccessRequired, - RoleEnum: protobuf.Member.Role, - ourConversationId: window.ConversationController.getOurConversationId(), - change: this.get('groupV2Change'), - }; - }, - getPropsForTimerNotification() { - const timerUpdate = this.get('expirationTimerUpdate'); - if (!timerUpdate) { - return null; - } - - const { expireTimer, fromSync, source, sourceUuid } = timerUpdate; - const timespan = Whisper.ExpirationTimerOptions.getName(expireTimer || 0); - const disabled = !expireTimer; - - const sourceId = ConversationController.ensureContactIds({ - e164: source, - uuid: sourceUuid, - }); - const ourId = ConversationController.getOurConversationId(); - const formattedContact = this.findAndFormatContact(sourceId); - - const basicProps = { - ...formattedContact, - type: 'fromOther', - timespan, - disabled, - }; - - if (fromSync) { - return { - ...basicProps, - type: 'fromSync', - }; - } - if (sourceId && sourceId === ourId) { - return { - ...basicProps, - type: 'fromMe', - }; - } - if (!sourceId) { - return { - ...basicProps, - type: 'fromMember', - }; - } - - return basicProps; - }, - getPropsForSafetyNumberNotification() { - const conversation = this.getConversation(); - const isGroup = conversation && !conversation.isPrivate(); - const identifier = this.get('key_changed'); - - return { - isGroup, - contact: this.findAndFormatContact(identifier), - }; - }, - getPropsForVerificationNotification() { - const type = this.get('verified') ? 'markVerified' : 'markNotVerified'; - const isLocal = this.get('local'); - const identifier = this.get('verifiedChanged'); - - return { - type, - isLocal, - contact: this.findAndFormatContact(identifier), - }; - }, - getPropsForGroupNotification() { - const groupUpdate = this.get('group_update'); - const changes = []; - - if ( - !groupUpdate.avatarUpdated && - !groupUpdate.left && - !groupUpdate.joined && - !groupUpdate.name - ) { - changes.push({ - type: 'general', - }); - } - - if (groupUpdate.joined) { - changes.push({ - type: 'add', - contacts: _.map( - Array.isArray(groupUpdate.joined) - ? groupUpdate.joined - : [groupUpdate.joined], - identifier => this.findAndFormatContact(identifier) - ), - }); - } - - if (groupUpdate.left === 'You') { - changes.push({ - type: 'remove', - isMe: true, - }); - } else if (groupUpdate.left) { - changes.push({ - type: 'remove', - contacts: _.map( - Array.isArray(groupUpdate.left) - ? groupUpdate.left - : [groupUpdate.left], - identifier => this.findAndFormatContact(identifier) - ), - }); - } - - if (groupUpdate.name) { - changes.push({ - type: 'name', - newName: groupUpdate.name, - }); - } - - if (groupUpdate.avatarUpdated) { - changes.push({ - type: 'avatar', - }); - } - - const sourceId = this.getContactId(); - const from = this.findAndFormatContact(sourceId); - - return { - from, - changes, - }; - }, - getPropsForResetSessionNotification() { - // It doesn't need anything right now! - return {}; - }, - getPropsForCallHistory() { - return { - callHistoryDetails: this.get('callHistoryDetails'), - }; - }, - getPropsForProfileChange() { - const change = this.get('profileChange'); - const changedId = this.get('changedId'); - - return { - changedContact: this.findAndFormatContact(changedId), - change, - }; - }, - - getAttachmentsForMessage() { - const sticker = this.get('sticker'); - if (sticker && sticker.data) { - const { data } = sticker; - - // We don't show anything if we're still loading a sticker - if (data.pending || !data.path) { - return []; - } - - return [ - { - ...data, - url: getAbsoluteAttachmentPath(data.path), - }, - ]; - } - - const attachments = this.get('attachments') || []; - return attachments - .filter(attachment => !attachment.error) - .map(attachment => this.getPropsForAttachment(attachment)); - }, - getPropsForMessage() { - const sourceId = this.getContactId(); - const contact = this.findAndFormatContact(sourceId); - const contactModel = this.findContact(sourceId); - - const authorColor = contactModel ? contactModel.getColor() : null; - const authorAvatarPath = contactModel - ? contactModel.getAvatarPath() - : null; - - const expirationLength = this.get('expireTimer') * 1000; - const expireTimerStart = this.get('expirationStartTimestamp'); - const expirationTimestamp = - expirationLength && expireTimerStart - ? expireTimerStart + expirationLength - : null; - - const conversation = this.getConversation(); - const isGroup = conversation && !conversation.isPrivate(); - const conversationAccepted = Boolean( - conversation && conversation.getAccepted() - ); - const sticker = this.get('sticker'); - - const isTapToView = this.isTapToView(); - - const reactions = (this.get('reactions') || []).map(re => { - const c = this.findAndFormatContact(re.fromId); - - return { - emoji: re.emoji, - timestamp: re.timestamp, - from: c, - }; - }); - - const selectedReaction = ( - (this.get('reactions') || []).find( - re => re.fromId === ConversationController.getOurConversationId() - ) || {} - ).emoji; - - return { - text: this.createNonBreakingLastSeparator(this.get('body')), - textPending: this.get('bodyPending'), - id: this.id, - conversationId: this.get('conversationId'), - conversationAccepted, - isSticker: Boolean(sticker), - direction: this.isIncoming() ? 'incoming' : 'outgoing', - timestamp: this.get('sent_at'), - status: this.getMessagePropStatus(), - contact: this.getPropsForEmbeddedContact(), - canReply: this.canReply(), - authorTitle: contact.title, - authorColor, - authorName: contact.name, - authorProfileName: contact.profileName, - authorPhoneNumber: contact.phoneNumber, - conversationType: isGroup ? 'group' : 'direct', - attachments: this.getAttachmentsForMessage(), - previews: this.getPropsForPreview(), - quote: this.getPropsForQuote(), - authorAvatarPath, - isExpired: this.hasExpired, - expirationLength, - expirationTimestamp, - reactions, - selectedReaction, - - isTapToView, - isTapToViewExpired: isTapToView && this.get('isErased'), - isTapToViewError: - isTapToView && this.isIncoming() && this.get('isTapToViewInvalid'), - - deletedForEveryone: this.get('deletedForEveryone') || false, - bodyRanges: this.processBodyRanges(), - }; - }, - - processBodyRanges(bodyRanges = this.get('bodyRanges')) { - if (!bodyRanges) { - return; - } - - // eslint-disable-next-line consistent-return - return ( - bodyRanges - .map(range => { - if (range.mentionUuid) { - const contactID = ConversationController.ensureContactIds({ - uuid: range.mentionUuid, - }); - const conversation = this.findContact(contactID); - - return { - ...range, - conversationID: contactID, - replacementText: conversation.getTitle(), - }; - } - - return null; - }) - .filter(Boolean) - // sorting in a descending order so that we can safely replace the - // positions in the text - .sort((a, b) => b.start - a.start) - ); - }, - - // Dependencies of prop-generation functions - findAndFormatContact(identifier) { - if (!identifier) { - return PLACEHOLDER_CONTACT; - } - - const contactModel = this.findContact(identifier); - if (contactModel) { - return contactModel.format(); - } - - const { format, isValidNumber } = PhoneNumber; - const regionCode = storage.get('regionCode'); - - if (!isValidNumber(identifier, { regionCode })) { - return PLACEHOLDER_CONTACT; - } - - const phoneNumber = format(identifier, { - ourRegionCode: regionCode, - }); - - return { - title: phoneNumber, - phoneNumber, - }; - }, - findContact(identifier) { - return ConversationController.get(identifier); - }, - getConversation() { - return ConversationController.get(this.get('conversationId')); - }, - createNonBreakingLastSeparator(text) { - if (!text) { - return null; - } - - const nbsp = '\xa0'; - const regex = /(\S)( +)(\S+\s*)$/; - return text.replace(regex, (match, start, spaces, end) => { - const newSpaces = - end.length < 12 - ? _.reduce(spaces, accumulator => accumulator + nbsp, '') - : spaces; - return `${start}${newSpaces}${end}`; - }); - }, - isIncoming() { - return this.get('type') === 'incoming'; - }, - getMessagePropStatus() { - const sent = this.get('sent'); - const sentTo = this.get('sent_to') || []; - - if (this.hasErrors()) { - if (sent || sentTo.length > 0) { - return 'partial-sent'; - } - return 'error'; - } - if (!this.isOutgoing()) { - return null; - } - - const readBy = this.get('read_by') || []; - if (storage.get('read-receipt-setting') && readBy.length > 0) { - return 'read'; - } - const delivered = this.get('delivered'); - const deliveredTo = this.get('delivered_to') || []; - if (delivered || deliveredTo.length > 0) { - return 'delivered'; - } - if (sent || sentTo.length > 0) { - return 'sent'; - } - - return 'sending'; - }, - getPropsForEmbeddedContact() { - const contacts = this.get('contact'); - if (!contacts || !contacts.length) { - return null; - } - - const regionCode = storage.get('regionCode'); - const { contactSelector } = Contact; - const contact = contacts[0]; - const firstNumber = - contact.number && contact.number[0] && contact.number[0].value; - - // Would be nice to do this before render, on initial load of message - if (!window.isSignalAccountCheckComplete(firstNumber)) { - window.checkForSignalAccount(firstNumber).then(() => { - this.trigger('change', this); - }); - } - - return contactSelector(contact, { - regionCode, - getAbsoluteAttachmentPath, - signalAccount: window.hasSignalAccount(firstNumber) - ? firstNumber - : null, - }); - }, - getPropsForAttachment(attachment) { - if (!attachment) { - return null; - } - - const { path, pending, flags, size, screenshot, thumbnail } = attachment; - - return { - ...attachment, - fileSize: size ? filesize(size) : null, - isVoiceMessage: - flags && - // eslint-disable-next-line no-bitwise - flags & textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE, - pending, - url: path ? getAbsoluteAttachmentPath(path) : null, - screenshot: screenshot - ? { - ...screenshot, - url: getAbsoluteAttachmentPath(screenshot.path), - } - : null, - thumbnail: thumbnail - ? { - ...thumbnail, - url: getAbsoluteAttachmentPath(thumbnail.path), - } - : null, - }; - }, - getPropsForPreview() { - const previews = this.get('preview') || []; - - return previews.map(preview => ({ - ...preview, - isStickerPack: window.Signal.LinkPreviews.isStickerPack(preview.url), - domain: window.Signal.LinkPreviews.getDomain(preview.url), - image: preview.image ? this.getPropsForAttachment(preview.image) : null, - })); - }, - getPropsForQuote() { - const quote = this.get('quote'); - if (!quote) { - return null; - } - - const { format } = PhoneNumber; - const regionCode = storage.get('regionCode'); - - const { - author, - authorUuid, - bodyRanges, - id: sentAt, - referencedMessageNotFound, - text, - } = quote; - - const contact = - (author || authorUuid) && - ConversationController.get( - ConversationController.ensureContactIds({ - e164: author, - uuid: authorUuid, - }) - ); - const authorColor = contact ? contact.getColor() : 'grey'; - - const authorPhoneNumber = format(author, { - ourRegionCode: regionCode, - }); - const authorProfileName = contact ? contact.getProfileName() : null; - const authorName = contact ? contact.get('name') : null; - const authorTitle = contact ? contact.getTitle() : null; - const isFromMe = contact ? contact.isMe() : false; - const firstAttachment = quote.attachments && quote.attachments[0]; - - return { - text: this.createNonBreakingLastSeparator(text), - attachment: firstAttachment - ? this.processQuoteAttachment(firstAttachment) - : null, - bodyRanges: this.processBodyRanges(bodyRanges), - isFromMe, - sentAt, - authorId: author, - authorPhoneNumber, - authorProfileName, - authorTitle, - authorName, - authorColor, - referencedMessageNotFound, - onClick: () => this.trigger('scroll-to-message'), - }; - }, - getStatus(identifier) { - const conversation = ConversationController.get(identifier); - - if (!conversation) { - return null; - } - - const e164 = conversation.get('e164'); - const uuid = conversation.get('uuid'); - const conversationId = conversation.get('id'); - - const readBy = this.get('read_by') || []; - if (includesAny(readBy, conversationId, e164, uuid)) { - return 'read'; - } - const deliveredTo = this.get('delivered_to') || []; - if (includesAny(deliveredTo, conversationId, e164, uuid)) { - return 'delivered'; - } - const sentTo = this.get('sent_to') || []; - if (includesAny(sentTo, conversationId, e164, uuid)) { - return 'sent'; - } - - return null; - }, - processQuoteAttachment(attachment) { - const { thumbnail } = attachment; - const path = - thumbnail && - thumbnail.path && - getAbsoluteAttachmentPath(thumbnail.path); - const objectUrl = thumbnail && thumbnail.objectUrl; - - const thumbnailWithObjectUrl = - !path && !objectUrl - ? null - : { ...(attachment.thumbnail || {}), objectUrl: path || objectUrl }; - - return { - ...attachment, - isVoiceMessage: Signal.Types.Attachment.isVoiceMessage(attachment), - thumbnail: thumbnailWithObjectUrl, - }; - }, - - getNotificationData() /* : { text: string, emoji?: string } */ { - if (this.isUnsupportedMessage()) { - return { text: i18n('message--getDescription--unsupported-message') }; - } - - if (this.isProfileChange()) { - const change = this.get('profileChange'); - const changedId = this.get('changedId'); - const changedContact = this.findAndFormatContact(changedId); - - return { - text: Signal.Util.getStringForProfileChange( - change, - changedContact, - i18n - ), - }; - } - - if (this.isGroupV2Change()) { - const { protobuf } = window.textsecure; - const change = this.get('groupV2Change'); - - const lines = window.Signal.GroupChange.renderChange(change, { - AccessControlEnum: protobuf.AccessControl.AccessRequired, - i18n: window.i18n, - ourConversationId: window.ConversationController.getOurConversationId(), - renderContact: conversationId => { - const conversation = window.ConversationController.get( - conversationId - ); - return conversation - ? conversation.getTitle() - : window.i18n('unknownUser'); - }, - renderString: (key, i18n, placeholders) => i18n(key, placeholders), - RoleEnum: protobuf.Member.Role, - }); - - return { text: lines.join(' ') }; - } - - const attachments = this.get('attachments') || []; - - if (this.isTapToView()) { - if (this.isErased()) { - return { text: i18n('message--getDescription--disappearing-media') }; - } - - if (Attachment.isImage(attachments)) { - return { - text: i18n('message--getDescription--disappearing-photo'), - emoji: '📷', - }; - } - if (Attachment.isVideo(attachments)) { - return { - text: i18n('message--getDescription--disappearing-video'), - emoji: '🎥', - }; - } - // There should be an image or video attachment, but we have a fallback just in - // case. - return { text: i18n('mediaMessage'), emoji: '📎' }; - } - - if (this.isGroupUpdate()) { - const groupUpdate = this.get('group_update'); - const fromContact = this.getContact(); - const messages = []; - - if (groupUpdate.left === 'You') { - return { text: i18n('youLeftTheGroup') }; - } - if (groupUpdate.left) { - return { - text: i18n('leftTheGroup', [ - this.getNameForNumber(groupUpdate.left), - ]), - }; - } - - if (!fromContact) { - return { text: '' }; - } - - if (fromContact.isMe()) { - messages.push(i18n('youUpdatedTheGroup')); - } else { - messages.push(i18n('updatedTheGroup', [fromContact.getTitle()])); - } - - if (groupUpdate.joined && groupUpdate.joined.length) { - const joinedContacts = _.map(groupUpdate.joined, item => - ConversationController.getOrCreate(item, 'private') - ); - const joinedWithoutMe = joinedContacts.filter( - contact => !contact.isMe() - ); - - if (joinedContacts.length > 1) { - messages.push( - i18n('multipleJoinedTheGroup', [ - _.map(joinedWithoutMe, contact => contact.getTitle()).join( - ', ' - ), - ]) - ); - - if (joinedWithoutMe.length < joinedContacts.length) { - messages.push(i18n('youJoinedTheGroup')); - } - } else { - const joinedContact = ConversationController.getOrCreate( - groupUpdate.joined[0], - 'private' - ); - if (joinedContact.isMe()) { - messages.push(i18n('youJoinedTheGroup')); - } else { - messages.push( - i18n('joinedTheGroup', [joinedContacts[0].getTitle()]) - ); - } - } - } - - if (groupUpdate.name) { - messages.push(i18n('titleIsNow', [groupUpdate.name])); - } - if (groupUpdate.avatarUpdated) { - messages.push(i18n('updatedGroupAvatar')); - } - - return { text: messages.join(' ') }; - } - if (this.isEndSession()) { - return { text: i18n('sessionEnded') }; - } - if (this.isIncoming() && this.hasErrors()) { - return { text: i18n('incomingError') }; - } - - const body = (this.get('body') || '').trim(); - - if (attachments.length) { - // This should never happen but we want to be extra-careful. - const attachment = attachments[0] || {}; - const { contentType } = attachment; - - if (contentType === MIME.IMAGE_GIF) { - return { - text: body || i18n('message--getNotificationText--gif'), - emoji: '🎡', - }; - } - if (Attachment.isImage(attachments)) { - return { - text: body || i18n('message--getNotificationText--photo'), - emoji: '📷', - }; - } - if (Attachment.isVideo(attachments)) { - return { - text: body || i18n('message--getNotificationText--video'), - emoji: '🎥', - }; - } - if (Attachment.isVoiceMessage(attachment)) { - return { - text: body || i18n('message--getNotificationText--voice-message'), - emoji: '🎤', - }; - } - if (Attachment.isAudio(attachments)) { - return { - text: body || i18n('message--getNotificationText--audio-message'), - emoji: '🔈', - }; - } - return { - text: body || i18n('message--getNotificationText--file'), - emoji: '📎', - }; - } - - const stickerData = this.get('sticker'); - if (stickerData) { - try { - const sticker = Signal.Stickers.getSticker( - stickerData.packId, - stickerData.stickerId - ); - const { emoji } = sticker || {}; - if (!emoji) { - window.log.warn('Unable to get emoji for sticker'); - } - return { - text: i18n('message--getNotificationText--stickers'), - emoji, - }; - } catch (error) { - window.log.error( - 'getNotificationData: sticker fetch failed', - error && error.stack ? error.stack : error - ); - return { - text: i18n('message--getNotificationText--stickers'), - }; - } - } - - if (this.isCallHistory()) { - return { - text: window.Signal.Components.getCallingNotificationText( - this.get('callHistoryDetails'), - window.i18n - ), - }; - } - if (this.isExpirationTimerUpdate()) { - const { expireTimer } = this.get('expirationTimerUpdate'); - if (!expireTimer) { - return { text: i18n('disappearingMessagesDisabled') }; - } - - return { - text: i18n('timerSetTo', [ - Whisper.ExpirationTimerOptions.getAbbreviated(expireTimer || 0), - ]), - }; - } - - if (this.isKeyChange()) { - const identifier = this.get('key_changed'); - const conversation = this.findContact(identifier); - return { - text: i18n('safetyNumberChangedGroup', [ - conversation ? conversation.getTitle() : null, - ]), - }; - } - const contacts = this.get('contact'); - if (contacts && contacts.length) { - return { text: Contact.getName(contacts[0]), emoji: '👤' }; - } - - if (body) { - return { text: body }; - } - - return { text: '' }; - }, - - getNotificationText() /* : string */ { - const { text, emoji } = this.getNotificationData(); - - let modifiedText = text; - - const hasMentions = Boolean(this.get('bodyRanges')); - - if (hasMentions) { - const bodyRanges = this.processBodyRanges(); - modifiedText = getTextWithMentions(bodyRanges, modifiedText); - } - - // Linux emoji support is mixed, so we disable it. (Note that this doesn't touch - // the `text`, which can contain emoji.) - const shouldIncludeEmoji = Boolean(emoji) && !Signal.OS.isLinux(); - if (shouldIncludeEmoji) { - return i18n('message--getNotificationText--text-with-emoji', { - text: modifiedText, - emoji, - }); - } - return modifiedText; - }, - - // General - idForLogging() { - const source = this.getSource(); - const device = this.getSourceDevice(); - const timestamp = this.get('sent_at'); - - return `${source}.${device} ${timestamp}`; - }, - defaults() { - return { - timestamp: new Date().getTime(), - attachments: [], - }; - }, - validate(attributes) { - const required = ['conversationId', 'received_at', 'sent_at']; - const missing = _.filter(required, attr => !attributes[attr]); - if (missing.length) { - window.log.warn(`Message missing attributes: ${missing}`); - } - }, - isUnread() { - return !!this.get('unread'); - }, - merge(model) { - const attributes = model.attributes || model; - this.set(attributes); - }, - getNameForNumber(number) { - const conversation = ConversationController.get(number); - if (!conversation) { - return number; - } - return conversation.getTitle(); - }, - onDestroy() { - this.cleanup(); - }, - async cleanup() { - const { messageDeleted } = window.reduxActions.conversations; - messageDeleted(this.id, this.get('conversationId')); - MessageController.unregister(this.id); - this.unload(); - await this.deleteData(); - }, - async deleteData() { - await deleteExternalMessageFiles(this.attributes); - - const sticker = this.get('sticker'); - if (!sticker) { - return; - } - - const { packId } = sticker; - if (packId) { - await deletePackReference(this.id, packId); - } - }, - isTapToView() { - return Boolean(this.get('isViewOnce') || this.get('messageTimer')); - }, - isValidTapToView() { - const body = this.get('body'); - if (body) { - return false; - } - - const attachments = this.get('attachments'); - if (!attachments || attachments.length !== 1) { - return false; - } - - const firstAttachment = attachments[0]; - if ( - !GoogleChrome.isImageTypeSupported(firstAttachment.contentType) && - !GoogleChrome.isVideoTypeSupported(firstAttachment.contentType) - ) { - return false; - } - - const quote = this.get('quote'); - const sticker = this.get('sticker'); - const contact = this.get('contact'); - const preview = this.get('preview'); - - if ( - quote || - sticker || - (contact && contact.length > 0) || - (preview && preview.length > 0) - ) { - return false; - } - - return true; - }, - async markViewed(options) { - const { fromSync } = options || {}; - - if (!this.isValidTapToView()) { - window.log.warn( - `markViewed: Message ${this.idForLogging()} is not a valid tap to view message!` - ); - return; - } - if (this.isErased()) { - window.log.warn( - `markViewed: Message ${this.idForLogging()} is already erased!` - ); - return; - } - - if (this.get('unread')) { - await this.markRead(); - } - - await this.eraseContents(); - - if (!fromSync) { - const sender = this.getSource(); - const senderUuid = this.getSourceUuid(); - const timestamp = this.get('sent_at'); - const ourNumber = textsecure.storage.user.getNumber(); - const ourUuid = textsecure.storage.user.getUuid(); - const { wrap, sendOptions } = ConversationController.prepareForSend( - ourNumber || ourUuid, - { - syncMessage: true, - } - ); - - await wrap( - textsecure.messaging.syncViewOnceOpen( - sender, - senderUuid, - timestamp, - sendOptions - ) - ); - } - }, - isErased() { - return Boolean(this.get('isErased')); - }, - async eraseContents(additionalProperties = {}, shouldPersist = true) { - if (this.get('isErased')) { - return; - } - - window.log.info(`Erasing data for message ${this.idForLogging()}`); - - try { - await this.deleteData(); - } catch (error) { - window.log.error( - `Error erasing data for message ${this.idForLogging()}:`, - error && error.stack ? error.stack : error - ); - } - - this.set({ - isErased: true, - body: '', - attachments: [], - quote: null, - contact: [], - sticker: null, - preview: [], - ...additionalProperties, - }); - this.trigger('content-changed'); - - if (shouldPersist) { - await window.Signal.Data.saveMessage(this.attributes, { - Message: Whisper.Message, - }); - } - }, - isEmpty() { - // Core message types - we check for all four because they can each stand alone - const hasBody = Boolean(this.get('body')); - const hasAttachment = (this.get('attachments') || []).length > 0; - const hasEmbeddedContact = (this.get('contact') || []).length > 0; - const isSticker = Boolean(this.get('sticker')); - - // Rendered sync messages - const isCallHistory = this.isCallHistory(); - const isGroupUpdate = this.isGroupUpdate(); - const isGroupV2Change = this.isGroupV2Change(); - const isEndSession = this.isEndSession(); - const isExpirationTimerUpdate = this.isExpirationTimerUpdate(); - const isVerifiedChange = this.isVerifiedChange(); - - // Placeholder messages - const isUnsupportedMessage = this.isUnsupportedMessage(); - const isTapToView = this.isTapToView(); - - // Errors - const hasErrors = this.hasErrors(); - - // Locally-generated notifications - const isKeyChange = this.isKeyChange(); - const isMessageHistoryUnsynced = this.isMessageHistoryUnsynced(); - const isProfileChange = this.isProfileChange(); - - // Note: not all of these message types go through message.handleDataMessage - - const hasSomethingToDisplay = - // Core message types - hasBody || - hasAttachment || - hasEmbeddedContact || - isSticker || - // Rendered sync messages - isCallHistory || - isGroupUpdate || - isGroupV2Change || - isEndSession || - isExpirationTimerUpdate || - isVerifiedChange || - // Placeholder messages - isUnsupportedMessage || - isTapToView || - // Errors - hasErrors || - // Locally-generated notifications - isKeyChange || - isMessageHistoryUnsynced || - isProfileChange; - - return !hasSomethingToDisplay; - }, - unload() { - if (this.quotedMessage) { - this.quotedMessage = null; - } - }, - onExpired() { - this.hasExpired = true; - }, - isUnidentifiedDelivery(contactId, lookup) { - if (this.isIncoming()) { - return this.get('unidentifiedDeliveryReceived'); - } - - return Boolean(lookup[contactId]); - }, - getSource() { - if (this.isIncoming()) { - return this.get('source'); - } - - return this.OUR_NUMBER; - }, - getSourceDevice() { - if (this.isIncoming()) { - return this.get('sourceDevice'); - } - - return window.textsecure.storage.user.getDeviceId(); - }, - getSourceUuid() { - if (this.isIncoming()) { - return this.get('sourceUuid'); - } - - return this.OUR_UUID; - }, - getContactId() { - const source = this.getSource(); - const sourceUuid = this.getSourceUuid(); - - if (!source && !sourceUuid) { - return ConversationController.getOurConversationId(); - } - - return ConversationController.ensureContactIds({ - e164: source, - uuid: sourceUuid, - }); - }, - getContact() { - const id = this.getContactId(); - return ConversationController.get(id); - }, - isOutgoing() { - return this.get('type') === 'outgoing'; - }, - hasErrors() { - return _.size(this.get('errors')) > 0; - }, - async saveErrors(providedErrors, options = {}) { - const { skipSave } = options; - - let errors = providedErrors; - - if (!(errors instanceof Array)) { - errors = [errors]; - } - errors.forEach(e => { - window.log.error( - 'Message.saveErrors:', - e && e.reason ? e.reason : null, - e && e.stack ? e.stack : e - ); - }); - errors = errors.map(e => { - if ( - e.constructor === Error || - e.constructor === TypeError || - e.constructor === ReferenceError - ) { - return _.pick(e, 'name', 'message', 'code', 'number', 'reason'); - } - return e; - }); - errors = errors.concat(this.get('errors') || []); - - this.set({ errors }); - - if (!skipSave) { - await window.Signal.Data.saveMessage(this.attributes, { - Message: Whisper.Message, - }); - } - }, - async markRead(readAt, options = {}) { - const { skipSave } = options; - - this.unset('unread'); - - if (this.get('expireTimer') && !this.get('expirationStartTimestamp')) { - const expirationStartTimestamp = Math.min( - Date.now(), - readAt || Date.now() - ); - this.set({ expirationStartTimestamp }); - } - - Whisper.Notifications.removeBy({ messageId: this.id }); - - if (!skipSave) { - await window.Signal.Data.saveMessage(this.attributes, { - Message: Whisper.Message, - }); - } - }, - isExpiring() { - return this.get('expireTimer') && this.get('expirationStartTimestamp'); - }, - isExpired() { - return this.msTilExpire() <= 0; - }, - msTilExpire() { - if (!this.isExpiring()) { - return Infinity; - } - const now = Date.now(); - const start = this.get('expirationStartTimestamp'); - const delta = this.get('expireTimer') * 1000; - let msFromNow = start + delta - now; - if (msFromNow < 0) { - msFromNow = 0; - } - return msFromNow; - }, - async setToExpire(force = false, options) { - const { skipSave } = options || {}; - - if (this.isExpiring() && (force || !this.get('expires_at'))) { - const start = this.get('expirationStartTimestamp'); - const delta = this.get('expireTimer') * 1000; - const expiresAt = start + delta; - - this.set({ expires_at: expiresAt }); - const id = this.get('id'); - if (id && !skipSave) { - await window.Signal.Data.saveMessage(this.attributes, { - Message: Whisper.Message, - }); - } - - window.log.info('Set message expiration', { - expiresAt, - sentAt: this.get('sent_at'), - }); - } - }, - getIncomingContact() { - if (!this.isIncoming()) { - return null; - } - const source = this.get('source'); - if (!source) { - return null; - } - - return ConversationController.getOrCreate(source, 'private'); - }, - getQuoteContact() { - const quote = this.get('quote'); - if (!quote) { - return null; - } - const { author } = quote; - if (!author) { - return null; - } - - return ConversationController.get(author); - }, - - // Send infrastructure - // One caller today: event handler for the 'Retry Send' entry in triple-dot menu - async retrySend() { - if (!textsecure.messaging) { - window.log.error('retrySend: Cannot retry since we are offline!'); - return null; - } - - this.set({ errors: null }); - - const conversation = this.getConversation(); - const intendedRecipients = (this.get('recipients') || []) - .map(identifier => ConversationController.getConversationId(identifier)) - .filter(Boolean); - const successfulRecipients = (this.get('sent_to') || []) - .map(identifier => ConversationController.getConversationId(identifier)) - .filter(Boolean); - const currentRecipients = conversation - .getRecipients() - .map(identifier => ConversationController.getConversationId(identifier)) - .filter(Boolean); - - const profileKey = conversation.get('profileSharing') - ? storage.get('profileKey') - : null; - - // Determine retry recipients and get their most up-to-date addressing information - let recipients = _.intersection(intendedRecipients, currentRecipients); - recipients = _.without(recipients, successfulRecipients).map(id => { - const c = ConversationController.get(id); - return c.getSendTarget(); - }); - - if (!recipients.length) { - window.log.warn('retrySend: Nobody to send to!'); - - return window.Signal.Data.saveMessage(this.attributes, { - Message: Whisper.Message, - }); - } - - const attachmentsWithData = await Promise.all( - (this.get('attachments') || []).map(loadAttachmentData) - ); - const { body, attachments } = Whisper.Message.getLongMessageAttachment({ - body: this.get('body'), - attachments: attachmentsWithData, - now: this.get('sent_at'), - }); - - const quoteWithData = await loadQuoteData(this.get('quote')); - const previewWithData = await loadPreviewData(this.get('preview')); - const stickerWithData = await loadStickerData(this.get('sticker')); - - // Special-case the self-send case - we send only a sync message - if ( - recipients.length === 1 && - (recipients[0] === this.OUR_NUMBER || recipients[0] === this.OUR_UUID) - ) { - const [identifier] = recipients; - const dataMessage = await textsecure.messaging.getMessageProto( - identifier, - body, - attachments, - quoteWithData, - previewWithData, - stickerWithData, - null, - this.get('sent_at'), - this.get('expireTimer'), - profileKey - ); - return this.sendSyncMessageOnly(dataMessage); - } - - let promise; - const options = conversation.getSendOptions(); - - if (conversation.isPrivate()) { - const [identifer] = recipients; - promise = textsecure.messaging.sendMessageToIdentifier( - identifer, - body, - attachments, - quoteWithData, - previewWithData, - stickerWithData, - null, - this.get('sent_at'), - this.get('expireTimer'), - profileKey, - options - ); - } else { - // Because this is a partial group send, we manually construct the request like - // sendMessageToGroup does. - - const groupV2 = conversation.getGroupV2Info(); - - promise = textsecure.messaging.sendMessage( - { - recipients, - body, - timestamp: this.get('sent_at'), - attachments, - quote: quoteWithData, - preview: previewWithData, - sticker: stickerWithData, - expireTimer: this.get('expireTimer'), - profileKey, - groupV2, - group: groupV2 - ? null - : { - id: this.getConversation().get('groupId'), - type: textsecure.protobuf.GroupContext.Type.DELIVER, - }, - }, - options - ); - } - - return this.send(conversation.wrapSend(promise)); - }, - isReplayableError(e) { - return ( - e.name === 'MessageError' || - e.name === 'OutgoingMessageError' || - e.name === 'SendMessageNetworkError' || - e.name === 'SignedPreKeyRotationError' || - e.name === 'OutgoingIdentityKeyError' - ); - }, - canReply() { - const isAccepted = this.getConversation().getAccepted(); - const errors = this.get('errors'); - const isOutgoing = this.get('type') === 'outgoing'; - const numDelivered = this.get('delivered'); - - // Case 1: We cannot reply if we have accepted the message request - if (!isAccepted) { - return false; - } - - // Case 2: We cannot reply if this message is deleted for everyone - if (this.get('deletedForEveryone')) { - return false; - } - - // Case 3: We can reply if this is outgoing and delievered to at least one recipient - if (isOutgoing && numDelivered > 0) { - return true; - } - - // Case 4: We can reply if there are no errors - if (!errors || (errors && errors.length === 0)) { - return true; - } - - // Case 5: default - return false; - }, - - // Called when the user ran into an error with a specific user, wants to send to them - // One caller today: ConversationView.forceSend() - async resend(identifier) { - const error = this.removeOutgoingErrors(identifier); - if (!error) { - window.log.warn('resend: requested number was not present in errors'); - return null; - } - - const profileKey = null; - const attachmentsWithData = await Promise.all( - (this.get('attachments') || []).map(loadAttachmentData) - ); - const { body, attachments } = Whisper.Message.getLongMessageAttachment({ - body: this.get('body'), - attachments: attachmentsWithData, - now: this.get('sent_at'), - }); - - const quoteWithData = await loadQuoteData(this.get('quote')); - const previewWithData = await loadPreviewData(this.get('preview')); - const stickerWithData = await loadStickerData(this.get('sticker')); - - // Special-case the self-send case - we send only a sync message - if (identifier === this.OUR_NUMBER || identifier === this.OUR_UUID) { - const dataMessage = await textsecure.messaging.getMessageProto( - identifier, - body, - attachments, - quoteWithData, - previewWithData, - stickerWithData, - null, - this.get('sent_at'), - this.get('expireTimer'), - profileKey - ); - return this.sendSyncMessageOnly(dataMessage); - } - - const { wrap, sendOptions } = ConversationController.prepareForSend( - identifier - ); - const promise = textsecure.messaging.sendMessageToIdentifier( - identifier, - body, - attachments, - quoteWithData, - previewWithData, - stickerWithData, - null, - this.get('sent_at'), - this.get('expireTimer'), - profileKey, - sendOptions - ); - - return this.send(wrap(promise)); - }, - removeOutgoingErrors(incomingIdentifier) { - const incomingConversationId = ConversationController.getConversationId( - incomingIdentifier - ); - const errors = _.partition( - this.get('errors'), - e => - ConversationController.getConversationId(e.identifer || e.number) === - incomingConversationId && - (e.name === 'MessageError' || - e.name === 'OutgoingMessageError' || - e.name === 'SendMessageNetworkError' || - e.name === 'SignedPreKeyRotationError' || - e.name === 'OutgoingIdentityKeyError') - ); - this.set({ errors: errors[1] }); - return errors[0][0]; - }, - - send(promise) { - this.trigger('pending'); - return promise - .then(async result => { - this.trigger('done'); - - // This is used by sendSyncMessage, then set to null - if (result.dataMessage) { - this.set({ dataMessage: result.dataMessage }); - } - - const sentTo = this.get('sent_to') || []; - this.set({ - sent_to: _.union(sentTo, result.successfulIdentifiers), - sent: true, - expirationStartTimestamp: Date.now(), - unidentifiedDeliveries: result.unidentifiedDeliveries, - }); - - await window.Signal.Data.saveMessage(this.attributes, { - Message: Whisper.Message, - }); - - this.trigger('sent', this); - this.sendSyncMessage(); - }) - .catch(result => { - this.trigger('done'); - - if (result.dataMessage) { - this.set({ dataMessage: result.dataMessage }); - } - - let promises = []; - - // If we successfully sent to a user, we can remove our unregistered flag. - result.successfulIdentifiers.forEach(identifier => { - const c = ConversationController.get(identifier); - if (c && c.isEverUnregistered()) { - c.setRegistered(); - } - }); - - if (result instanceof Error) { - this.saveErrors(result); - if (result.name === 'SignedPreKeyRotationError') { - promises.push(getAccountManager().rotateSignedPreKey()); - } else if (result.name === 'OutgoingIdentityKeyError') { - const c = ConversationController.get(result.number); - promises.push(c.getProfiles()); - } - } else { - if (result.successfulIdentifiers.length > 0) { - const sentTo = this.get('sent_to') || []; - - // If we just found out that we couldn't send to a user because they are no - // longer registered, we will update our unregistered flag. In groups we - // will not event try to send to them for 6 hours. And we will never try - // to fetch them on startup again. - // The way to discover registration once more is: - // 1) any attempt to send to them in 1:1 conversation - // 2) the six-hour time period has passed and we send in a group again - const unregisteredUserErrors = _.filter( - result.errors, - error => error.name === 'UnregisteredUserError' - ); - unregisteredUserErrors.forEach(error => { - const c = ConversationController.get(error.identifier); - if (c) { - c.setUnregistered(); - } - }); - - // In groups, we don't treat unregistered users as a user-visible - // error. The message will look successful, but the details - // screen will show that we didn't send to these unregistered users. - const filteredErrors = _.reject( - result.errors, - error => error.name === 'UnregisteredUserError' - ); - - // We don't start the expiration timer if there are real errors - // left after filtering out all of the unregistered user errors. - const expirationStartTimestamp = filteredErrors.length - ? null - : Date.now(); - - this.saveErrors(filteredErrors); - - this.set({ - sent_to: _.union(sentTo, result.successfulIdentifiers), - sent: true, - expirationStartTimestamp, - unidentifiedDeliveries: result.unidentifiedDeliveries, - }); - promises.push(this.sendSyncMessage()); - } else { - this.saveErrors(result.errors); - } - promises = promises.concat( - _.map(result.errors, error => { - if (error.name === 'OutgoingIdentityKeyError') { - const c = ConversationController.get( - error.identifer || error.number - ); - promises.push(c.getProfiles()); - } - }) - ); - } - - this.trigger('send-error', this.get('errors')); - - return Promise.all(promises); - }); - }, - - async sendSyncMessageOnly(dataMessage) { - const conv = this.getConversation(); - this.set({ dataMessage }); - - try { - this.set({ - // These are the same as a normal send() - sent_to: [conv.getSendTarget()], - sent: true, - expirationStartTimestamp: Date.now(), - }); - const result = await this.sendSyncMessage(); - this.set({ - // We have to do this afterward, since we didn't have a previous send! - unidentifiedDeliveries: result ? result.unidentifiedDeliveries : null, - - // These are unique to a Note to Self message - immediately read/delivered - delivered_to: [ConversationController.getOurConversationId()], - read_by: [ConversationController.getOurConversationId()], - }); - } catch (result) { - const errors = (result && result.errors) || [ - new Error('Unknown error'), - ]; - this.set({ errors }); - } finally { - await window.Signal.Data.saveMessage(this.attributes, { - Message: Whisper.Message, - }); - this.trigger('done'); - - const errors = this.get('errors'); - if (errors) { - this.trigger('send-error', errors); - } else { - this.trigger('sent'); - } - } - }, - - sendSyncMessage() { - const ourNumber = textsecure.storage.user.getNumber(); - const ourUuid = textsecure.storage.user.getUuid(); - const { wrap, sendOptions } = ConversationController.prepareForSend( - ourUuid || ourNumber, - { - syncMessage: true, - } - ); - - this.syncPromise = this.syncPromise || Promise.resolve(); - const next = () => { - const dataMessage = this.get('dataMessage'); - if (!dataMessage) { - return Promise.resolve(); - } - const isUpdate = Boolean(this.get('synced')); - const conv = this.getConversation(); - - return wrap( - textsecure.messaging.sendSyncMessage( - dataMessage, - this.get('sent_at'), - conv.get('e164'), - conv.get('uuid'), - this.get('expirationStartTimestamp'), - this.get('sent_to'), - this.get('unidentifiedDeliveries'), - isUpdate, - sendOptions - ) - ).then(result => { - this.set({ - synced: true, - dataMessage: null, - }); - return window.Signal.Data.saveMessage(this.attributes, { - Message: Whisper.Message, - }).then(() => result); - }); - }; - - this.syncPromise = this.syncPromise.then(next, next); - - return this.syncPromise; - }, - - // Receive logic - async queueAttachmentDownloads() { - const attachmentsToQueue = this.get('attachments') || []; - const messageId = this.id; - let count = 0; - let bodyPending; - - window.log.info( - `Queueing ${ - attachmentsToQueue.length - } attachment downloads for message ${this.idForLogging()}` - ); - - const [longMessageAttachments, normalAttachments] = _.partition( - attachmentsToQueue, - attachment => - attachment.contentType === Whisper.Message.LONG_MESSAGE_CONTENT_TYPE - ); - - if (longMessageAttachments.length > 1) { - window.log.error( - `Received more than one long message attachment in message ${this.idForLogging()}` - ); - } - - window.log.info( - `Queueing ${ - longMessageAttachments.length - } long message attachment downloads for message ${this.idForLogging()}` - ); - - if (longMessageAttachments.length > 0) { - count += 1; - bodyPending = true; - await window.Signal.AttachmentDownloads.addJob( - longMessageAttachments[0], - { - messageId, - type: 'long-message', - index: 0, - } - ); - } - - window.log.info( - `Queueing ${ - normalAttachments.length - } normal attachment downloads for message ${this.idForLogging()}` - ); - const attachments = await Promise.all( - normalAttachments.map((attachment, index) => { - count += 1; - return window.Signal.AttachmentDownloads.addJob(attachment, { - messageId, - type: 'attachment', - index, - }); - }) - ); - - const previewsToQueue = this.get('preview') || []; - window.log.info( - `Queueing ${ - previewsToQueue.length - } preview attachment downloads for message ${this.idForLogging()}` - ); - const preview = await Promise.all( - previewsToQueue.map(async (item, index) => { - if (!item.image) { - return item; - } - - count += 1; - return { - ...item, - image: await window.Signal.AttachmentDownloads.addJob(item.image, { - messageId, - type: 'preview', - index, - }), - }; - }) - ); - - const contactsToQueue = this.get('contact') || []; - window.log.info( - `Queueing ${ - contactsToQueue.length - } contact attachment downloads for message ${this.idForLogging()}` - ); - const contact = await Promise.all( - contactsToQueue.map(async (item, index) => { - if (!item.avatar || !item.avatar.avatar) { - return item; - } - - count += 1; - return { - ...item, - avatar: { - ...item.avatar, - avatar: await window.Signal.AttachmentDownloads.addJob( - item.avatar.avatar, - { - messageId, - type: 'contact', - index, - } - ), - }, - }; - }) - ); - - let quote = this.get('quote'); - const quoteAttachmentsToQueue = - quote && quote.attachments ? quote.attachments : []; - window.log.info( - `Queueing ${ - quoteAttachmentsToQueue.length - } quote attachment downloads for message ${this.idForLogging()}` - ); - if (quoteAttachmentsToQueue.length > 0) { - quote = { - ...quote, - attachments: await Promise.all( - (quote.attachments || []).map(async (item, index) => { - // If we already have a path, then we copied this image from the quoted - // message and we don't need to download the attachment. - if (!item.thumbnail || item.thumbnail.path) { - return item; - } - - count += 1; - return { - ...item, - thumbnail: await window.Signal.AttachmentDownloads.addJob( - item.thumbnail, - { - messageId, - type: 'quote', - index, - } - ), - }; - }) - ), - }; - } - - let sticker = this.get('sticker'); - if (sticker) { - window.log.info( - `Queueing sticker download for message ${this.idForLogging()}` - ); - count += 1; - const { packId, stickerId, packKey } = sticker; - - const status = getStickerPackStatus(packId); - let data; - - if (status && (status === 'downloaded' || status === 'installed')) { - try { - const copiedSticker = await copyStickerToAttachments( - packId, - stickerId - ); - data = { - ...copiedSticker, - contentType: 'image/webp', - }; - } catch (error) { - window.log.error( - `Problem copying sticker (${packId}, ${stickerId}) to attachments:`, - error && error.stack ? error.stack : error - ); - } - } - if (!data) { - data = await window.Signal.AttachmentDownloads.addJob(sticker.data, { - messageId, - type: 'sticker', - index: 0, - }); - } - if (!status) { - // Save the packId/packKey for future download/install - savePackMetadata(packId, packKey, { messageId }); - } else { - await addStickerPackReference(messageId, packId); - } - - sticker = { - ...sticker, - packId, - data, - }; - } - - window.log.info( - `Queued ${count} total attachment downloads for message ${this.idForLogging()}` - ); - - if (count > 0) { - this.set({ - bodyPending, - attachments, - preview, - contact, - quote, - sticker, - }); - - return true; - } - - return false; - }, - - async copyFromQuotedMessage(message) { - const { quote } = message; - if (!quote) { - return message; - } - - 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 messageAuthorId = item.getContactId(); - - return authorConversationId === messageAuthorId; - }); - - if (!found) { - quote.referencedMessageNotFound = true; - return message; - } - if (found.isTapToView()) { - quote.text = null; - quote.attachments = [ - { - contentType: 'image/jpeg', - }, - ]; - - return message; - } - - const queryMessage = MessageController.register(found.id, found); - quote.text = queryMessage.get('body'); - if (firstAttachment) { - firstAttachment.thumbnail = null; - } - - if ( - !firstAttachment || - (!GoogleChrome.isImageTypeSupported(firstAttachment.contentType) && - !GoogleChrome.isVideoTypeSupported(firstAttachment.contentType)) - ) { - return message; - } - - try { - if ( - queryMessage.get('schemaVersion') < - TypedMessage.VERSION_NEEDED_FOR_DISPLAY - ) { - const upgradedMessage = await upgradeMessageSchema( - queryMessage.attributes - ); - queryMessage.set(upgradedMessage); - await window.Signal.Data.saveMessage(upgradedMessage, { - Message: Whisper.Message, - }); - } - } catch (error) { - window.log.error( - 'Problem upgrading message quoted message from database', - Errors.toLogFormat(error) - ); - return message; - } - - const queryAttachments = queryMessage.get('attachments') || []; - if (queryAttachments.length > 0) { - const queryFirst = queryAttachments[0]; - const { thumbnail } = queryFirst; - - if (thumbnail && thumbnail.path) { - firstAttachment.thumbnail = { - ...thumbnail, - copied: true, - }; - } - } - - const queryPreview = queryMessage.get('preview') || []; - if (queryPreview.length > 0) { - const queryFirst = queryPreview[0]; - const { image } = queryFirst; - - if (image && image.path) { - firstAttachment.thumbnail = { - ...image, - copied: true, - }; - } - } - - const sticker = queryMessage.get('sticker'); - if (sticker && sticker.data && sticker.data.path) { - firstAttachment.thumbnail = { - ...sticker.data, - copied: true, - }; - } - - return message; - }, - - handleDataMessage(initialMessage, confirm, options = {}) { - const { data } = options; - - // This function is called from the background script in a few scenarios: - // 1. on an incoming message - // 2. on a sent message sync'd from another device - // 3. in rare cases, an incoming message can be retried, though it will - // still go through one of the previous two codepaths - const message = this; - const source = message.get('source'); - const sourceUuid = message.get('sourceUuid'); - const type = message.get('type'); - const conversationId = message.get('conversationId'); - const GROUP_TYPES = textsecure.protobuf.GroupContext.Type; - - const conversation = ConversationController.get(conversationId); - return conversation.queueJob(async () => { - window.log.info( - `Starting handleDataMessage for message ${message.idForLogging()} in conversation ${conversation.idForLogging()}` - ); - - // First, check for duplicates. If we find one, stop processing here. - const existingMessage = await getMessageBySender(this.attributes, { - Message: Whisper.Message, - }); - const isUpdate = Boolean(data && data.isRecipientUpdate); - - if (existingMessage && type === 'incoming') { - window.log.warn('Received duplicate message', this.idForLogging()); - confirm(); - return; - } - if (type === 'outgoing') { - if (isUpdate && existingMessage) { - window.log.info( - `handleDataMessage: Updating message ${message.idForLogging()} with received transcript` - ); - - let sentTo = []; - let unidentifiedDeliveries = []; - if (Array.isArray(data.unidentifiedStatus)) { - sentTo = data.unidentifiedStatus.map(item => item.destination); - - const unidentified = _.filter(data.unidentifiedStatus, item => - Boolean(item.unidentified) - ); - unidentifiedDeliveries = unidentified.map( - item => item.destination - ); - } - - const toUpdate = MessageController.register( - existingMessage.id, - existingMessage - ); - toUpdate.set({ - sent_to: _.union(toUpdate.get('sent_to'), sentTo), - unidentifiedDeliveries: _.union( - toUpdate.get('unidentifiedDeliveries'), - unidentifiedDeliveries - ), - }); - await window.Signal.Data.saveMessage(toUpdate.attributes, { - Message: Whisper.Message, - }); - - confirm(); - return; - } - if (isUpdate) { - window.log.warn( - `handleDataMessage: Received update transcript, but no existing entry for message ${message.idForLogging()}. Dropping.` - ); - - confirm(); - return; - } - if (existingMessage) { - window.log.warn( - `handleDataMessage: Received duplicate transcript for message ${message.idForLogging()}, but it was not an update transcript. Dropping.` - ); - - confirm(); - return; - } - } - - const existingRevision = conversation.get('revision'); - const isGroupV2 = Boolean(initialMessage.groupV2); - const isV2GroupUpdate = - initialMessage.groupV2 && - (!existingRevision || - initialMessage.groupV2.revision > existingRevision); - - // GroupV2 - if (isGroupV2) { - conversation.maybeRepairGroupV2( - _.pick(initialMessage.groupV2, [ - 'masterKey', - 'secretParams', - 'publicParams', - ]) - ); - } - - if (isV2GroupUpdate) { - const { revision, groupChange } = initialMessage.groupV2; - try { - await window.Signal.Groups.maybeUpdateGroup({ - conversation, - groupChangeBase64: groupChange, - newRevision: revision, - receivedAt: message.get('received_at'), - sentAt: message.get('sent_at'), - }); - } catch (error) { - const errorText = error && error.stack ? error.stack : error; - window.log.error( - `handleDataMessage: Failed to process group update for ${conversation.idForLogging()} as part of message ${message.idForLogging()}: ${errorText}` - ); - throw error; - } - } - - const ourConversationId = ConversationController.getOurConversationId(); - const senderId = ConversationController.ensureContactIds({ - e164: source, - uuid: sourceUuid, - }); - const isV1GroupUpdate = - initialMessage.group && - initialMessage.group.type !== - textsecure.protobuf.GroupContext.Type.DELIVER; - - // Drop an incoming GroupV2 message if we or the sender are not part of the group - // after applying the message's associated group chnages. - if ( - type === 'incoming' && - !conversation.isPrivate() && - isGroupV2 && - (conversation.get('left') || - !conversation.hasMember(ourConversationId) || - !conversation.hasMember(senderId)) - ) { - window.log.warn( - `Received message destined for group ${conversation.idForLogging()}, which we or the sender are not a part of. Dropping.` - ); - confirm(); - return; - } - - // We drop incoming messages for v1 groups we already know about, which we're not - // a part of, except for group updates. Because group v1 updates haven't been - // applied by this point. - if ( - type === 'incoming' && - !conversation.isPrivate() && - !isGroupV2 && - !isV1GroupUpdate && - (conversation.get('left') || - !conversation.hasMember(ourConversationId)) - ) { - window.log.warn( - `Received message destined for group ${conversation.idForLogging()}, which we're not a part of. Dropping.` - ); - confirm(); - return; - } - - // Send delivery receipts, but only for incoming sealed sender messages - // and not for messages from unaccepted conversations - if ( - type === 'incoming' && - this.get('unidentifiedDeliveryReceived') && - !this.hasErrors() && - conversation.getAccepted() - ) { - // Note: We both queue and batch because we want to wait until we are done - // processing incoming messages to start sending outgoing delivery receipts. - // The queue can be paused easily. - Whisper.deliveryReceiptQueue.add(() => { - Whisper.deliveryReceiptBatcher.add({ - source, - sourceUuid, - timestamp: this.get('sent_at'), - }); - }); - } - - const withQuoteReference = await this.copyFromQuotedMessage( - initialMessage - ); - const dataMessage = await upgradeMessageSchema(withQuoteReference); - - try { - const now = new Date().getTime(); - - const urls = window.Signal.LinkPreviews.findLinks(dataMessage.body); - const incomingPreview = dataMessage.preview || []; - const preview = incomingPreview.filter( - item => - (item.image || item.title) && - urls.includes(item.url) && - window.Signal.LinkPreviews.isLinkSafeToPreview(item.url) - ); - if (preview.length < incomingPreview.length) { - window.log.info( - `${message.idForLogging()}: Eliminated ${preview.length - - incomingPreview.length} previews with invalid urls'` - ); - } - - message.set({ - id: window.getGuid(), - attachments: dataMessage.attachments, - body: dataMessage.body, - bodyRanges: dataMessage.bodyRanges, - contact: dataMessage.contact, - conversationId: conversation.id, - decrypted_at: now, - errors: [], - flags: dataMessage.flags, - hasAttachments: dataMessage.hasAttachments, - hasFileAttachments: dataMessage.hasFileAttachments, - hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments, - isViewOnce: Boolean(dataMessage.isViewOnce), - preview, - requiredProtocolVersion: - dataMessage.requiredProtocolVersion || - this.INITIAL_PROTOCOL_VERSION, - supportedVersionAtReceive: this.CURRENT_PROTOCOL_VERSION, - quote: dataMessage.quote, - schemaVersion: dataMessage.schemaVersion, - sticker: dataMessage.sticker, - }); - - const isSupported = !message.isUnsupportedMessage(); - if (!isSupported) { - await message.eraseContents(); - } - - if (isSupported) { - let attributes = { - ...conversation.attributes, - }; - - // GroupV1 - if (!isGroupV2 && dataMessage.group) { - const pendingGroupUpdate = []; - const memberConversations = await Promise.all( - dataMessage.group.membersE164.map(e164 => - ConversationController.getOrCreateAndWait(e164, 'private') - ) - ); - const members = memberConversations.map(c => c.get('id')); - attributes = { - ...attributes, - type: 'group', - groupId: dataMessage.group.id, - }; - if (dataMessage.group.type === GROUP_TYPES.UPDATE) { - attributes = { - ...attributes, - name: dataMessage.group.name, - members: _.union(members, conversation.get('members')), - }; - - if (dataMessage.group.name !== conversation.get('name')) { - pendingGroupUpdate.push(['name', dataMessage.group.name]); - } - - const avatarAttachment = dataMessage.group.avatar; - - let downloadedAvatar; - let hash; - if (avatarAttachment) { - try { - downloadedAvatar = await window.Signal.Util.downloadAttachment( - avatarAttachment - ); - - if (downloadedAvatar) { - const loadedAttachment = await Signal.Migrations.loadAttachmentData( - downloadedAvatar - ); - - hash = await Signal.Types.Conversation.computeHash( - loadedAttachment.data - ); - } - } catch (err) { - window.log.info( - 'handleDataMessage: group avatar download failed' - ); - } - } - - const existingAvatar = conversation.get('avatar'); - - if ( - // Avatar added - (!existingAvatar && avatarAttachment) || - // Avatar changed - (existingAvatar && existingAvatar.hash !== hash) || - // Avatar removed - (existingAvatar && !avatarAttachment) - ) { - // Removes existing avatar from disk - if (existingAvatar && existingAvatar.path) { - await Signal.Migrations.deleteAttachmentData( - existingAvatar.path - ); - } - - let avatar = null; - if (downloadedAvatar && avatarAttachment !== null) { - const onDiskAttachment = await window.Signal.Types.Attachment.migrateDataToFileSystem( - downloadedAvatar, - { - writeNewAttachmentData: - window.Signal.Migrations.writeNewAttachmentData, - } - ); - avatar = { - ...onDiskAttachment, - hash, - }; - } - - attributes.avatar = avatar; - - pendingGroupUpdate.push(['avatarUpdated', true]); - } else { - window.log.info( - 'handleDataMessage: Group avatar hash matched; not replacing group avatar' - ); - } - - const difference = _.difference( - members, - conversation.get('members') - ); - if (difference.length > 0) { - // Because GroupV1 groups are based on e164 only - const e164s = difference.map(id => { - const c = ConversationController.get(id); - return c ? c.get('e164') : null; - }); - pendingGroupUpdate.push(['joined', e164s]); - } - if (conversation.get('left')) { - window.log.warn('re-added to a left group'); - attributes.left = false; - conversation.set({ addedBy: message.getContactId() }); - } - } else if (dataMessage.group.type === GROUP_TYPES.QUIT) { - const sender = ConversationController.get(senderId); - const inGroup = Boolean( - sender && - (conversation.get('members') || []).includes(sender.id) - ); - if (!inGroup) { - const senderString = sender ? sender.idForLogging() : null; - window.log.info( - `Got 'left' message from someone not in group: ${senderString}. Dropping.` - ); - return; - } - - if (sender.isMe()) { - attributes.left = true; - pendingGroupUpdate.push(['left', 'You']); - } else { - pendingGroupUpdate.push(['left', sender.get('id')]); - } - attributes.members = _.without( - conversation.get('members'), - sender.get('id') - ); - } - - if (pendingGroupUpdate.length) { - const groupUpdate = pendingGroupUpdate.reduce( - (acc, [key, value]) => { - acc[key] = value; - return acc; - }, - {} - ); - message.set({ group_update: groupUpdate }); - } - } - - // Drop empty messages after. This needs to happen after the initial - // message.set call and after GroupV1 processing to make sure all possible - // properties are set before we determine that a message is empty. - if (message.isEmpty()) { - window.log.info( - `handleDataMessage: Dropping empty message ${message.idForLogging()} in conversation ${conversation.idForLogging()}` - ); - confirm(); - return; - } - - if (type === 'outgoing') { - const receipts = Whisper.DeliveryReceipts.forMessage( - conversation, - message - ); - receipts.forEach(receipt => - message.set({ - delivered: (message.get('delivered') || 0) + 1, - delivered_to: _.union(message.get('delivered_to') || [], [ - receipt.get('deliveredTo'), - ]), - }) - ); - } - - attributes.active_at = now; - conversation.set(attributes); - - if (dataMessage.expireTimer) { - message.set({ expireTimer: dataMessage.expireTimer }); - } - - if (!isGroupV2) { - if (message.isExpirationTimerUpdate()) { - message.set({ - expirationTimerUpdate: { - source, - sourceUuid, - expireTimer: dataMessage.expireTimer, - }, - }); - conversation.set({ expireTimer: dataMessage.expireTimer }); - } - - // NOTE: Remove once the above calls this.model.updateExpirationTimer() - const { expireTimer } = dataMessage; - const shouldLogExpireTimerChange = - message.isExpirationTimerUpdate() || expireTimer; - if (shouldLogExpireTimerChange) { - window.log.info("Update conversation 'expireTimer'", { - id: conversation.idForLogging(), - expireTimer, - source: 'handleDataMessage', - }); - } - - if (!message.isEndSession()) { - if (dataMessage.expireTimer) { - if ( - dataMessage.expireTimer !== conversation.get('expireTimer') - ) { - conversation.updateExpirationTimer( - dataMessage.expireTimer, - source, - message.get('received_at'), - { - fromGroupUpdate: message.isGroupUpdate(), - } - ); - } - } else if ( - conversation.get('expireTimer') && - // We only turn off timers if it's not a group update - !message.isGroupUpdate() - ) { - conversation.updateExpirationTimer( - null, - source, - message.get('received_at') - ); - } - } - } - - if (type === 'incoming') { - const readSync = Whisper.ReadSyncs.forMessage(message); - if (readSync) { - if ( - message.get('expireTimer') && - !message.get('expirationStartTimestamp') - ) { - message.set( - 'expirationStartTimestamp', - Math.min(readSync.get('read_at'), Date.now()) - ); - } - } - if (readSync || message.isExpirationTimerUpdate()) { - message.unset('unread'); - // This is primarily to allow the conversation to mark all older - // messages as read, as is done when we receive a read sync for - // a message we already know about. - const c = message.getConversation(); - if (c) { - c.onReadMessage(message); - } - } else { - conversation.set({ - unreadCount: conversation.get('unreadCount') + 1, - isArchived: false, - }); - } - } - - if (type === 'outgoing') { - const reads = Whisper.ReadReceipts.forMessage( - conversation, - message - ); - if (reads.length) { - const readBy = reads.map(receipt => receipt.get('reader')); - message.set({ - read_by: _.union(message.get('read_by'), readBy), - }); - } - - // A sync'd message to ourself is automatically considered read/delivered - if (conversation.isMe()) { - message.set({ - read_by: conversation.getRecipients(), - delivered_to: conversation.getRecipients(), - }); - } - - message.set({ recipients: conversation.getRecipients() }); - } - - if (dataMessage.profileKey) { - const profileKey = dataMessage.profileKey.toString('base64'); - if ( - source === textsecure.storage.user.getNumber() || - sourceUuid === textsecure.storage.user.getUuid() - ) { - conversation.set({ profileSharing: true }); - } else if (conversation.isPrivate()) { - conversation.setProfileKey(profileKey); - } else { - const localId = ConversationController.ensureContactIds({ - e164: source, - uuid: sourceUuid, - }); - ConversationController.get(localId).setProfileKey(profileKey); - } - } - - if (message.isTapToView() && type === 'outgoing') { - await message.eraseContents(); - } - - if ( - type === 'incoming' && - message.isTapToView() && - !message.isValidTapToView() - ) { - window.log.warn( - `Received tap to view message ${message.idForLogging()} with invalid data. Erasing contents.` - ); - message.set({ - isTapToViewInvalid: true, - }); - await message.eraseContents(); - } - // Check for out-of-order view syncs - if (type === 'incoming' && message.isTapToView()) { - const viewSync = Whisper.ViewSyncs.forMessage(message); - if (viewSync) { - await message.markViewed({ fromSync: true }); - } - } - } - - const conversationTimestamp = conversation.get('timestamp'); - if ( - !conversationTimestamp || - message.get('sent_at') > conversationTimestamp - ) { - conversation.set({ - lastMessage: message.getNotificationText(), - timestamp: message.get('sent_at'), - }); - } - - MessageController.register(message.id, message); - conversation.incrementMessageCount(); - window.Signal.Data.updateConversation(conversation.attributes); - - // Only queue attachments for downloads if this is an outgoing message - // or we've accepted the conversation - if (this.getConversation().getAccepted() || message.isOutgoing()) { - await message.queueAttachmentDownloads(); - } - - // Does this message have any pending, previously-received associated reactions? - const reactions = Whisper.Reactions.forMessage(message); - reactions.forEach(reaction => { - message.handleReaction(reaction, false); - }); - - // Does this message have any pending, previously-received associated - // delete for everyone messages? - const deletes = Whisper.Deletes.forMessage(message); - deletes.forEach(del => { - window.Signal.Util.deleteForEveryone(message, del, false); - }); - - await window.Signal.Data.saveMessage(message.attributes, { - Message: Whisper.Message, - forceSave: true, - }); - - conversation.trigger('newmessage', message); - - if (message.get('unread')) { - await conversation.notify(message); - } - - // Increment the sent message count if this is an outgoing message - if (type === 'outgoing') { - conversation.incrementSentMessageCount(); - } - - Whisper.events.trigger('incrementProgress'); - confirm(); - } catch (error) { - const errorForLog = error && error.stack ? error.stack : error; - window.log.error( - 'handleDataMessage', - message.idForLogging(), - 'error:', - errorForLog - ); - throw error; - } - }); - }, - - async handleReaction(reaction, shouldPersist = true) { - if (this.get('deletedForEveryone')) { - return; - } - - const reactions = this.get('reactions') || []; - const messageId = this.idForLogging(); - const count = reactions.length; - - if (reaction.get('remove')) { - window.log.info('Removing reaction for message', messageId); - const newReactions = reactions.filter( - re => - re.emoji !== reaction.get('emoji') || - re.fromId !== reaction.get('fromId') - ); - this.set({ reactions: newReactions }); - } else { - window.log.info('Adding reaction for message', messageId); - const newReactions = reactions.filter( - re => re.fromId !== reaction.get('fromId') - ); - newReactions.push(reaction.toJSON()); - this.set({ reactions: newReactions }); - - const conversation = ConversationController.get( - this.get('conversationId') - ); - - // Only notify for reactions to our own messages - if (conversation && this.isOutgoing() && !reaction.get('fromSync')) { - conversation.notify(this, reaction); - } - } - - const newCount = this.get('reactions').length; - window.log.info( - `Done processing reaction for message ${messageId}. Went from ${count} to ${newCount} reactions.` - ); - - if (shouldPersist) { - await window.Signal.Data.saveMessage(this.attributes, { - Message: Whisper.Message, - }); - } - }, - - async handleDeleteForEveryone(del, shouldPersist = true) { - window.log.info('Handling DOE.', { - fromId: del.get('fromId'), - targetSentTimestamp: del.get('targetSentTimestamp'), - messageServerTimestamp: this.get('serverTimestamp'), - deleteServerTimestamp: del.get('serverTimestamp'), - }); - - // Remove any notifications for this message - Whisper.Notifications.removeBy({ messageId: this.get('id') }); - - // Erase the contents of this message - await this.eraseContents( - { deletedForEveryone: true, reactions: [] }, - shouldPersist - ); - - // Update the conversation's last message in case this was the last message - this.getConversation().updateLastMessage(); - }, - }); - - // Receive will be enabled before we enable send - Whisper.Message.LONG_MESSAGE_CONTENT_TYPE = 'text/x-signal-plain'; - - Whisper.Message.getLongMessageAttachment = ({ body, attachments, now }) => { - if (!body || body.length <= 2048) { - return { - body, - attachments, - }; - } - - const data = bytesFromString(body); - const attachment = { - contentType: Whisper.Message.LONG_MESSAGE_CONTENT_TYPE, - fileName: `long-message-${now}.txt`, - data, - size: data.byteLength, - }; - - return { - body: body.slice(0, 2048), - attachments: [attachment, ...attachments], - }; - }; - - Whisper.Message.updateTimers = () => { - Whisper.ExpiringMessagesListener.update(); - Whisper.TapToViewMessagesListener.update(); - }; - - Whisper.MessageCollection = Backbone.Collection.extend({ - model: Whisper.Message, - comparator(left, right) { - if (left.get('received_at') === right.get('received_at')) { - return (left.get('sent_at') || 0) - (right.get('sent_at') || 0); - } - - return (left.get('received_at') || 0) - (right.get('received_at') || 0); - }, - }); -})(); diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js deleted file mode 100644 index 4f41bf19e..000000000 --- a/js/views/conversation_view.js +++ /dev/null @@ -1,3297 +0,0 @@ -/* global - $, - _, - ConversationController, - extension, - i18n, - loadImage, - MessageController, - Signal, - storage, - textsecure, - Whisper, -*/ - -// eslint-disable-next-line func-names -(function() { - const FIVE_MINUTES = 1000 * 60 * 5; - - window.Whisper = window.Whisper || {}; - const { Message, MIME, VisualAttachment } = window.Signal.Types; - const { - copyIntoTempDirectory, - deleteDraftFile, - deleteTempFile, - getAbsoluteAttachmentPath, - getAbsoluteDraftPath, - getAbsoluteTempPath, - openFileInFolder, - readAttachmentData, - readDraftData, - saveAttachmentToDisk, - upgradeMessageSchema, - writeNewDraftData, - } = window.Signal.Migrations; - const { - getOlderMessagesByConversation, - getMessageMetricsForConversation, - getMessageById, - getMessagesBySentAt, - getNewerMessagesByConversation, - } = window.Signal.Data; - - Whisper.ExpiredToast = Whisper.ToastView.extend({ - render_attributes() { - return { toastMessage: i18n('expiredWarning') }; - }, - }); - Whisper.BlockedToast = Whisper.ToastView.extend({ - render_attributes() { - return { toastMessage: i18n('unblockToSend') }; - }, - }); - Whisper.BlockedGroupToast = Whisper.ToastView.extend({ - render_attributes() { - return { toastMessage: i18n('unblockGroupToSend') }; - }, - }); - Whisper.LeftGroupToast = Whisper.ToastView.extend({ - render_attributes() { - return { toastMessage: i18n('youLeftTheGroup') }; - }, - }); - Whisper.OriginalNotFoundToast = Whisper.ToastView.extend({ - render_attributes() { - return { toastMessage: i18n('originalMessageNotFound') }; - }, - }); - Whisper.OriginalNoLongerAvailableToast = Whisper.ToastView.extend({ - render_attributes() { - return { toastMessage: i18n('originalMessageNotAvailable') }; - }, - }); - Whisper.FoundButNotLoadedToast = Whisper.ToastView.extend({ - render_attributes() { - return { toastMessage: i18n('messageFoundButNotLoaded') }; - }, - }); - Whisper.VoiceNoteLimit = Whisper.ToastView.extend({ - render_attributes() { - return { toastMessage: i18n('voiceNoteLimit') }; - }, - }); - Whisper.VoiceNoteMustBeOnlyAttachmentToast = Whisper.ToastView.extend({ - render_attributes() { - return { toastMessage: i18n('voiceNoteMustBeOnlyAttachment') }; - }, - }); - Whisper.ConversationArchivedToast = Whisper.ToastView.extend({ - render_attributes() { - return { toastMessage: i18n('conversationArchived') }; - }, - }); - Whisper.ConversationUnarchivedToast = Whisper.ToastView.extend({ - render_attributes() { - return { toastMessage: i18n('conversationReturnedToInbox') }; - }, - }); - Whisper.TapToViewExpiredIncomingToast = Whisper.ToastView.extend({ - render_attributes() { - return { - toastMessage: i18n('Message--tap-to-view--incoming--expired-toast'), - }; - }, - }); - Whisper.TapToViewExpiredOutgoingToast = Whisper.ToastView.extend({ - render_attributes() { - return { - toastMessage: i18n('Message--tap-to-view--outgoing--expired-toast'), - }; - }, - }); - Whisper.FileSavedToast = Whisper.ToastView.extend({ - className: 'toast toast-clickable', - initialize(options) { - if (!options.fullPath) { - throw new Error('FileSavedToast: name option was not provided!'); - } - this.fullPath = options.fullPath; - this.timeout = 10000; - - if (window.getInteractionMode() === 'keyboard') { - setTimeout(() => { - this.$el.focus(); - }, 1); - } - }, - events: { - click: 'onClick', - keydown: 'onKeydown', - }, - onClick() { - openFileInFolder(this.fullPath); - this.close(); - }, - onKeydown(event) { - if (event.key !== 'Enter' && event.key !== ' ') { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - openFileInFolder(this.fullPath); - this.close(); - }, - render_attributes() { - return { toastMessage: i18n('attachmentSaved') }; - }, - }); - Whisper.ReactionFailedToast = Whisper.ToastView.extend({ - className: 'toast toast-clickable', - initialize() { - this.timeout = 4000; - - if (window.getInteractionMode() === 'keyboard') { - setTimeout(() => { - this.$el.focus(); - }, 1); - } - }, - events: { - click: 'onClick', - keydown: 'onKeydown', - }, - onClick() { - this.close(); - }, - onKeydown(event) { - if (event.key !== 'Enter' && event.key !== ' ') { - return; - } - - event.preventDefault(); - event.stopPropagation(); - - this.close(); - }, - render_attributes() { - return { toastMessage: i18n('Reactions--error') }; - }, - }); - - const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; - Whisper.MessageBodyTooLongToast = Whisper.ToastView.extend({ - render_attributes() { - return { toastMessage: i18n('messageBodyTooLong') }; - }, - }); - - Whisper.FileSizeToast = Whisper.ToastView.extend({ - templateName: 'file-size-modal', - render_attributes() { - return { - 'file-size-warning': i18n('fileSizeWarning'), - limit: this.model.limit, - units: this.model.units, - }; - }, - }); - Whisper.UnableToLoadToast = Whisper.ToastView.extend({ - render_attributes() { - return { toastMessage: i18n('unableToLoadAttachment') }; - }, - }); - - Whisper.DangerousFileTypeToast = Whisper.ToastView.extend({ - template: i18n('dangerousFileType'), - }); - Whisper.OneNonImageAtATimeToast = Whisper.ToastView.extend({ - template: i18n('oneNonImageAtATimeToast'), - }); - Whisper.CannotMixImageAndNonImageAttachmentsToast = Whisper.ToastView.extend({ - template: i18n('cannotMixImageAndNonImageAttachments'), - }); - Whisper.MaxAttachmentsToast = Whisper.ToastView.extend({ - template: i18n('maximumAttachments'), - }); - Whisper.TimerConflictToast = Whisper.ToastView.extend({ - template: i18n('GroupV2--timerConflict'), - }); - - Whisper.ConversationLoadingScreen = Whisper.View.extend({ - templateName: 'conversation-loading-screen', - className: 'conversation-loading-screen', - }); - - Whisper.ConversationView = Whisper.View.extend({ - className() { - return ['conversation', this.model.get('type')].join(' '); - }, - id() { - return `conversation-${this.model.cid}`; - }, - template: $('#conversation').html(), - render_attributes() { - return { - 'send-message': i18n('sendMessage'), - }; - }, - initialize(options) { - // Events on Conversation model - this.listenTo(this.model, 'destroy', this.stopListening); - this.listenTo(this.model, 'change:verified', this.onVerifiedChange); - this.listenTo(this.model, 'newmessage', this.addMessage); - this.listenTo(this.model, 'opened', this.onOpened); - this.listenTo(this.model, 'backgrounded', this.resetEmojiResults); - this.listenTo(this.model, 'scroll-to-message', this.scrollToMessage); - this.listenTo(this.model, 'unload', reason => - this.unload(`model trigger - ${reason}`) - ); - this.listenTo(this.model, 'focus-composer', this.focusMessageField); - this.listenTo(this.model, 'open-all-media', this.showAllMedia); - this.listenTo(this.model, 'begin-recording', this.captureAudio); - this.listenTo(this.model, 'attach-file', this.onChooseAttachment); - this.listenTo(this.model, 'escape-pressed', this.resetPanel); - this.listenTo(this.model, 'show-message-details', this.showMessageDetail); - this.listenTo(this.model, 'toggle-reply', messageId => { - const target = this.quote || !messageId ? null : messageId; - this.setQuoteMessage(target); - }); - this.listenTo( - this.model, - 'save-attachment', - this.downloadAttachmentWrapper - ); - this.listenTo(this.model, 'delete-message', this.deleteMessage); - this.listenTo(this.model, 'remove-link-review', this.removeLinkPreview); - this.listenTo( - this.model, - 'remove-all-draft-attachments', - this.clearAttachments - ); - - // Events on Message models - we still listen to these here because they - // can be emitted by the non-reduxified MessageDetail pane - this.listenTo( - this.model.messageCollection, - 'show-identity', - this.showSafetyNumber - ); - this.listenTo(this.model.messageCollection, 'force-send', this.forceSend); - this.listenTo(this.model.messageCollection, 'delete', this.deleteMessage); - this.listenTo( - this.model.messageCollection, - 'show-visual-attachment', - this.showLightbox - ); - this.listenTo( - this.model.messageCollection, - 'display-tap-to-view-message', - this.displayTapToViewMessage - ); - this.listenTo( - this.model.messageCollection, - 'navigate-to', - this.navigateTo - ); - this.listenTo( - this.model.messageCollection, - 'download-new-version', - this.downloadNewVersion - ); - - this.lazyUpdateVerified = _.debounce( - this.model.updateVerified.bind(this.model), - 1000 // one second - ); - this.model.throttledGetProfiles = - this.model.throttledGetProfiles || - _.throttle(this.model.getProfiles.bind(this.model), FIVE_MINUTES); - this.model.throttledUpdateSharedGroups = - this.model.throttledUpdateSharedGroups || - _.throttle( - this.model.updateSharedGroups.bind(this.model), - FIVE_MINUTES - ); - this.model.throttledFetchLatestGroupV2Data = - this.model.throttledFetchLatestGroupV2Data || - _.throttle( - this.model.fetchLatestGroupV2Data.bind(this.model), - FIVE_MINUTES - ); - - this.debouncedMaybeGrabLinkPreview = _.debounce( - this.maybeGrabLinkPreview.bind(this), - 200 - ); - this.debouncedSaveDraft = _.debounce(this.saveDraft.bind(this), 200); - - this.render(); - - this.loadingScreen = new Whisper.ConversationLoadingScreen(); - this.loadingScreen.render(); - this.loadingScreen.$el.prependTo(this.$('.discussion-container')); - - this.window = options.window; - const attachmentListEl = $( - '
' - ); - - this.attachmentListView = new Whisper.ReactWrapperView({ - el: attachmentListEl, - Component: window.Signal.Components.AttachmentList, - props: this.getPropsForAttachmentList(), - }); - - extension.windows.onClosed(() => { - this.unload('windows closed'); - }); - - this.setupHeader(); - this.setupTimeline(); - this.setupCompositionArea({ attachmentListEl: attachmentListEl[0] }); - }, - - events: { - 'click .composition-area-placeholder': 'onClickPlaceholder', - 'click .bottom-bar': 'focusMessageField', - 'click .capture-audio .microphone': 'captureAudio', - 'change input.file-input': 'onChoseAttachment', - - dragover: 'onDragOver', - dragleave: 'onDragLeave', - drop: 'onDrop', - paste: 'onPaste', - }, - - getMuteExpirationLabel() { - const muteExpiresAt = this.model.get('muteExpiresAt'); - if (!muteExpiresAt) { - return; - } - - const today = window.moment(Date.now()); - const expires = window.moment(muteExpiresAt); - - if (today.isSame(expires, 'day')) { - // eslint-disable-next-line consistent-return - return expires.format('hh:mm A'); - } - - // eslint-disable-next-line consistent-return - return expires.format('M/D/YY, hh:mm A'); - }, - - setupHeader() { - const getHeaderProps = () => { - const expireTimer = this.model.get('expireTimer'); - const expirationSettingName = expireTimer - ? Whisper.ExpirationTimerOptions.getName(expireTimer || 0) - : null; - - return { - ...this.model.cachedProps, - - leftGroup: this.model.get('left'), - - disableTimerChanges: - this.model.get('left') || - !this.model.getAccepted() || - !this.model.canChangeTimer(), - showBackButton: Boolean(this.panels && this.panels.length), - - expirationSettingName, - timerOptions: Whisper.ExpirationTimerOptions.map(item => ({ - name: item.getName(), - value: item.get('seconds'), - })), - - muteExpirationLabel: this.getMuteExpirationLabel(), - - onSetDisappearingMessages: seconds => - this.setDisappearingMessages(seconds), - onDeleteMessages: () => this.destroyMessages(), - onResetSession: () => this.endSession(), - onSearchInConversation: () => { - const { searchInConversation } = window.reduxActions.search; - const name = this.model.isMe() - ? i18n('noteToSelf') - : this.model.getTitle(); - searchInConversation(this.model.id, name); - }, - onSetMuteNotifications: ms => this.setMuteNotifications(ms), - - // These are view only and don't update the Conversation model, so they - // need a manual update call. - onOutgoingAudioCallInConversation: async () => { - window.log.info( - 'onOutgoingAudioCallInConversation: about to start an audio call' - ); - - const conversation = this.model; - const isVideoCall = false; - - if (await this.isCallSafe()) { - window.log.info( - 'onOutgoingAudioCallInConversation: call is deemed "safe". Making call' - ); - await window.Signal.Services.calling.startOutgoingCall( - conversation, - isVideoCall - ); - window.log.info( - 'onOutgoingAudioCallInConversation: started the call' - ); - } else { - window.log.info( - 'onOutgoingAudioCallInConversation: call is deemed "unsafe". Stopping' - ); - } - }, - - onOutgoingVideoCallInConversation: async () => { - window.log.info( - 'onOutgoingVideoCallInConversation: about to start a video call' - ); - - const conversation = this.model; - const isVideoCall = true; - - if (await this.isCallSafe()) { - window.log.info( - 'onOutgoingVideoCallInConversation: call is deemed "safe". Making call' - ); - await window.Signal.Services.calling.startOutgoingCall( - conversation, - isVideoCall - ); - window.log.info( - 'onOutgoingVideoCallInConversation: started the call' - ); - } else { - window.log.info( - 'onOutgoingVideoCallInConversation: call is deemed "unsafe". Stopping' - ); - } - }, - - onShowSafetyNumber: () => { - this.showSafetyNumber(); - }, - onShowAllMedia: () => { - this.showAllMedia(); - }, - onShowGroupMembers: async () => { - await this.showMembers(); - this.updateHeader(); - }, - onGoBack: () => { - this.resetPanel(); - }, - - onArchive: () => { - this.model.setArchived(true); - this.model.trigger('unload', 'archive'); - - Whisper.ToastView.show( - Whisper.ConversationArchivedToast, - document.body - ); - }, - onMoveToInbox: () => { - this.model.setArchived(false); - - Whisper.ToastView.show( - Whisper.ConversationUnarchivedToast, - document.body - ); - }, - }; - }; - this.titleView = new Whisper.ReactWrapperView({ - className: 'title-wrapper', - Component: window.Signal.Components.ConversationHeader, - props: getHeaderProps(this.model), - }); - this.updateHeader = () => this.titleView.update(getHeaderProps()); - this.listenTo(this.model, 'change', this.updateHeader); - this.$('.conversation-header').append(this.titleView.el); - }, - - setupCompositionArea({ attachmentListEl }) { - const compositionApi = { current: null }; - this.compositionApi = compositionApi; - - const micCellEl = $(` -
- -
- `)[0]; - - const messageRequestEnum = - window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; - - const props = { - id: this.model.id, - compositionApi, - onClickAddPack: () => this.showStickerManager(), - onPickSticker: (packId, stickerId) => - this.sendStickerMessage({ packId, stickerId }), - onSubmit: message => this.sendMessage(message), - onEditorStateChange: (msg, caretLocation) => - this.onEditorStateChange(msg, caretLocation), - onTextTooLong: () => this.showToast(Whisper.MessageBodyTooLongToast), - onChooseAttachment: this.onChooseAttachment.bind(this), - getQuotedMessage: () => this.model.get('quotedMessageId'), - clearQuotedMessage: () => this.setQuoteMessage(null), - micCellEl, - attachmentListEl, - onAccept: this.model.syncMessageRequestResponse.bind( - this.model, - messageRequestEnum.ACCEPT - ), - onBlock: this.model.syncMessageRequestResponse.bind( - this.model, - messageRequestEnum.BLOCK - ), - onUnblock: this.model.syncMessageRequestResponse.bind( - this.model, - messageRequestEnum.ACCEPT - ), - onDelete: this.model.syncMessageRequestResponse.bind( - this.model, - messageRequestEnum.DELETE - ), - onBlockAndDelete: this.model.syncMessageRequestResponse.bind( - this.model, - messageRequestEnum.BLOCK_AND_DELETE - ), - }; - - this.compositionAreaView = new Whisper.ReactWrapperView({ - className: 'composition-area-wrapper', - JSX: Signal.State.Roots.createCompositionArea(window.reduxStore, props), - }); - - // Finally, add it to the DOM - this.$('.composition-area-placeholder').append( - this.compositionAreaView.el - ); - }, - - setupTimeline() { - const { id } = this.model; - - const reactToMessage = (messageId, reaction) => { - this.sendReactionMessage(messageId, reaction); - }; - const replyToMessage = messageId => { - this.setQuoteMessage(messageId); - }; - const retrySend = messageId => { - this.retrySend(messageId); - }; - const deleteMessage = messageId => { - this.deleteMessage(messageId); - }; - const showMessageDetail = messageId => { - this.showMessageDetail(messageId); - }; - const openConversation = (conversationId, messageId) => { - this.openConversation(conversationId, messageId); - }; - const showContactDetail = options => { - this.showContactDetail(options); - }; - const showVisualAttachment = options => { - this.showLightbox(options); - }; - const downloadAttachment = options => { - this.downloadAttachment(options); - }; - const displayTapToViewMessage = messageId => - this.displayTapToViewMessage(messageId); - const showIdentity = conversationId => { - this.showSafetyNumber(conversationId); - }; - const openLink = url => { - this.navigateTo(url); - }; - const downloadNewVersion = () => { - this.downloadNewVersion(); - }; - const showExpiredIncomingTapToViewToast = () => { - this.showToast(Whisper.TapToViewExpiredIncomingToast); - }; - const showExpiredOutgoingTapToViewToast = () => { - this.showToast(Whisper.TapToViewExpiredOutgoingToast); - }; - - const scrollToQuotedMessage = async options => { - const { author, sentAt } = options; - - const conversationId = this.model.id; - const messages = await getMessagesBySentAt(sentAt, { - MessageCollection: Whisper.MessageCollection, - }); - const message = messages.find( - item => - item.get('conversationId') === conversationId && - item.getSource() === author - ); - - if (!message) { - this.showToast(Whisper.OriginalNotFoundToast); - return; - } - - this.scrollToMessage(message.id); - }; - - const loadOlderMessages = async oldestMessageId => { - const { - messagesAdded, - setMessagesLoading, - } = window.reduxActions.conversations; - const conversationId = this.model.id; - - setMessagesLoading(conversationId, true); - const finish = this.setInProgressFetch(); - - try { - const message = await getMessageById(oldestMessageId, { - Message: Whisper.Message, - }); - if (!message) { - throw new Error( - `loadOlderMessages: failed to load message ${oldestMessageId}` - ); - } - - const receivedAt = message.get('received_at'); - const models = await getOlderMessagesByConversation(conversationId, { - receivedAt, - messageId: oldestMessageId, - limit: 500, - MessageCollection: Whisper.MessageCollection, - }); - - if (models.length < 1) { - window.log.warn( - 'loadOlderMessages: requested, but loaded no messages' - ); - return; - } - - const cleaned = await this.cleanModels(models); - this.model.messageCollection.add(cleaned); - - const isNewMessage = false; - messagesAdded( - id, - models.map(model => model.getReduxData()), - isNewMessage, - window.isActive() - ); - } catch (error) { - setMessagesLoading(conversationId, true); - throw error; - } finally { - finish(); - } - }; - const loadNewerMessages = async newestMessageId => { - const { - messagesAdded, - setMessagesLoading, - } = window.reduxActions.conversations; - const conversationId = this.model.id; - - setMessagesLoading(conversationId, true); - const finish = this.setInProgressFetch(); - - try { - const message = await getMessageById(newestMessageId, { - Message: Whisper.Message, - }); - if (!message) { - throw new Error( - `loadNewerMessages: failed to load message ${newestMessageId}` - ); - } - - const receivedAt = message.get('received_at'); - const models = await getNewerMessagesByConversation(this.model.id, { - receivedAt, - limit: 500, - MessageCollection: Whisper.MessageCollection, - }); - - if (models.length < 1) { - window.log.warn( - 'loadNewerMessages: requested, but loaded no messages' - ); - return; - } - - const cleaned = await this.cleanModels(models); - this.model.messageCollection.add(cleaned); - - const isNewMessage = false; - messagesAdded( - id, - models.map(model => model.getReduxData()), - isNewMessage, - window.isActive() - ); - } catch (error) { - setMessagesLoading(conversationId, false); - throw error; - } finally { - finish(); - } - }; - const markMessageRead = async messageId => { - if (!window.isActive()) { - return; - } - - const message = await getMessageById(messageId, { - Message: Whisper.Message, - }); - if (!message) { - throw new Error( - `markMessageRead: failed to load message ${messageId}` - ); - } - - await this.model.markRead(message.get('received_at')); - }; - - this.timelineView = new Whisper.ReactWrapperView({ - className: 'timeline-wrapper', - JSX: Signal.State.Roots.createTimeline(window.reduxStore, { - id, - - deleteMessage, - displayTapToViewMessage, - downloadAttachment, - downloadNewVersion, - loadNewerMessages, - loadNewestMessages: this.loadNewestMessages.bind(this), - loadAndScroll: this.loadAndScroll.bind(this), - loadOlderMessages, - markMessageRead, - openConversation, - openLink, - reactToMessage, - replyToMessage, - retrySend, - scrollToQuotedMessage, - showContactDetail, - showIdentity, - showMessageDetail, - showVisualAttachment, - showExpiredIncomingTapToViewToast, - showExpiredOutgoingTapToViewToast, - updateSharedGroups: this.model.throttledUpdateSharedGroups, - }), - }); - - this.$('.timeline-placeholder').append(this.timelineView.el); - }, - - showToast(ToastView, options) { - const toast = new ToastView(options); - - const lightboxEl = $('.module-lightbox'); - if (lightboxEl.length > 0) { - toast.$el.appendTo(lightboxEl); - } else { - toast.$el.appendTo(this.$el); - } - - toast.render(); - }, - - async cleanModels(collection) { - const result = collection - .filter(message => Boolean(message.id)) - .map(message => MessageController.register(message.id, message)); - - const eliminated = collection.length - result.length; - if (eliminated > 0) { - window.log.warn( - `cleanModels: Eliminated ${eliminated} messages without an id` - ); - } - - for (let max = result.length, i = 0; i < max; i += 1) { - const message = result[i]; - const { attributes } = message; - const { schemaVersion } = attributes; - - if (schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY) { - // Yep, we really do want to wait for each of these - // eslint-disable-next-line no-await-in-loop - const upgradedMessage = await upgradeMessageSchema(attributes); - message.set(upgradedMessage); - // eslint-disable-next-line no-await-in-loop - await window.Signal.Data.saveMessage(upgradedMessage, { - Message: Whisper.Message, - }); - } - } - - return result; - }, - - async scrollToMessage(messageId) { - const message = await getMessageById(messageId, { - Message: Whisper.Message, - }); - if (!message) { - throw new Error(`scrollToMessage: failed to load message ${messageId}`); - } - - if (this.model.messageCollection.get(messageId)) { - const { scrollToMessage } = window.reduxActions.conversations; - scrollToMessage(this.model.id, messageId); - return; - } - - this.loadAndScroll(messageId); - }, - - setInProgressFetch() { - let resolvePromise; - this.model.inProgressFetch = new Promise(resolve => { - resolvePromise = resolve; - }); - - const finish = () => { - resolvePromise(); - this.model.inProgressFinish = null; - }; - - return finish; - }, - - async loadAndScroll(messageId, options) { - const { disableScroll } = options || {}; - const { - messagesReset, - setMessagesLoading, - } = window.reduxActions.conversations; - const conversationId = this.model.id; - - setMessagesLoading(conversationId, true); - const finish = this.setInProgressFetch(); - - try { - const message = await getMessageById(messageId, { - Message: Whisper.Message, - }); - if (!message) { - throw new Error( - `loadMoreAndScroll: failed to load message ${messageId}` - ); - } - - const receivedAt = message.get('received_at'); - const older = await getOlderMessagesByConversation(conversationId, { - limit: 250, - receivedAt, - messageId, - MessageCollection: Whisper.MessageCollection, - }); - const newer = await getNewerMessagesByConversation(conversationId, { - limit: 250, - receivedAt, - MessageCollection: Whisper.MessageCollection, - }); - const metrics = await getMessageMetricsForConversation(conversationId); - - const all = [...older.models, message, ...newer.models]; - - const cleaned = await this.cleanModels(all); - this.model.messageCollection.reset(cleaned); - const scrollToMessageId = disableScroll ? undefined : messageId; - - messagesReset( - conversationId, - cleaned.map(model => model.getReduxData()), - metrics, - scrollToMessageId - ); - } catch (error) { - setMessagesLoading(conversationId, false); - throw error; - } finally { - finish(); - } - }, - - async loadNewestMessages(newestMessageId, setFocus) { - const { - messagesReset, - setMessagesLoading, - } = window.reduxActions.conversations; - const conversationId = this.model.id; - - setMessagesLoading(conversationId, true); - const finish = this.setInProgressFetch(); - - try { - let scrollToLatestUnread = true; - - if (newestMessageId) { - const newestInMemoryMessage = await getMessageById(newestMessageId, { - Message: Whisper.Message, - }); - if (!newestInMemoryMessage) { - window.log.warn( - `loadNewestMessages: did not find message ${newestMessageId}` - ); - } - - // If newest in-memory message is unread, scrolling down would mean going to - // the very bottom, not the oldest unread. - if (newestInMemoryMessage.isUnread()) { - scrollToLatestUnread = false; - } - } - - const metrics = await getMessageMetricsForConversation(conversationId); - - if (scrollToLatestUnread && metrics.oldestUnread) { - this.loadAndScroll(metrics.oldestUnread.id, { - disableScroll: !setFocus, - }); - return; - } - - const messages = await getOlderMessagesByConversation(conversationId, { - limit: 500, - MessageCollection: Whisper.MessageCollection, - }); - - const cleaned = await this.cleanModels(messages); - this.model.messageCollection.reset(cleaned); - const scrollToMessageId = - setFocus && metrics.newest ? metrics.newest.id : undefined; - - // Because our `getOlderMessages` fetch above didn't specify a receivedAt, we got - // the most recent 500 messages in the conversation. If it has a conflict with - // metrics, fetched a bit before, that's likely a race condition. So we tell our - // reducer to trust the message set we just fetched for determining if we have - // the newest message loaded. - const unboundedFetch = true; - messagesReset( - conversationId, - cleaned.map(model => model.getReduxData()), - metrics, - scrollToMessageId, - unboundedFetch - ); - } catch (error) { - setMessagesLoading(conversationId, false); - throw error; - } finally { - finish(); - } - }, - - // We need this, or clicking the reactified buttons will submit the form and send any - // mid-composition message content. - onClickPlaceholder(e) { - e.preventDefault(); - }, - - onChooseAttachment() { - this.$('input.file-input').click(); - }, - async onChoseAttachment() { - const fileField = this.$('input.file-input'); - const files = fileField.prop('files'); - - for (let i = 0, max = files.length; i < max; i += 1) { - const file = files[i]; - // eslint-disable-next-line no-await-in-loop - await this.maybeAddAttachment(file); - this.toggleMicrophone(); - } - - fileField.val(null); - }, - - unload(reason) { - window.log.info( - 'unloading conversation', - this.model.idForLogging(), - 'due to:', - reason - ); - - const { conversationUnloaded } = window.reduxActions.conversations; - if (conversationUnloaded) { - conversationUnloaded(this.model.id); - } - - if (this.model.get('draftChanged')) { - if (this.model.hasDraft()) { - this.model.set({ - draftChanged: false, - draftTimestamp: Date.now(), - timestamp: Date.now(), - }); - } else { - this.model.set({ - draftChanged: false, - draftTimestamp: null, - }); - } - - // We don't wait here; we need to take down the view - this.saveModel(); - - this.model.updateLastMessage(); - } - - this.titleView.remove(); - this.timelineView.remove(); - this.compositionAreaView.remove(); - - if (this.attachmentListView) { - this.attachmentListView.remove(); - } - if (this.captionEditorView) { - this.captionEditorView.remove(); - } - if (this.stickerButtonView) { - this.stickerButtonView.remove(); - } - if (this.stickerPreviewModalView) { - this.stickerPreviewModalView.remove(); - } - if (this.captureAudioView) { - this.captureAudioView.remove(); - } - if (this.banner) { - this.banner.remove(); - } - if (this.lastSeenIndicator) { - this.lastSeenIndicator.remove(); - } - if (this.scrollDownButton) { - this.scrollDownButton.remove(); - } - if (this.quoteView) { - this.quoteView.remove(); - } - if (this.lightboxView) { - this.lightboxView.remove(); - } - if (this.lightboxGalleryView) { - this.lightboxGalleryView.remove(); - } - if (this.panels && this.panels.length) { - for (let i = 0, max = this.panels.length; i < max; i += 1) { - const panel = this.panels[i]; - panel.remove(); - } - } - - this.remove(); - - this.model.messageCollection.reset([]); - }, - - navigateTo(url) { - window.location = url; - }, - - downloadNewVersion() { - window.location = 'https://signal.org/download'; - }, - - onDragOver(e) { - if (e.originalEvent.dataTransfer.types[0] !== 'Files') { - return; - } - - e.stopPropagation(); - e.preventDefault(); - this.$el.addClass('dropoff'); - }, - - onDragLeave(e) { - if (e.originalEvent.dataTransfer.types[0] !== 'Files') { - return; - } - - e.stopPropagation(); - e.preventDefault(); - }, - - async onDrop(e) { - if (e.originalEvent.dataTransfer.types[0] !== 'Files') { - return; - } - - e.stopPropagation(); - e.preventDefault(); - - const { files } = e.originalEvent.dataTransfer; - for (let i = 0, max = files.length; i < max; i += 1) { - const file = files[i]; - // eslint-disable-next-line no-await-in-loop - await this.maybeAddAttachment(file); - } - }, - - onPaste(e) { - const { items } = e.originalEvent.clipboardData; - let imgBlob = null; - for (let i = 0; i < items.length; i += 1) { - if (items[i].type.split('/')[0] === 'image') { - imgBlob = items[i].getAsFile(); - } - } - if (imgBlob !== null) { - const file = imgBlob; - this.maybeAddAttachment(file); - - e.stopPropagation(); - e.preventDefault(); - } - }, - - getPropsForAttachmentList() { - const draftAttachments = this.model.get('draftAttachments') || []; - - return { - // In conversation model/redux - attachments: draftAttachments.map(attachment => ({ - ...attachment, - url: attachment.screenshotPath - ? getAbsoluteDraftPath(attachment.screenshotPath) - : getAbsoluteDraftPath(attachment.path), - })), - // Passed in from ConversationView - onAddAttachment: this.onChooseAttachment.bind(this), - onClickAttachment: this.onClickAttachment.bind(this), - onCloseAttachment: this.onCloseAttachment.bind(this), - onClose: this.clearAttachments.bind(this), - }; - }, - - onClickAttachment(attachment) { - const getProps = () => ({ - url: attachment.url, - caption: attachment.caption, - attachment, - onSave, - }); - - const onSave = caption => { - this.model.set({ - draftAttachments: this.model.get('draftAttachments').map(item => { - if ( - (item.path && item.path === attachment.path) || - (item.screenshotPath && - item.screenshotPath === attachment.screenshotPath) - ) { - return { - ...attachment, - caption, - }; - } - - return item; - }), - draftChanged: true, - }); - - this.captionEditorView.remove(); - Signal.Backbone.Views.Lightbox.hide(); - - this.updateAttachmentsView(); - this.saveModel(); - }; - - this.captionEditorView = new Whisper.ReactWrapperView({ - className: 'attachment-list-wrapper', - Component: window.Signal.Components.CaptionEditor, - props: getProps(), - onClose: () => Signal.Backbone.Views.Lightbox.hide(), - }); - Signal.Backbone.Views.Lightbox.show(this.captionEditorView.el); - }, - - async deleteDraftAttachment(attachment) { - if (attachment.screenshotPath) { - await deleteDraftFile(attachment.screenshotPath); - } - if (attachment.path) { - await deleteDraftFile(attachment.path); - } - }, - - async saveModel() { - window.Signal.Data.updateConversation(this.model.attributes); - }, - - async addAttachment(attachment) { - const onDisk = await this.writeDraftAttachment(attachment); - - const draftAttachments = this.model.get('draftAttachments') || []; - this.model.set({ - draftAttachments: [...draftAttachments, onDisk], - draftChanged: true, - }); - await this.saveModel(); - - this.updateAttachmentsView(); - }, - - async onCloseAttachment(attachment) { - const draftAttachments = this.model.get('draftAttachments') || []; - - this.model.set({ - draftAttachments: _.reject( - draftAttachments, - item => item.path === attachment.path - ), - draftChanged: true, - }); - - this.updateAttachmentsView(); - - await this.saveModel(); - await this.deleteDraftAttachment(attachment); - }, - - async clearAttachments() { - this.voiceNoteAttachment = null; - - const draftAttachments = this.model.get('draftAttachments') || []; - this.model.set({ - draftAttachments: [], - draftChanged: true, - }); - - this.updateAttachmentsView(); - - // We're fine doing this all at once; at most it should be 32 attachments - await Promise.all([ - this.saveModel(), - Promise.all( - draftAttachments.map(attachment => - this.deleteDraftAttachment(attachment) - ) - ), - ]); - }, - - hasFiles() { - const draftAttachments = this.model.get('draftAttachments') || []; - return draftAttachments.length > 0; - }, - - async getFiles() { - if (this.voiceNoteAttachment) { - // We don't need to pull these off disk; we return them as-is - return [this.voiceNoteAttachment]; - } - - const draftAttachments = this.model.get('draftAttachments') || []; - const files = _.compact( - await Promise.all( - draftAttachments.map(attachment => this.getFile(attachment)) - ) - ); - return files; - }, - - async getFile(attachment) { - if (!attachment) { - return Promise.resolve(); - } - - const data = await readDraftData(attachment.path); - if (data.byteLength !== attachment.size) { - window.log.error( - `Attachment size from disk ${data.byteLength} did not match attachment size ${attachment.size}` - ); - return null; - } - - return { - ..._.pick(attachment, [ - 'contentType', - 'fileName', - 'size', - 'caption', - 'blurHash', - ]), - data, - }; - }, - - arrayBufferFromFile(file) { - return new Promise((resolve, reject) => { - const FR = new FileReader(); - FR.onload = e => { - resolve(e.target.result); - }; - FR.onerror = reject; - FR.onabort = reject; - FR.readAsArrayBuffer(file); - }); - }, - - showFileSizeError({ limit, units, u }) { - const toast = new Whisper.FileSizeToast({ - model: { limit, units: units[u] }, - }); - toast.$el.insertAfter(this.$el); - toast.render(); - }, - - updateAttachmentsView() { - this.attachmentListView.update(this.getPropsForAttachmentList()); - this.toggleMicrophone(); - if (this.hasFiles()) { - this.removeLinkPreview(); - } - }, - - async writeDraftAttachment(attachment) { - let toWrite = attachment; - - if (toWrite.data) { - const path = await writeNewDraftData(toWrite.data); - toWrite = { - ..._.omit(toWrite, ['data']), - path, - }; - } - if (toWrite.screenshotData) { - const screenshotPath = await writeNewDraftData(toWrite.screenshotData); - toWrite = { - ..._.omit(toWrite, ['screenshotData']), - screenshotPath, - }; - } - - return toWrite; - }, - - async maybeAddAttachment(file) { - if (!file) { - return; - } - - const MB = 1000 * 1024; - if (file.size > 100 * MB) { - this.showFileSizeError({ limit: 100, units: ['MB'], u: 0 }); - return; - } - - if (window.Signal.Util.isFileDangerous(file.name)) { - this.showToast(Whisper.DangerousFileTypeToast); - return; - } - - const draftAttachments = this.model.get('draftAttachments') || []; - if (draftAttachments.length >= 32) { - this.showToast(Whisper.MaxAttachmentsToast); - return; - } - - const haveNonImage = _.any( - draftAttachments, - attachment => !MIME.isImage(attachment.contentType) - ); - // You can't add another attachment if you already have a non-image staged - if (haveNonImage) { - this.showToast(Whisper.OneNonImageAtATimeToast); - return; - } - - // You can't add a non-image attachment if you already have attachments staged - if (!MIME.isImage(file.type) && draftAttachments.length > 0) { - this.showToast(Whisper.CannotMixImageAndNonImageAttachmentsToast); - return; - } - - let attachment; - - try { - if (Signal.Util.GoogleChrome.isImageTypeSupported(file.type)) { - attachment = await this.handleImageAttachment(file); - } else if (Signal.Util.GoogleChrome.isVideoTypeSupported(file.type)) { - attachment = await this.handleVideoAttachment(file); - } else { - const data = await this.arrayBufferFromFile(file); - attachment = { - data, - size: data.byteLength, - contentType: file.type, - fileName: file.name, - }; - } - } catch (e) { - window.log.error( - `Was unable to generate thumbnail for file type ${file.type}`, - e && e.stack ? e.stack : e - ); - const data = await this.arrayBufferFromFile(file); - attachment = { - data, - size: data.byteLength, - contentType: file.type, - fileName: file.name, - }; - } - - try { - if (!this.isSizeOkay(attachment)) { - return; - } - } catch (error) { - window.log.error( - 'Error ensuring that image is properly sized:', - error && error.stack ? error.stack : error - ); - - this.showToast(Whisper.UnableToLoadToast); - return; - } - - await this.addAttachment(attachment); - }, - - isSizeOkay(attachment) { - let limitKb = 1000000; - const type = - attachment.contentType === 'image/gif' - ? 'gif' - : attachment.contentType.split('/')[0]; - - switch (type) { - case 'image': - limitKb = 6000; - break; - case 'gif': - limitKb = 25000; - break; - case 'audio': - limitKb = 100000; - break; - case 'video': - limitKb = 100000; - break; - default: - limitKb = 100000; - break; - } - if ((attachment.data.byteLength / 1024).toFixed(4) >= limitKb) { - const units = ['kB', 'MB', 'GB']; - let u = -1; - let limit = limitKb * 1000; - do { - limit /= 1000; - u += 1; - } while (limit >= 1000 && u < units.length - 1); - this.showFileSizeError({ limit, units, u }); - return false; - } - - return true; - }, - - async handleVideoAttachment(file) { - const objectUrl = URL.createObjectURL(file); - if (!objectUrl) { - throw new Error('Failed to create object url for video!'); - } - try { - const screenshotContentType = 'image/png'; - const screenshotBlob = await VisualAttachment.makeVideoScreenshot({ - objectUrl, - contentType: screenshotContentType, - logger: window.log, - }); - const screenshotData = await VisualAttachment.blobToArrayBuffer( - screenshotBlob - ); - const data = await this.arrayBufferFromFile(file); - - return { - fileName: file.name, - screenshotContentType, - screenshotData, - screenshotSize: screenshotData.byteLength, - contentType: file.type, - data, - size: data.byteLength, - }; - } finally { - URL.revokeObjectURL(objectUrl); - } - }, - - async handleImageAttachment(file) { - const blurHash = await window.imageToBlurHash(file); - if (MIME.isJPEG(file.type)) { - const rotatedDataUrl = await window.autoOrientImage(file); - const rotatedBlob = window.dataURLToBlobSync(rotatedDataUrl); - const { - contentType, - file: resizedBlob, - fileName, - } = await this.autoScale({ - contentType: file.type, - fileName: file.name, - file: rotatedBlob, - }); - const data = await await VisualAttachment.blobToArrayBuffer( - resizedBlob - ); - - return { - fileName: fileName || file.name, - contentType, - data, - size: data.byteLength, - blurHash, - }; - } - - const { contentType, file: resizedBlob, fileName } = await this.autoScale( - { - contentType: file.type, - fileName: file.name, - file, - } - ); - const data = await await VisualAttachment.blobToArrayBuffer(resizedBlob); - return { - fileName: fileName || file.name, - contentType, - data, - size: data.byteLength, - blurHash, - }; - }, - - autoScale(attachment) { - const { contentType, file, fileName } = attachment; - if ( - contentType.split('/')[0] !== 'image' || - contentType === 'image/tiff' - ) { - // nothing to do - return Promise.resolve(attachment); - } - - return new Promise((resolve, reject) => { - const url = URL.createObjectURL(file); - const img = document.createElement('img'); - img.onerror = reject; - img.onload = () => { - URL.revokeObjectURL(url); - - const maxSize = 6000 * 1024; - const maxHeight = 4096; - const maxWidth = 4096; - if ( - img.naturalWidth <= maxWidth && - img.naturalHeight <= maxHeight && - file.size <= maxSize - ) { - resolve(attachment); - return; - } - - const gifMaxSize = 25000 * 1024; - if (file.type === 'image/gif' && file.size <= gifMaxSize) { - resolve(attachment); - return; - } - - if (file.type === 'image/gif') { - reject(new Error('GIF is too large')); - return; - } - - const targetContentType = 'image/jpeg'; - const canvas = loadImage.scale(img, { - canvas: true, - maxWidth, - maxHeight, - }); - - let quality = 0.95; - let i = 4; - let blob; - do { - i -= 1; - blob = window.dataURLToBlobSync( - canvas.toDataURL(targetContentType, quality) - ); - quality = (quality * maxSize) / blob.size; - // NOTE: During testing with a large image, we observed the - // `quality` value being > 1. Should we clamp it to [0.5, 1.0]? - // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#Syntax - if (quality < 0.5) { - quality = 0.5; - } - } while (i > 0 && blob.size > maxSize); - - resolve({ - ...attachment, - fileName: this.fixExtension(fileName, targetContentType), - contentType: targetContentType, - file: blob, - }); - }; - img.src = url; - }); - }, - - getFileName(fileName) { - if (!fileName) { - return ''; - } - - if (!fileName.includes('.')) { - return fileName; - } - - return fileName - .split('.') - .slice(0, -1) - .join('.'); - }, - - getType(contentType) { - if (!contentType) { - return ''; - } - - if (!contentType.includes('/')) { - return contentType; - } - - return contentType.split('/')[1]; - }, - - fixExtension(fileName, contentType) { - const extension = this.getType(contentType); - const name = this.getFileName(fileName); - return `${name}.${extension}`; - }, - - markAllAsVerifiedDefault(unverified) { - return Promise.all( - unverified.map(contact => { - if (contact.isUnverified()) { - return contact.setVerifiedDefault(); - } - - return null; - }) - ); - }, - - markAllAsApproved(untrusted) { - return Promise.all(untrusted.map(contact => contact.setApproved())); - }, - - openSafetyNumberScreens(unverified) { - if (unverified.length === 1) { - this.showSafetyNumber(unverified.at(0).id); - return; - } - - this.showMembers(null, unverified, { needVerify: true }); - }, - - onVerifiedChange() { - if (this.model.isUnverified()) { - const unverified = this.model.getUnverified(); - let message; - if (!unverified.length) { - return; - } - if (unverified.length > 1) { - message = i18n('multipleNoLongerVerified'); - } else { - message = i18n('noLongerVerified', [unverified.at(0).getTitle()]); - } - - // Need to re-add, since unverified set may have changed - if (this.banner) { - this.banner.remove(); - this.banner = null; - } - - this.banner = new Whisper.BannerView({ - message, - onDismiss: () => { - this.markAllAsVerifiedDefault(unverified); - }, - onClick: () => { - this.openSafetyNumberScreens(unverified); - }, - }); - - const container = this.$('.discussion-container'); - container.append(this.banner.el); - } else if (this.banner) { - this.banner.remove(); - this.banner = null; - } - }, - - toggleMicrophone() { - this.compositionApi.current.setShowMic(!this.hasFiles()); - }, - - captureAudio(e) { - if (e) { - e.preventDefault(); - } - - if (this.compositionApi.current.isDirty()) { - return; - } - - if (this.hasFiles()) { - this.showToast(Whisper.VoiceNoteMustBeOnlyAttachmentToast); - return; - } - - this.showToast(Whisper.VoiceNoteLimit); - - // Note - clicking anywhere will close the audio capture panel, due to - // the onClick handler in InboxView, which calls its closeRecording method. - - if (this.captureAudioView) { - this.captureAudioView.remove(); - this.captureAudioView = null; - } - - this.captureAudioView = new Whisper.RecorderView(); - - const view = this.captureAudioView; - view.render(); - view.on('send', this.handleAudioCapture.bind(this)); - view.on('confirm', this.handleAudioConfirm.bind(this)); - view.on('closed', this.endCaptureAudio.bind(this)); - view.$el.appendTo(this.$('.capture-audio')); - view.$('.finish').focus(); - this.compositionApi.current.setMicActive(true); - - this.disableMessageField(); - this.$('.microphone').hide(); - }, - handleAudioConfirm(blob, lostFocus) { - const dialog = new Whisper.ConfirmationDialogView({ - cancelText: i18n('discard'), - message: lostFocus - ? i18n('voiceRecordingInterruptedBlur') - : i18n('voiceRecordingInterruptedMax'), - okText: i18n('sendAnyway'), - resolve: async () => { - await this.handleAudioCapture(blob); - }, - }); - - this.$el.prepend(dialog.el); - dialog.focusCancel(); - }, - async handleAudioCapture(blob) { - if (this.hasFiles()) { - throw new Error('A voice note cannot be sent with other attachments'); - } - - const data = await this.arrayBufferFromFile(blob); - - // These aren't persisted to disk; they are meant to be sent immediately - this.voiceNoteAttachment = { - contentType: blob.type, - data, - size: data.byteLength, - flags: textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE, - }; - - // Note: The RecorderView removes itself on send - this.captureAudioView = null; - - this.sendMessage(); - }, - endCaptureAudio() { - this.enableMessageField(); - this.$('.microphone').show(); - - // Note: The RecorderView removes itself on close - this.captureAudioView = null; - - this.compositionApi.current.setMicActive(false); - }, - - async onOpened(messageId) { - if (messageId) { - const message = await getMessageById(messageId, { - Message: Whisper.Message, - }); - - if (message) { - this.loadAndScroll(messageId); - return; - } - - window.log.warn(`onOpened: Did not find message ${messageId}`); - } - - this.loadNewestMessages(); - this.model.updateLastMessage(); - - this.focusMessageField(); - - const quotedMessageId = this.model.get('quotedMessageId'); - if (quotedMessageId) { - this.setQuoteMessage(quotedMessageId); - } - - this.model.throttledFetchLatestGroupV2Data(); - - const statusPromise = this.model.throttledGetProfiles(); - // eslint-disable-next-line more/no-then - this.statusFetch = statusPromise.then(() => - // eslint-disable-next-line more/no-then - this.model.updateVerified().then(() => { - this.onVerifiedChange(); - this.statusFetch = null; - }) - ); - }, - - async retrySend(messageId) { - const message = this.model.messageCollection.get(messageId); - if (!message) { - throw new Error(`retrySend: Did not find message for id ${messageId}`); - } - await message.retrySend(); - }, - - async showAllMedia() { - if (this.panels && this.panels.length > 0) { - return; - } - - // We fetch more documents than media as they don’t require to be loaded - // into memory right away. Revisit this once we have infinite scrolling: - const DEFAULT_MEDIA_FETCH_COUNT = 50; - const DEFAULT_DOCUMENTS_FETCH_COUNT = 150; - - const conversationId = this.model.get('id'); - - const getProps = async () => { - const rawMedia = await Signal.Data.getMessagesWithVisualMediaAttachments( - conversationId, - { - limit: DEFAULT_MEDIA_FETCH_COUNT, - MessageCollection: Whisper.MessageCollection, - } - ); - const rawDocuments = await Signal.Data.getMessagesWithFileAttachments( - conversationId, - { - limit: DEFAULT_DOCUMENTS_FETCH_COUNT, - MessageCollection: Whisper.MessageCollection, - } - ); - - // First we upgrade these messages to ensure that they have thumbnails - for (let max = rawMedia.length, i = 0; i < max; i += 1) { - const message = rawMedia[i]; - const { schemaVersion } = message; - - if (schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY) { - // Yep, we really do want to wait for each of these - // eslint-disable-next-line no-await-in-loop - rawMedia[i] = await upgradeMessageSchema(message); - // eslint-disable-next-line no-await-in-loop - await window.Signal.Data.saveMessage(rawMedia[i], { - Message: Whisper.Message, - }); - } - } - - const media = _.flatten( - rawMedia.map(message => { - const { attachments } = message; - return (attachments || []) - .filter( - attachment => - attachment.thumbnail && - !attachment.pending && - !attachment.error - ) - .map((attachment, index) => { - const { thumbnail } = attachment; - - return { - objectURL: getAbsoluteAttachmentPath(attachment.path), - thumbnailObjectUrl: thumbnail - ? getAbsoluteAttachmentPath(thumbnail.path) - : null, - contentType: attachment.contentType, - index, - attachment, - message, - }; - }); - }) - ); - - // Unlike visual media, only one non-image attachment is supported - const documents = rawDocuments - .filter(message => - Boolean(message.attachments && message.attachments.length) - ) - .map(message => { - const attachments = message.attachments || []; - const attachment = attachments[0]; - return { - contentType: attachment.contentType, - index: 0, - attachment, - message, - }; - }); - - const saveAttachment = async ({ attachment, message } = {}) => { - const timestamp = message.sent_at; - const fullPath = await Signal.Types.Attachment.save({ - attachment, - readAttachmentData, - saveAttachmentToDisk, - timestamp, - }); - - if (fullPath) { - this.showToast(Whisper.FileSavedToast, { fullPath }); - } - }; - - const onItemClick = async ({ message, attachment, type }) => { - switch (type) { - case 'documents': { - saveAttachment({ message, attachment }); - break; - } - - case 'media': { - const selectedIndex = media.findIndex( - mediaMessage => mediaMessage.attachment.path === attachment.path - ); - this.lightboxGalleryView = new Whisper.ReactWrapperView({ - className: 'lightbox-wrapper', - Component: Signal.Components.LightboxGallery, - props: { - media, - onSave: saveAttachment, - selectedIndex, - }, - onClose: () => Signal.Backbone.Views.Lightbox.hide(), - }); - Signal.Backbone.Views.Lightbox.show(this.lightboxGalleryView.el); - break; - } - - default: - throw new TypeError(`Unknown attachment type: '${type}'`); - } - }; - - return { - documents, - media, - onItemClick, - }; - }; - - const view = new Whisper.ReactWrapperView({ - className: 'panel', - Component: Signal.Components.MediaGallery, - props: await getProps(), - onClose: () => { - this.stopListening(this.model.messageCollection, 'remove', update); - }, - }); - - const update = async () => { - view.update(await getProps()); - }; - - this.listenTo(this.model.messageCollection, 'remove', update); - - this.listenBack(view); - this.updateHeader(); - }, - - focusMessageField() { - if (this.panels && this.panels.length) { - return; - } - - const { compositionApi } = this; - - if (compositionApi && compositionApi.current) { - compositionApi.current.focusInput(); - } - }, - - focusMessageFieldAndClearDisabled() { - this.compositionApi.current.setDisabled(false); - this.focusMessageField(); - }, - - disableMessageField() { - this.compositionApi.current.setDisabled(true); - }, - - enableMessageField() { - this.compositionApi.current.setDisabled(false); - }, - - resetEmojiResults() { - this.compositionApi.current.resetEmojiResults(false); - }, - - async addMessage(message) { - // This is debounced, so it won't hit the database too often. - this.lazyUpdateVerified(); - - // We do this here because we don't want convo.messageCollection to have - // anything in it unless it has an associated view. This is so, when we - // fetch on open, it's clean. - this.model.addIncomingMessage(message); - }, - - async showMembers(e, providedMembers, options = {}) { - _.defaults(options, { needVerify: false }); - - let model = providedMembers || this.model.contactCollection; - - if (!providedMembers && this.model.get('groupVersion') === 2) { - model = new Whisper.GroupConversationCollection( - this.model.get('membersV2').map(({ conversationId, role }) => ({ - conversation: ConversationController.get(conversationId), - isAdmin: - role === window.textsecure.protobuf.Member.Role.ADMINISTRATOR, - })) - ); - } - - const view = new Whisper.GroupMemberList({ - model, - // we pass this in to allow nested panels - listenBack: this.listenBack.bind(this), - needVerify: options.needVerify, - }); - - this.listenBack(view); - }, - - forceSend({ contactId, messageId }) { - const contact = ConversationController.get(contactId); - const message = this.model.messageCollection.get(messageId); - if (!message) { - throw new Error(`forceSend: Did not find message for id ${messageId}`); - } - - const dialog = new Whisper.ConfirmationDialogView({ - message: i18n('identityKeyErrorOnSend', { - name1: contact.getTitle(), - name2: contact.getTitle(), - }), - okText: i18n('sendAnyway'), - resolve: async () => { - await contact.updateVerified(); - - if (contact.isUnverified()) { - await contact.setVerifiedDefault(); - } - - const untrusted = await contact.isUntrusted(); - if (untrusted) { - await contact.setApproved(); - } - - message.resend(contact.getSendTarget()); - }, - }); - - this.$el.prepend(dialog.el); - dialog.focusCancel(); - }, - - showSafetyNumber(id) { - let conversation; - - if (!id && this.model.isPrivate()) { - // eslint-disable-next-line prefer-destructuring - conversation = this.model; - } else { - conversation = ConversationController.get(id); - } - if (conversation) { - const view = new Whisper.KeyVerificationPanelView({ - model: conversation, - }); - this.listenBack(view); - this.updateHeader(); - } - }, - - downloadAttachmentWrapper(messageId) { - const message = this.model.messageCollection.get(messageId); - if (!message) { - throw new Error( - `downloadAttachmentWrapper: Did not find message for id ${messageId}` - ); - } - - const { attachments, sent_at: timestamp } = message.attributes; - if (!attachments || attachments.length < 1) { - return; - } - - const attachment = attachments[0]; - const { fileName } = attachment; - - const isDangerous = window.Signal.Util.isFileDangerous(fileName || ''); - - this.downloadAttachment({ attachment, timestamp, isDangerous }); - }, - - async downloadAttachment({ attachment, timestamp, isDangerous }) { - if (isDangerous) { - this.showToast(Whisper.DangerousFileTypeToast); - return; - } - - const fullPath = await Signal.Types.Attachment.save({ - attachment, - readAttachmentData, - saveAttachmentToDisk, - timestamp, - }); - - if (fullPath) { - this.showToast(Whisper.FileSavedToast, { fullPath }); - } - }, - - async displayTapToViewMessage(messageId) { - const message = this.model.messageCollection.get(messageId); - if (!message) { - throw new Error( - `displayTapToViewMessage: Did not find message for id ${messageId}` - ); - } - - if (!message.isTapToView()) { - throw new Error( - `displayTapToViewMessage: Message ${message.idForLogging()} is not a tap to view message` - ); - } - - if (message.isErased()) { - throw new Error( - `displayTapToViewMessage: Message ${message.idForLogging()} is already erased` - ); - } - - const firstAttachment = message.get('attachments')[0]; - if (!firstAttachment || !firstAttachment.path) { - throw new Error( - `displayTapToViewMessage: Message ${message.idForLogging()} had no first attachment with path` - ); - } - - const absolutePath = getAbsoluteAttachmentPath(firstAttachment.path); - const tempPath = await copyIntoTempDirectory(absolutePath); - const tempAttachment = { - ...firstAttachment, - path: tempPath, - }; - - await message.markViewed(); - - const closeLightbox = async () => { - if (!this.lightboxView) { - return; - } - - const { lightboxView } = this; - this.lightboxView = null; - - this.stopListening(message); - Signal.Backbone.Views.Lightbox.hide(); - lightboxView.remove(); - - await deleteTempFile(tempPath); - }; - this.listenTo(message, 'expired', closeLightbox); - this.listenTo(message, 'change', () => { - if (this.lightBoxView) { - this.lightBoxView.update(getProps()); - } - }); - - const getProps = () => { - const { path, contentType } = tempAttachment; - - return { - objectURL: getAbsoluteTempPath(path), - contentType, - onSave: null, // important so download button is omitted - isViewOnce: true, - }; - }; - this.lightboxView = new Whisper.ReactWrapperView({ - className: 'lightbox-wrapper', - Component: Signal.Components.Lightbox, - props: getProps(), - onClose: closeLightbox, - }); - - Signal.Backbone.Views.Lightbox.show(this.lightboxView.el); - }, - - deleteMessage(messageId) { - const message = this.model.messageCollection.get(messageId); - if (!message) { - throw new Error( - `deleteMessage: Did not find message for id ${messageId}` - ); - } - - const dialog = new Whisper.ConfirmationDialogView({ - message: i18n('deleteWarning'), - okText: i18n('delete'), - resolve: () => { - window.Signal.Data.removeMessage(message.id, { - Message: Whisper.Message, - }); - message.trigger('unload'); - this.model.messageCollection.remove(message.id); - if (message.isOutgoing()) { - this.model.decrementSentMessageCount(); - } else { - this.model.decrementMessageCount(); - } - this.resetPanel(); - }, - }); - - this.$el.prepend(dialog.el); - dialog.focusCancel(); - }, - - showStickerPackPreview(packId, packKey) { - window.Signal.Stickers.downloadEphemeralPack(packId, packKey); - - const props = { - packId, - onClose: async () => { - this.stickerPreviewModalView.remove(); - this.stickerPreviewModalView = null; - await window.Signal.Stickers.removeEphemeralPack(packId); - }, - }; - - this.stickerPreviewModalView = new Whisper.ReactWrapperView({ - className: 'sticker-preview-modal-wrapper', - JSX: Signal.State.Roots.createStickerPreviewModal( - window.reduxStore, - props - ), - }); - }, - - showLightbox({ attachment, messageId }) { - const message = this.model.messageCollection.get(messageId); - if (!message) { - throw new Error( - `showLightbox: did not find message for id ${messageId}` - ); - } - const sticker = message.get('sticker'); - if (sticker) { - const { packId, packKey } = sticker; - this.showStickerPackPreview(packId, packKey); - return; - } - - const { contentType, path } = attachment; - - if ( - !Signal.Util.GoogleChrome.isImageTypeSupported(contentType) && - !Signal.Util.GoogleChrome.isVideoTypeSupported(contentType) - ) { - this.downloadAttachment({ attachment, message }); - return; - } - - const attachments = message.get('attachments') || []; - - const media = attachments - .filter(item => item.thumbnail && !item.pending && !item.error) - .map((item, index) => ({ - objectURL: getAbsoluteAttachmentPath(item.path), - path: item.path, - contentType: item.contentType, - index, - message, - attachment: item, - })); - - if (media.length === 1) { - const props = { - objectURL: getAbsoluteAttachmentPath(path), - contentType, - caption: attachment.caption, - onSave: () => { - const timestamp = message.get('sent_at'); - this.downloadAttachment({ attachment, timestamp, message }); - }, - }; - this.lightboxView = new Whisper.ReactWrapperView({ - className: 'lightbox-wrapper', - Component: Signal.Components.Lightbox, - props, - onClose: () => { - Signal.Backbone.Views.Lightbox.hide(); - this.stopListening(message); - }, - }); - this.listenTo(message, 'expired', () => this.lightboxView.remove()); - Signal.Backbone.Views.Lightbox.show(this.lightboxView.el); - return; - } - - const selectedIndex = _.findIndex( - media, - item => attachment.path === item.path - ); - - const onSave = async (options = {}) => { - const fullPath = await Signal.Types.Attachment.save({ - attachment: options.attachment, - index: options.index + 1, - readAttachmentData, - saveAttachmentToDisk, - timestamp: options.message.get('sent_at'), - }); - - if (fullPath) { - this.showToast(Whisper.FileSavedToast, { fullPath }); - } - }; - - const props = { - media, - selectedIndex: selectedIndex >= 0 ? selectedIndex : 0, - onSave, - }; - this.lightboxGalleryView = new Whisper.ReactWrapperView({ - className: 'lightbox-wrapper', - Component: Signal.Components.LightboxGallery, - props, - onClose: () => { - Signal.Backbone.Views.Lightbox.hide(); - this.stopListening(message); - }, - }); - this.listenTo(message, 'expired', () => - this.lightboxGalleryView.remove() - ); - Signal.Backbone.Views.Lightbox.show(this.lightboxGalleryView.el); - }, - - showMessageDetail(messageId) { - const message = this.model.messageCollection.get(messageId); - if (!message) { - throw new Error( - `showMessageDetail: Did not find message for id ${messageId}` - ); - } - - if (!message.isNormalBubble()) { - return; - } - - const onClose = () => { - this.stopListening(message, 'change', update); - this.resetPanel(); - }; - - const props = message.getPropsForMessageDetail(); - const view = new Whisper.ReactWrapperView({ - className: 'panel message-detail-wrapper', - Component: Signal.Components.MessageDetail, - props, - onClose, - }); - - const update = () => view.update(message.getPropsForMessageDetail()); - this.listenTo(message, 'change', update); - this.listenTo(message, 'expired', onClose); - // We could listen to all involved contacts, but we'll call that overkill - - this.listenBack(view); - this.updateHeader(); - view.render(); - }, - - showStickerManager() { - const view = new Whisper.ReactWrapperView({ - className: ['sticker-manager-wrapper', 'panel'].join(' '), - JSX: Signal.State.Roots.createStickerManager(window.reduxStore), - onClose: () => { - this.resetPanel(); - }, - }); - - this.listenBack(view); - this.updateHeader(); - view.render(); - }, - - showContactDetail({ contact, signalAccount }) { - const view = new Whisper.ReactWrapperView({ - Component: Signal.Components.ContactDetail, - className: 'contact-detail-pane panel', - props: { - contact, - hasSignalAccount: Boolean(signalAccount), - onSendMessage: () => { - if (signalAccount) { - this.openConversation(signalAccount); - } - }, - }, - onClose: () => { - this.resetPanel(); - }, - }); - - this.listenBack(view); - this.updateHeader(); - }, - - async openConversation(number) { - window.Whisper.events.trigger('showConversation', number); - }, - - listenBack(view) { - this.panels = this.panels || []; - - if (this.panels.length === 0) { - this.previousFocus = document.activeElement; - } - - this.panels.unshift(view); - view.$el.insertAfter(this.$('.panel').last()); - view.$el.one('animationend', () => { - view.$el.addClass('panel--static'); - }); - }, - resetPanel() { - if (!this.panels || !this.panels.length) { - return; - } - - const view = this.panels.shift(); - - if ( - this.panels.length === 0 && - this.previousFocus && - this.previousFocus.focus - ) { - this.previousFocus.focus(); - this.previousFocus = null; - } - - if (this.panels.length > 0) { - this.panels[0].$el.fadeIn(250); - } - this.updateHeader(); - - view.$el.addClass('panel--remove').one('transitionend', () => { - view.remove(); - - if (this.panels.length === 0) { - // Make sure poppers are positioned properly - window.dispatchEvent(new Event('resize')); - } - }); - }, - - endSession() { - this.model.endSession(); - }, - - async setDisappearingMessages(seconds) { - try { - if (seconds > 0) { - await this.model.updateExpirationTimer(seconds); - } else { - await this.model.updateExpirationTimer(null); - } - } catch (error) { - if (error.code === 409) { - this.showToast(Whisper.TimerConflictToast); - } - } - }, - - setMuteNotifications(ms) { - this.model.set({ - muteExpiresAt: ms > 0 ? Date.now() + ms : undefined, - }); - }, - - async destroyMessages() { - try { - await this.confirm(i18n('deleteConversationConfirmation')); - try { - this.model.trigger('unload', 'delete messages'); - await this.model.destroyMessages(); - this.model.updateLastMessage(); - } catch (error) { - window.log.error( - 'destroyMessages: Failed to successfully delete conversation', - error && error.stack ? error.stack : error - ); - } - } catch (error) { - // nothing to see here, user canceled out of dialog - } - }, - - async isCallSafe() { - const contacts = await this.getUntrustedContacts(); - if (contacts && contacts.length) { - const callAnyway = await this.showSendAnywayDialog( - contacts, - i18n('callAnyway') - ); - if (!callAnyway) { - window.log.info( - 'Safety number change dialog not accepted, new call not allowed.' - ); - return false; - } - } - - return true; - }, - - showSendAnywayDialog(contacts, confirmText) { - return new Promise(resolve => { - const dialog = new Whisper.SafetyNumberChangeDialogView({ - confirmText, - contacts, - reject: () => { - resolve(false); - }, - resolve: () => { - resolve(true); - }, - }); - - this.$el.prepend(dialog.el); - }); - }, - - async sendReactionMessage(messageId, reaction) { - const messageModel = messageId - ? await getMessageById(messageId, { - Message: Whisper.Message, - }) - : null; - - try { - await this.model.sendReactionMessage(reaction, { - targetAuthorE164: messageModel.getSource(), - targetAuthorUuid: messageModel.getSourceUuid(), - targetTimestamp: messageModel.get('sent_at'), - }); - } catch (error) { - window.log.error('Error sending reaction', error, messageId, reaction); - this.showToast(Whisper.ReactionFailedToast); - } - }, - - async sendStickerMessage(options = {}) { - try { - const contacts = await this.getUntrustedContacts(options); - - if (contacts && contacts.length) { - const sendAnyway = await this.showSendAnywayDialog(contacts); - if (sendAnyway) { - this.sendStickerMessage({ ...options, force: true }); - } - - return; - } - - const { packId, stickerId } = options; - this.model.sendStickerMessage(packId, stickerId); - } catch (error) { - window.log.error( - 'clickSend error:', - error && error.stack ? error.stack : error - ); - } - }, - - async getUntrustedContacts(options = {}) { - // This will go to the trust store for the latest identity key information, - // and may result in the display of a new banner for this conversation. - await this.model.updateVerified(); - const unverifiedContacts = this.model.getUnverified(); - - if (options.force) { - if (unverifiedContacts.length) { - await this.markAllAsVerifiedDefault(unverifiedContacts); - // We only want force to break us through one layer of checks - // eslint-disable-next-line no-param-reassign - options.force = false; - } - } else if (unverifiedContacts.length) { - return unverifiedContacts; - } - - const untrustedContacts = await this.model.getUntrusted(); - - if (options.force) { - if (untrustedContacts.length) { - await this.markAllAsApproved(untrustedContacts); - } - } else if (untrustedContacts.length) { - return untrustedContacts; - } - - return null; - }, - - async setQuoteMessage(messageId) { - const model = messageId - ? await getMessageById(messageId, { - Message: Whisper.Message, - }) - : null; - - if (model && !model.canReply()) { - return; - } - - if (model && !model.isNormalBubble()) { - return; - } - - this.quote = null; - this.quotedMessage = null; - this.quoteHolder = null; - - const existing = this.model.get('quotedMessageId'); - if (existing !== messageId) { - this.model.set({ - quotedMessageId: messageId, - draftChanged: true, - }); - - await this.saveModel(); - } - - if (this.quoteView) { - this.quoteView.remove(); - this.quoteView = null; - } - - if (model) { - const message = MessageController.register(model.id, model); - this.quotedMessage = message; - - if (message) { - this.quote = await this.model.makeQuote(this.quotedMessage); - - this.focusMessageFieldAndClearDisabled(); - } - } - - this.renderQuotedMessage(); - }, - - renderQuotedMessage() { - if (this.quoteView) { - this.quoteView.remove(); - this.quoteView = null; - } - if (!this.quotedMessage) { - return; - } - - const message = new Whisper.Message({ - conversationId: this.model.id, - quote: this.quote, - }); - message.quotedMessage = this.quotedMessage; - this.quoteHolder = message; - - const props = message.getPropsForQuote(); - - this.listenTo(message, 'scroll-to-message', () => { - this.scrollToMessage(message.quotedMessage.id); - }); - - const contact = this.quotedMessage.getContact(); - if (contact) { - this.listenTo(contact, 'change', this.renderQuotedMesage); - } - - this.quoteView = new Whisper.ReactWrapperView({ - className: 'quote-wrapper', - Component: window.Signal.Components.Quote, - elCallback: el => - this.$(this.compositionApi.current.attSlotRef.current).prepend(el), - props: { - ...props, - withContentAbove: true, - onClose: () => { - // This can't be the normal 'onClose' because that is always run when this - // view is removed from the DOM, and would clear the draft quote. - this.setQuoteMessage(null); - }, - }, - }); - }, - - async sendMessage(message = '', options = {}) { - this.sendStart = Date.now(); - - try { - const contacts = await this.getUntrustedContacts(options); - this.disableMessageField(); - - if (contacts && contacts.length) { - const sendAnyway = await this.showSendAnywayDialog(contacts); - if (sendAnyway) { - this.sendMessage(message, { force: true }); - return; - } - - this.focusMessageFieldAndClearDisabled(); - return; - } - } catch (error) { - this.focusMessageFieldAndClearDisabled(); - window.log.error( - 'sendMessage error:', - error && error.stack ? error.stack : error - ); - return; - } - - this.model.clearTypingTimers(); - - let ToastView; - if (window.reduxStore.getState().expiration.hasExpired) { - ToastView = Whisper.ExpiredToast; - } - if ( - this.model.isPrivate() && - (storage.isBlocked(this.model.get('e164')) || - storage.isUuidBlocked(this.model.get('uuid'))) - ) { - ToastView = Whisper.BlockedToast; - } - if ( - !this.model.isPrivate() && - storage.isGroupBlocked(this.model.get('groupId')) - ) { - ToastView = Whisper.BlockedGroupToast; - } - if (!this.model.isPrivate() && this.model.get('left')) { - ToastView = Whisper.LeftGroupToast; - } - if (message.length > MAX_MESSAGE_BODY_LENGTH) { - ToastView = Whisper.MessageBodyTooLongToast; - } - - if (ToastView) { - this.showToast(ToastView); - this.focusMessageFieldAndClearDisabled(); - return; - } - - try { - if (!message.length && !this.hasFiles() && !this.voiceNoteAttachment) { - return; - } - - const attachments = await this.getFiles(); - const sendDelta = Date.now() - this.sendStart; - window.log.info('Send pre-checks took', sendDelta, 'milliseconds'); - - this.model.sendMessage( - message, - attachments, - this.quote, - this.getLinkPreview() - ); - - this.compositionApi.current.reset(); - this.setQuoteMessage(null); - this.resetLinkPreview(); - this.clearAttachments(); - } catch (error) { - window.log.error( - 'Error pulling attached files before send', - error && error.stack ? error.stack : error - ); - } finally { - this.focusMessageFieldAndClearDisabled(); - } - }, - - onEditorStateChange(messageText, caretLocation) { - this.maybeBumpTyping(messageText); - this.debouncedSaveDraft(messageText); - this.debouncedMaybeGrabLinkPreview(messageText, caretLocation); - }, - - async saveDraft(messageText) { - const trimmed = - messageText && messageText.length > 0 ? messageText.trim() : ''; - - if (this.model.get('draft') && (!messageText || trimmed.length === 0)) { - this.model.set({ - draft: null, - draftChanged: true, - }); - await this.saveModel(); - - return; - } - - if (messageText !== this.model.get('draft')) { - this.model.set({ - draft: messageText, - draftChanged: true, - }); - await this.saveModel(); - } - }, - - maybeGrabLinkPreview(message, caretLocation) { - // Don't generate link previews if user has turned them off - if (!storage.get('linkPreviews', false)) { - return; - } - // Do nothing if we're offline - if (!textsecure.messaging) { - return; - } - // If we have attachments, don't add link preview - if (this.hasFiles()) { - return; - } - // If we're behind a user-configured proxy, we don't support link previews - if (window.isBehindProxy()) { - return; - } - - if (!message) { - this.resetLinkPreview(); - return; - } - if (this.disableLinkPreviews) { - return; - } - - const links = window.Signal.LinkPreviews.findLinks( - message, - caretLocation - ); - const { currentlyMatchedLink } = this; - if (links.includes(currentlyMatchedLink)) { - return; - } - - this.currentlyMatchedLink = null; - this.excludedPreviewUrls = this.excludedPreviewUrls || []; - - const link = links.find( - item => - window.Signal.LinkPreviews.isLinkInWhitelist(item) && - !this.excludedPreviewUrls.includes(item) - ); - if (!link) { - this.removeLinkPreview(); - return; - } - - this.currentlyMatchedLink = link; - this.addLinkPreview(link); - }, - - resetLinkPreview() { - this.disableLinkPreviews = false; - this.excludedPreviewUrls = []; - this.removeLinkPreview(); - }, - - removeLinkPreview() { - (this.preview || []).forEach(item => { - if (item.url) { - URL.revokeObjectURL(item.url); - } - }); - this.preview = null; - this.previewLoading = null; - this.currentlyMatchedLink = false; - this.renderLinkPreview(); - }, - - async makeChunkedRequest(url) { - const PARALLELISM = 3; - const first = await textsecure.messaging.makeProxiedRequest(url, { - start: 0, - end: Signal.Crypto.getRandomValue(1023, 2047), - returnArrayBuffer: true, - }); - const { totalSize, result } = first; - const initialOffset = result.data.byteLength; - const firstChunk = { - start: 0, - end: initialOffset, - ...result, - }; - - const chunks = await Signal.LinkPreviews.getChunkPattern( - totalSize, - initialOffset - ); - - let results = []; - const jobs = chunks.map(chunk => async () => { - const { start, end } = chunk; - - const jobResult = await textsecure.messaging.makeProxiedRequest(url, { - start, - end, - returnArrayBuffer: true, - }); - - return { - ...chunk, - ...jobResult.result, - }; - }); - - while (jobs.length > 0) { - const activeJobs = []; - for (let i = 0, max = PARALLELISM; i < max; i += 1) { - if (!jobs.length) { - break; - } - - const job = jobs.shift(); - activeJobs.push(job()); - } - - // eslint-disable-next-line no-await-in-loop - results = results.concat(await Promise.all(activeJobs)); - } - - if (!results.length) { - throw new Error('No responses received'); - } - - const { contentType } = results[0]; - const data = Signal.LinkPreviews.assembleChunks( - [firstChunk].concat(results) - ); - - return { - contentType, - data, - }; - }, - - async getStickerPackPreview(url) { - const isPackDownloaded = pack => - pack && (pack.status === 'downloaded' || pack.status === 'installed'); - const isPackValid = pack => - pack && - (pack.status === 'ephemeral' || - pack.status === 'downloaded' || - pack.status === 'installed'); - - const dataFromLink = window.Signal.Stickers.getDataFromLink(url); - if (!dataFromLink) { - return null; - } - const { id, key } = dataFromLink; - - try { - const keyBytes = window.Signal.Crypto.bytesFromHexString(key); - const keyBase64 = window.Signal.Crypto.arrayBufferToBase64(keyBytes); - - const existing = window.Signal.Stickers.getStickerPack(id); - if (!isPackDownloaded(existing)) { - await window.Signal.Stickers.downloadEphemeralPack(id, keyBase64); - } - - const pack = window.Signal.Stickers.getStickerPack(id); - if (!isPackValid(pack)) { - return null; - } - if (pack.key !== keyBase64) { - return null; - } - - const { title, coverStickerId } = pack; - const sticker = pack.stickers[coverStickerId]; - const data = - pack.status === 'ephemeral' - ? await window.Signal.Migrations.readTempData(sticker.path) - : await window.Signal.Migrations.readStickerData(sticker.path); - - return { - title, - url, - image: { - ...sticker, - data, - size: data.byteLength, - contentType: 'image/webp', - }, - }; - } catch (error) { - window.log.error( - 'getStickerPackPreview error:', - error && error.stack ? error.stack : error - ); - return null; - } finally { - if (id) { - await window.Signal.Stickers.removeEphemeralPack(id); - } - } - }, - - async getPreview(url) { - if (window.Signal.LinkPreviews.isStickerPack(url)) { - return this.getStickerPackPreview(url); - } - - let html; - try { - html = await textsecure.messaging.makeProxiedRequest(url); - } catch (error) { - if (error.code >= 300) { - return null; - } - } - - const title = window.Signal.LinkPreviews.getTitleMetaTag(html); - const imageUrl = window.Signal.LinkPreviews.getImageMetaTag(html); - - let image; - let objectUrl; - try { - if (imageUrl) { - if (!Signal.LinkPreviews.isMediaLinkInWhitelist(imageUrl)) { - const primaryDomain = Signal.LinkPreviews.getDomain(url); - const imageDomain = Signal.LinkPreviews.getDomain(imageUrl); - throw new Error( - `imageUrl for domain ${primaryDomain} did not match media whitelist. Domain: ${imageDomain}` - ); - } - - const chunked = await this.makeChunkedRequest(imageUrl); - - // Ensure that this file is either small enough or is resized to meet our - // requirements for attachments - const withBlob = await this.autoScale({ - contentType: chunked.contentType, - file: new Blob([chunked.data], { - type: chunked.contentType, - }), - }); - - const data = await this.arrayBufferFromFile(withBlob.file); - objectUrl = URL.createObjectURL(withBlob.file); - - const dimensions = await Signal.Types.VisualAttachment.getImageDimensions( - { - objectUrl, - logger: window.log, - } - ); - - image = { - data, - size: data.byteLength, - ...dimensions, - contentType: withBlob.file.type, - }; - } - } catch (error) { - // We still want to show the preview if we failed to get an image - window.log.error( - 'getPreview failed to get image for link preview:', - error.message - ); - } finally { - if (objectUrl) { - URL.revokeObjectURL(objectUrl); - } - } - - return { - title, - url, - image, - }; - }, - - async addLinkPreview(url) { - (this.preview || []).forEach(item => { - if (item.url) { - URL.revokeObjectURL(item.url); - } - }); - this.preview = null; - - this.currentlyMatchedLink = url; - this.previewLoading = this.getPreview(url); - const promise = this.previewLoading; - this.renderLinkPreview(); - - try { - const result = await promise; - - if ( - url !== this.currentlyMatchedLink || - promise !== this.previewLoading - ) { - // another request was started, or this was canceled - return; - } - - // If we couldn't pull down the initial URL - if (!result) { - this.excludedPreviewUrls.push(url); - this.removeLinkPreview(); - return; - } - - if (result.image) { - const blob = new Blob([result.image.data], { - type: result.image.contentType, - }); - result.image.url = URL.createObjectURL(blob); - } else if (!result.title) { - // A link preview isn't worth showing unless we have either a title or an image - this.removeLinkPreview(); - return; - } - - this.preview = [result]; - this.renderLinkPreview(); - } catch (error) { - window.log.error( - 'Problem loading link preview, disabling.', - error && error.stack ? error.stack : error - ); - this.disableLinkPreviews = true; - this.removeLinkPreview(); - } - }, - - renderLinkPreview() { - if (this.previewView) { - this.previewView.remove(); - this.previewView = null; - } - if (!this.currentlyMatchedLink) { - return; - } - - const first = (this.preview && this.preview[0]) || null; - const props = { - ...first, - domain: first && window.Signal.LinkPreviews.getDomain(first.url), - isLoaded: Boolean(first), - onClose: () => { - this.disableLinkPreviews = true; - this.removeLinkPreview(); - }, - }; - - this.previewView = new Whisper.ReactWrapperView({ - className: 'preview-wrapper', - Component: window.Signal.Components.StagedLinkPreview, - elCallback: el => - this.$(this.compositionApi.current.attSlotRef.current).prepend(el), - props, - }); - }, - - getLinkPreview() { - // Don't generate link previews if user has turned them off - if (!storage.get('linkPreviews', false)) { - return []; - } - - if (!this.preview) { - return []; - } - - return this.preview.map(item => { - if (item.image) { - // We eliminate the ObjectURL here, unneeded for send or save - return { - ...item, - image: _.omit(item.image, 'url'), - }; - } - - return item; - }); - }, - - // Called whenever the user changes the message composition field. But only - // fires if there's content in the message field after the change. - maybeBumpTyping(messageText) { - if (messageText.length) { - this.model.throttledBumpTyping(); - } - }, - }); -})(); diff --git a/loading.html b/loading.html index ed20d12bc..817ba3732 100644 --- a/loading.html +++ b/loading.html @@ -19,6 +19,7 @@
+ diff --git a/loading_preload.js b/loading_preload.js index 41b62301e..3faab7880 100644 --- a/loading_preload.js +++ b/loading_preload.js @@ -10,3 +10,4 @@ const { locale } = config; const localeMessages = ipcRenderer.sendSync('locale-data'); window.i18n = i18n.setup(locale, localeMessages); +window.Backbone = require('backbone'); diff --git a/permissions_popup.html b/permissions_popup.html index 96922999e..d9a8b3b3f 100644 --- a/permissions_popup.html +++ b/permissions_popup.html @@ -32,6 +32,7 @@ + diff --git a/permissions_popup_preload.js b/permissions_popup_preload.js index fb6f5539e..3f262bbef 100644 --- a/permissions_popup_preload.js +++ b/permissions_popup_preload.js @@ -40,3 +40,4 @@ window.setMediaPermissions = makeSetter('media-permissions'); window.setMediaCameraPermissions = makeSetter('media-camera-permissions'); window.getThemeSetting = makeGetter('theme-setting'); window.setThemeSetting = makeSetter('theme-setting'); +window.Backbone = require('backbone'); diff --git a/preload.js b/preload.js index ee7a28439..db14a01b0 100644 --- a/preload.js +++ b/preload.js @@ -424,6 +424,7 @@ try { window.ReactDOM = require('react-dom'); window.moment = require('moment'); window.PQueue = require('p-queue').default; + window.Backbone = require('backbone'); const Signal = require('./js/modules/signal'); const i18n = require('./js/modules/i18n'); @@ -452,6 +453,10 @@ try { logger: window.log, }); + // these need access to window.Signal: + require('./ts/models/messages'); + require('./ts/models/conversations'); + function wrapWithPromise(fn) { return (...args) => Promise.resolve(fn(...args)); } diff --git a/settings.html b/settings.html index 958b416d2..eb950b1df 100644 --- a/settings.html +++ b/settings.html @@ -164,6 +164,7 @@ + diff --git a/settings_preload.js b/settings_preload.js index cce0b20e2..2ad605c57 100644 --- a/settings_preload.js +++ b/settings_preload.js @@ -118,3 +118,5 @@ function makeSetter(name) { } require('./js/logging'); + +window.Backbone = require('backbone'); diff --git a/sticker-creator/index.html b/sticker-creator/index.html index 5a9c9414f..7de20ec22 100644 --- a/sticker-creator/index.html +++ b/sticker-creator/index.html @@ -7,6 +7,7 @@
+ diff --git a/sticker-creator/preload.js b/sticker-creator/preload.js index d84bfcaf5..b75067501 100644 --- a/sticker-creator/preload.js +++ b/sticker-creator/preload.js @@ -18,6 +18,7 @@ window.getEnvironment = () => config.environment; window.getVersion = () => config.version; window.getGuid = require('uuid/v4'); window.PQueue = require('p-queue').default; +window.Backbone = require('backbone'); window.localeMessages = ipc.sendSync('locale-data'); diff --git a/test/index.html b/test/index.html index 226509123..78c90a5e2 100644 --- a/test/index.html +++ b/test/index.html @@ -332,6 +332,7 @@ + diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index 2fcc26ce0..d13caf798 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -2,10 +2,11 @@ import { debounce, reduce, uniq, without } from 'lodash'; import dataInterface from './sql/Client'; import { ConversationModelCollectionType, - ConversationModelType, - ConversationTypeType, + WhatIsThis, + ConversationAttributesTypeType, } from './model-types.d'; -import { SendOptionsType } from './textsecure/SendMessage'; +import { SendOptionsType, CallbackResultType } from './textsecure/SendMessage'; +import { ConversationModel } from './models/conversations'; const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; @@ -40,7 +41,7 @@ export function start(): void { this.on('add remove change:unreadCount', debouncedUpdateUnreadCount); window.Whisper.events.on('updateUnreadCount', debouncedUpdateUnreadCount); }, - addActive(model: ConversationModelType) { + addActive(model: ConversationModel) { if (model.get('active_at')) { this.add(model); } else { @@ -52,7 +53,7 @@ export function start(): void { 'badge-count-muted-conversations' ); const newUnreadCount = reduce( - this.map((m: ConversationModelType) => + this.map((m: ConversationModel) => !canCountMutedConversations && m.isMuted() ? 0 : m.get('unreadCount') ), (item: number, memo: number) => (item || 0) + memo, @@ -91,7 +92,7 @@ export class ConversationController { this._conversations = conversations; } - get(id?: string | null): ConversationModelType | undefined { + get(id?: string | null): ConversationModel | undefined { if (!this._initialFetchComplete) { throw new Error( 'ConversationController.get() needs complete initial fetch' @@ -103,16 +104,16 @@ export class ConversationController { } dangerouslyCreateAndAdd( - attributes: Partial - ): ConversationModelType { + attributes: Partial + ): ConversationModel { return this._conversations.add(attributes); } getOrCreate( - identifier: string, - type: ConversationTypeType, + identifier: string | null, + type: ConversationAttributesTypeType, additionalInitialProps = {} - ): ConversationModelType { + ): ConversationModel { if (typeof identifier !== 'string') { throw new TypeError("'id' must be a string"); } @@ -202,10 +203,10 @@ export class ConversationController { } async getOrCreateAndWait( - id: string, - type: ConversationTypeType, + id: string | null, + type: ConversationAttributesTypeType, additionalInitialProps = {} - ): Promise { + ): Promise { await this._initialPromise; const conversation = this.getOrCreate(id, type, additionalInitialProps); @@ -217,7 +218,7 @@ export class ConversationController { throw new Error('getOrCreateAndWait: did not get conversation'); } - getConversationId(address: string): string | null { + getConversationId(address: string | null): string | null { if (!address) { return null; } @@ -251,8 +252,8 @@ export class ConversationController { uuid, highTrust, }: { - e164?: string; - uuid?: string; + e164?: string | null; + uuid?: string | null; highTrust?: boolean; }): string | undefined { // Check for at least one parameter being provided. This is necessary @@ -488,8 +489,8 @@ export class ConversationController { } async combineContacts( - current: ConversationModelType, - obsolete: ConversationModelType + current: ConversationModel, + obsolete: ConversationModel ): Promise { const obsoleteId = obsolete.get('id'); const currentId = current.get('id'); @@ -503,7 +504,11 @@ export class ConversationController { 'combineContacts: Copying profile key from old to new contact' ); - await current.setProfileKey(obsolete.get('profileKey')); + const profileKey = obsolete.get('profileKey'); + + if (profileKey) { + await current.setProfileKey(profileKey); + } } window.log.warn( @@ -583,7 +588,7 @@ export class ConversationController { async getConversationForTargetMessage( targetFromId: string, targetTimestamp: number - ): Promise { + ): Promise { const messages = await getMessagesBySentAt(targetTimestamp, { MessageCollection: window.Whisper.MessageCollection, }); @@ -605,11 +610,13 @@ export class ConversationController { return null; } - prepareForSend( - id: string, - options?: unknown + prepareForSend( + id: string | undefined, + options?: WhatIsThis ): { - wrap: (promise: Promise) => Promise; + wrap: ( + promise: Promise + ) => Promise; sendOptions: SendOptionsType | undefined; } { // id is any valid conversation identifier @@ -619,14 +626,14 @@ export class ConversationController { : undefined; const wrap = conversation ? conversation.wrapSend.bind(conversation) - : async (promise: Promise) => promise; + : async (promise: Promise) => promise; return { wrap, sendOptions }; } async getAllGroupsInvolvingId( conversationId: string - ): Promise> { + ): Promise> { const groups = await getAllGroupsInvolvingId(conversationId, { ConversationCollection: window.Whisper.ConversationCollection, }); diff --git a/ts/backboneJquery.ts b/ts/backboneJquery.ts new file mode 100644 index 000000000..6832570c0 --- /dev/null +++ b/ts/backboneJquery.ts @@ -0,0 +1,3 @@ +// we are requiring backbone in preload.js, and we need to tell backbone where +// jquery is after it's loaded. +window.Backbone.$ = window.Backbone.$ || window.$; diff --git a/js/background.js b/ts/background.ts similarity index 76% rename from js/background.js rename to ts/background.ts index c99292b43..80e410647 100644 --- a/js/background.js +++ b/ts/background.ts @@ -1,16 +1,4 @@ -/* global - $, - _, - Backbone, - ConversationController, - MessageController, - getAccountManager, - Signal, - storage, - textsecure, - WebAPI, - Whisper, -*/ +type WhatIsThis = typeof window.WhatIsThis; // eslint-disable-next-line func-names (async function() { @@ -18,17 +6,17 @@ concurrency: 1, timeout: 1000 * 60 * 2, }); - Whisper.deliveryReceiptQueue = new window.PQueue({ + window.Whisper.deliveryReceiptQueue = new window.PQueue({ concurrency: 1, timeout: 1000 * 60 * 2, }); - Whisper.deliveryReceiptQueue.pause(); - Whisper.deliveryReceiptBatcher = window.Signal.Util.createBatcher({ + window.Whisper.deliveryReceiptQueue.pause(); + window.Whisper.deliveryReceiptBatcher = window.Signal.Util.createBatcher({ wait: 500, maxSize: 500, - processBatch: async items => { + processBatch: async (items: WhatIsThis) => { const byConversationId = _.groupBy(items, item => - ConversationController.ensureContactIds({ + window.ConversationController.ensureContactIds({ e164: item.source, uuid: item.sourceUuid, }) @@ -41,18 +29,22 @@ item => item.timestamp ); - const c = ConversationController.get(conversationId); - const uuid = c.get('uuid'); - const e164 = c.get('e164'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const c = window.ConversationController.get(conversationId)!; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const uuid = c.get('uuid')!; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const e164 = c.get('e164')!; c.queueJob(async () => { try { - const { wrap, sendOptions } = ConversationController.prepareForSend( - c.get('id') - ); + const { + wrap, + sendOptions, + } = window.ConversationController.prepareForSend(c.get('id')); // eslint-disable-next-line no-await-in-loop await wrap( - textsecure.messaging.sendDeliveryReceipt( + window.textsecure.messaging.sendDeliveryReceipt( e164, uuid, timestamps, @@ -101,7 +93,7 @@ ]; const LISTENER_DEBOUNCE = 5 * 1000; - let activeHandlers = []; + let activeHandlers: Array = []; let activeTimestamp = Date.now(); window.addEventListener('blur', () => { @@ -150,7 +142,9 @@ clearSelectedMessage(); } if (userChanged) { - userChanged({ interactionMode }); + userChanged({ + interactionMode, + } as WhatIsThis); } }; window.enterMouseMode = () => { @@ -168,7 +162,7 @@ clearSelectedMessage(); } if (userChanged) { - userChanged({ interactionMode }); + userChanged({ interactionMode } as WhatIsThis); } }; @@ -188,13 +182,14 @@ // Load these images now to ensure that they don't flicker on first use window.preloadedImages = []; - function preload(list) { + function preload(list: Array) { for (let index = 0, max = list.length; index < max; index += 1) { const image = new Image(); image.src = `./images/${list[index]}`; window.preloadedImages.push(image); } } + const builtInImages = await window.getBuiltInImages(); preload(builtInImages); @@ -202,11 +197,11 @@ // of preload.js processing window.setImmediate = window.nodeSetImmediate; - const { IdleDetector, MessageDataMigrator } = Signal.Workflow; + const { IdleDetector, MessageDataMigrator } = window.Signal.Workflow; const { removeDatabase: removeIndexedDB, doesDatabaseExist, - } = Signal.IndexedDB; + } = window.Signal.IndexedDB; const { Errors, Message } = window.Signal.Types; const { upgradeMessageSchema, @@ -219,20 +214,21 @@ window.log.info('background page reloaded'); window.log.info('environment:', window.getEnvironment()); - let idleDetector; + let idleDetector: WhatIsThis; let initialLoadComplete = false; let newVersion = false; window.owsDesktopApp = {}; window.document.title = window.getTitle(); - Whisper.KeyChangeListener.init(textsecure.storage.protocol); - textsecure.storage.protocol.on('removePreKey', () => { - getAccountManager().refreshPreKeys(); + window.Whisper.KeyChangeListener.init(window.textsecure.storage.protocol); + window.textsecure.storage.protocol.on('removePreKey', () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + window.getAccountManager()!.refreshPreKeys(); }); - let messageReceiver; - let preMessageReceiverStatus; + let messageReceiver: WhatIsThis; + let preMessageReceiverStatus: WhatIsThis; window.getSocketStatus = () => { if (messageReceiver) { return messageReceiver.getStatus(); @@ -242,31 +238,31 @@ } return -1; }; - Whisper.events = _.clone(Backbone.Events); - let accountManager; + window.Whisper.events = _.clone(window.Backbone.Events); + let accountManager: typeof window.textsecure.AccountManager; window.getAccountManager = () => { if (!accountManager) { - const OLD_USERNAME = storage.get('number_id'); - const USERNAME = storage.get('uuid_id'); - const PASSWORD = storage.get('password'); - accountManager = new textsecure.AccountManager( + const OLD_USERNAME = window.storage.get('number_id'); + const USERNAME = window.storage.get('uuid_id'); + const PASSWORD = window.storage.get('password'); + accountManager = new window.textsecure.AccountManager( USERNAME || OLD_USERNAME, PASSWORD ); accountManager.addEventListener('registration', () => { - const ourNumber = textsecure.storage.user.getNumber(); - const ourUuid = textsecure.storage.user.getUuid(); + const ourNumber = window.textsecure.storage.user.getNumber(); + const ourUuid = window.textsecure.storage.user.getUuid(); const user = { regionCode: window.storage.get('regionCode'), ourNumber, ourUuid, - ourConversationId: ConversationController.getOurConversationId(), + ourConversationId: window.ConversationController.getOurConversationId(), }; - Whisper.events.trigger('userChanged', user); + window.Whisper.events.trigger('userChanged', user); window.Signal.Util.Registration.markDone(); window.log.info('dispatching registration event'); - Whisper.events.trigger('registration_done'); + window.Whisper.events.trigger('registration_done'); }); } return accountManager; @@ -284,7 +280,7 @@ try { await new Promise((resolve, reject) => { - const dialog = new Whisper.ConfirmationDialogView({ + const dialog = new window.Whisper.ConfirmationDialogView({ message: window.i18n('deleteOldIndexedDBData'), okText: window.i18n('deleteOldData'), cancelText: window.i18n('quit'), @@ -320,7 +316,7 @@ } // Set a flag to delete IndexedDB on next startup if it wasn't deleted just now. - // We need to use direct data calls, since storage isn't ready yet. + // We need to use direct data calls, since window.storage isn't ready yet. await window.Signal.Data.createOrUpdateItem({ id: 'indexeddb-delete-needed', value: true, @@ -329,9 +325,9 @@ } window.log.info('Storage fetch'); - storage.fetch(); + window.storage.fetch(); - function mapOldThemeToNew(theme) { + function mapOldThemeToNew(theme: WhatIsThis) { switch (theme) { case 'dark': case 'light': @@ -347,9 +343,9 @@ } // We need this 'first' check because we don't want to start the app up any other time - // than the first time. And storage.fetch() will cause onready() to fire. + // than the first time. And window.storage.fetch() will cause onready() to fire. let first = true; - storage.onready(async () => { + window.storage.onready(async () => { if (!first) { return; } @@ -357,71 +353,74 @@ // These make key operations available to IPC handlers created in preload.js window.Events = { - getDeviceName: () => textsecure.storage.user.getDeviceName(), + getDeviceName: () => window.textsecure.storage.user.getDeviceName(), getThemeSetting: () => - storage.get( + window.storage.get( 'theme-setting', window.platform === 'darwin' ? 'system' : 'light' ), - setThemeSetting: value => { - storage.put('theme-setting', value); + setThemeSetting: (value: WhatIsThis) => { + window.storage.put('theme-setting', value); onChangeTheme(); }, - getHideMenuBar: () => storage.get('hide-menu-bar'), - setHideMenuBar: value => { - storage.put('hide-menu-bar', value); + getHideMenuBar: () => window.storage.get('hide-menu-bar'), + setHideMenuBar: (value: WhatIsThis) => { + window.storage.put('hide-menu-bar', value); window.setAutoHideMenuBar(value); window.setMenuBarVisibility(!value); }, getNotificationSetting: () => - storage.get('notification-setting', 'message'), - setNotificationSetting: value => - storage.put('notification-setting', value), + window.storage.get('notification-setting', 'message'), + setNotificationSetting: (value: WhatIsThis) => + window.storage.put('notification-setting', value), getNotificationDrawAttention: () => - storage.get('notification-draw-attention', true), - setNotificationDrawAttention: value => - storage.put('notification-draw-attention', value), - getAudioNotification: () => storage.get('audio-notification'), - setAudioNotification: value => storage.put('audio-notification', value), + window.storage.get('notification-draw-attention', true), + setNotificationDrawAttention: (value: WhatIsThis) => + window.storage.put('notification-draw-attention', value), + getAudioNotification: () => window.storage.get('audio-notification'), + setAudioNotification: (value: WhatIsThis) => + window.storage.put('audio-notification', value), getCountMutedConversations: () => - storage.get('badge-count-muted-conversations', false), - setCountMutedConversations: value => { - storage.put('badge-count-muted-conversations', value); + window.storage.get('badge-count-muted-conversations', false), + setCountMutedConversations: (value: WhatIsThis) => { + window.storage.put('badge-count-muted-conversations', value); window.Whisper.events.trigger('updateUnreadCount'); }, getCallRingtoneNotification: () => - storage.get('call-ringtone-notification', true), - setCallRingtoneNotification: value => - storage.put('call-ringtone-notification', value), + window.storage.get('call-ringtone-notification', true), + setCallRingtoneNotification: (value: WhatIsThis) => + window.storage.put('call-ringtone-notification', value), getCallSystemNotification: () => - storage.get('call-system-notification', true), - setCallSystemNotification: value => - storage.put('call-system-notification', value), + window.storage.get('call-system-notification', true), + setCallSystemNotification: (value: WhatIsThis) => + window.storage.put('call-system-notification', value), getIncomingCallNotification: () => - storage.get('incoming-call-notification', true), - setIncomingCallNotification: value => - storage.put('incoming-call-notification', value), + window.storage.get('incoming-call-notification', true), + setIncomingCallNotification: (value: WhatIsThis) => + window.storage.put('incoming-call-notification', value), - getSpellCheck: () => storage.get('spell-check', true), - setSpellCheck: value => { - storage.put('spell-check', value); + getSpellCheck: () => window.storage.get('spell-check', true), + setSpellCheck: (value: WhatIsThis) => { + window.storage.put('spell-check', value); }, - getAlwaysRelayCalls: () => storage.get('always-relay-calls'), - setAlwaysRelayCalls: value => storage.put('always-relay-calls', value), + getAlwaysRelayCalls: () => window.storage.get('always-relay-calls'), + setAlwaysRelayCalls: (value: WhatIsThis) => + window.storage.put('always-relay-calls', value), // eslint-disable-next-line eqeqeq - isPrimary: () => textsecure.storage.user.getDeviceId() == '1', + isPrimary: () => window.textsecure.storage.user.getDeviceId() == '1', getSyncRequest: () => new Promise((resolve, reject) => { const syncRequest = window.getSyncRequest(); syncRequest.addEventListener('success', resolve); syncRequest.addEventListener('timeout', reject); }), - getLastSyncTime: () => storage.get('synced_at'), - setLastSyncTime: value => storage.put('synced_at', value), + getLastSyncTime: () => window.storage.get('synced_at'), + setLastSyncTime: (value: WhatIsThis) => + window.storage.put('synced_at', value), addDarkOverlay: () => { if ($('.dark-overlay').length) { @@ -468,7 +467,7 @@ await window.Signal.Data.shutdown(); }, - showStickerPack: async (packId, key) => { + showStickerPack: async (packId: string, key: string) => { // We can get these events even if the user has never linked this instance. if (!window.Signal.Util.Registration.everDone()) { return; @@ -485,16 +484,16 @@ }, }; - const stickerPreviewModalView = new Whisper.ReactWrapperView({ + const stickerPreviewModalView = new window.Whisper.ReactWrapperView({ className: 'sticker-preview-modal-wrapper', - JSX: Signal.State.Roots.createStickerPreviewModal( + JSX: window.Signal.State.Roots.createStickerPreviewModal( window.reduxStore, props ), }); }, - installStickerPack: async (packId, key) => { + installStickerPack: async (packId: string, key: string) => { window.Signal.Stickers.downloadStickerPack(packId, key, { finalStatus: 'installed', }); @@ -503,8 +502,8 @@ // How long since we were last running? const now = Date.now(); - const lastHeartbeat = storage.get('lastHeartbeat'); - await storage.put('lastStartup', Date.now()); + const lastHeartbeat = window.storage.get('lastHeartbeat'); + await window.storage.put('lastStartup', Date.now()); const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000; if (lastHeartbeat > 0 && now - lastHeartbeat > THIRTY_DAYS) { @@ -512,14 +511,17 @@ } // Start heartbeat timer - storage.put('lastHeartbeat', Date.now()); + window.storage.put('lastHeartbeat', Date.now()); const TWELVE_HOURS = 12 * 60 * 60 * 1000; - setInterval(() => storage.put('lastHeartbeat', Date.now()), TWELVE_HOURS); + setInterval( + () => window.storage.put('lastHeartbeat', Date.now()), + TWELVE_HOURS + ); const currentVersion = window.getVersion(); - const lastVersion = storage.get('version'); + const lastVersion = window.storage.get('version'); newVersion = !lastVersion || currentVersion !== lastVersion; - await storage.put('version', currentVersion); + await window.storage.put('version', currentVersion); if (newVersion && lastVersion) { window.log.info( @@ -532,14 +534,14 @@ if (window.isBeforeVersion(lastVersion, 'v1.29.2-beta.1')) { // Stickers flags await Promise.all([ - storage.put('showStickersIntroduction', true), - storage.put('showStickerPickerHint', true), + window.storage.put('showStickersIntroduction', true), + window.storage.put('showStickerPickerHint', true), ]); } if (window.isBeforeVersion(lastVersion, 'v1.26.0')) { // Ensure that we re-register our support for sealed sender - await storage.put( + await window.storage.put( 'hasRegisterSupportForUnauthenticatedDelivery', false ); @@ -590,8 +592,8 @@ if (!isMigrationWithIndexComplete) { const batchWithIndex = await MessageDataMigrator.processNext({ - BackboneMessage: Whisper.Message, - BackboneMessageCollection: Whisper.MessageCollection, + BackboneMessage: window.Whisper.Message, + BackboneMessageCollection: window.Whisper.MessageCollection, numMessagesPerBatch: NUM_MESSAGES_PER_BATCH, upgradeMessageSchema, getMessagesNeedingUpgrade: @@ -612,8 +614,9 @@ window.Signal.conversationControllerStart(); - // We start this up before ConversationController.load() to ensure that our feature - // flags are represented in the cached props we generate on load of each convo. + // We start this up before window.ConversationController.load() to + // ensure that our feature flags are represented in the cached props + // we generate on load of each convo. window.Signal.RemoteConfig.initRemoteConfig(); // On startup, we don't want to wait for the remote config fetch if we've already @@ -629,12 +632,12 @@ try { await Promise.all([ - ConversationController.load(), - Signal.Stickers.load(), - Signal.Emojis.load(), - textsecure.storage.protocol.hydrateCaches(), + window.ConversationController.load(), + window.Signal.Stickers.load(), + window.Signal.Emojis.load(), + window.textsecure.storage.protocol.hydrateCaches(), ]); - await ConversationController.checkForConflicts(); + await window.ConversationController.checkForConflicts(); } catch (error) { window.log.error( 'background.js: ConversationController failed to load:', @@ -663,12 +666,12 @@ const conversations = convoCollection.map( conversation => conversation.cachedProps ); - const ourNumber = textsecure.storage.user.getNumber(); - const ourUuid = textsecure.storage.user.getUuid(); - const ourConversationId = ConversationController.getOurConversationId(); + const ourNumber = window.textsecure.storage.user.getNumber(); + const ourUuid = window.textsecure.storage.user.getUuid(); + const ourConversationId = window.ConversationController.getOurConversationId(); const initialState = { conversations: { - conversationLookup: Signal.Util.makeLookup(conversations, 'id'), + conversationLookup: window.Signal.Util.makeLookup(conversations, 'id'), messagesByConversation: {}, messagesLookup: {}, selectedConversation: null, @@ -676,9 +679,9 @@ selectedMessageCounter: 0, showArchived: false, }, - emojis: Signal.Emojis.getInitialState(), - items: storage.getItemsState(), - stickers: Signal.Stickers.getInitialState(), + emojis: window.Signal.Emojis.getInitialState(), + items: window.storage.getItemsState(), + stickers: window.Signal.Stickers.getInitialState(), user: { attachmentsPath: window.baseAttachmentsPath, stickersPath: window.baseStickersPath, @@ -693,52 +696,52 @@ }, }; - const store = Signal.State.createStore(initialState); + const store = window.Signal.State.createStore(initialState); window.reduxStore = store; - const actions = {}; + const actions: WhatIsThis = {}; window.reduxActions = actions; // Binding these actions to our redux store and exposing them allows us to update // redux when things change in the backbone world. - actions.calling = Signal.State.bindActionCreators( - Signal.State.Ducks.calling.actions, + actions.calling = window.Signal.State.bindActionCreators( + window.Signal.State.Ducks.calling.actions, store.dispatch ); - actions.conversations = Signal.State.bindActionCreators( - Signal.State.Ducks.conversations.actions, + actions.conversations = window.Signal.State.bindActionCreators( + window.Signal.State.Ducks.conversations.actions, store.dispatch ); - actions.emojis = Signal.State.bindActionCreators( - Signal.State.Ducks.emojis.actions, + actions.emojis = window.Signal.State.bindActionCreators( + window.Signal.State.Ducks.emojis.actions, store.dispatch ); - actions.expiration = Signal.State.bindActionCreators( - Signal.State.Ducks.expiration.actions, + actions.expiration = window.Signal.State.bindActionCreators( + window.Signal.State.Ducks.expiration.actions, store.dispatch ); - actions.items = Signal.State.bindActionCreators( - Signal.State.Ducks.items.actions, + actions.items = window.Signal.State.bindActionCreators( + window.Signal.State.Ducks.items.actions, store.dispatch ); - actions.network = Signal.State.bindActionCreators( - Signal.State.Ducks.network.actions, + actions.network = window.Signal.State.bindActionCreators( + window.Signal.State.Ducks.network.actions, store.dispatch ); - actions.updates = Signal.State.bindActionCreators( - Signal.State.Ducks.updates.actions, + actions.updates = window.Signal.State.bindActionCreators( + window.Signal.State.Ducks.updates.actions, store.dispatch ); - actions.user = Signal.State.bindActionCreators( - Signal.State.Ducks.user.actions, + actions.user = window.Signal.State.bindActionCreators( + window.Signal.State.Ducks.user.actions, store.dispatch ); - actions.search = Signal.State.bindActionCreators( - Signal.State.Ducks.search.actions, + actions.search = window.Signal.State.bindActionCreators( + window.Signal.State.Ducks.search.actions, store.dispatch ); - actions.stickers = Signal.State.bindActionCreators( - Signal.State.Ducks.stickers.actions, + actions.stickers = window.Signal.State.bindActionCreators( + window.Signal.State.Ducks.stickers.actions, store.dispatch ); @@ -765,23 +768,26 @@ }); convoCollection.on('reset', removeAllConversations); - Whisper.events.on('messageExpired', messageExpired); - Whisper.events.on('userChanged', userChanged); + window.Whisper.events.on('messageExpired', messageExpired); + window.Whisper.events.on('userChanged', userChanged); - let shortcutGuideView = null; + let shortcutGuideView: WhatIsThis | null = null; window.showKeyboardShortcuts = () => { if (!shortcutGuideView) { - shortcutGuideView = new Whisper.ReactWrapperView({ + shortcutGuideView = new window.Whisper.ReactWrapperView({ className: 'shortcut-guide-wrapper', - JSX: Signal.State.Roots.createShortcutGuideModal(window.reduxStore, { - close: () => { - if (shortcutGuideView) { - shortcutGuideView.remove(); - shortcutGuideView = null; - } - }, - }), + JSX: window.Signal.State.Roots.createShortcutGuideModal( + window.reduxStore, + { + close: () => { + if (shortcutGuideView) { + shortcutGuideView.remove(); + shortcutGuideView = null; + } + }, + } + ), onClose: () => { shortcutGuideView = null; }, @@ -789,9 +795,9 @@ } }; - function getConversationByIndex(index) { + function getConversationByIndex(index: WhatIsThis) { const state = store.getState(); - const lists = Signal.State.Selectors.conversations.getLeftPaneLists( + const lists = window.Signal.State.Selectors.conversations.getLeftPaneLists( state ); const toSearch = state.conversations.showArchived @@ -807,9 +813,13 @@ return null; } - function findConversation(conversationId, direction, unreadOnly) { + function findConversation( + conversationId: WhatIsThis, + direction: WhatIsThis, + unreadOnly: WhatIsThis + ) { const state = store.getState(); - const lists = Signal.State.Selectors.conversations.getLeftPaneLists( + const lists = window.Signal.State.Selectors.conversations.getLeftPaneLists( state ); const toSearch = state.conversations.showArchived @@ -820,7 +830,9 @@ let startIndex; if (conversationId) { - const index = toSearch.findIndex(item => item.id === conversationId); + const index = toSearch.findIndex( + (item: WhatIsThis) => item.id === conversationId + ); if (index >= 0) { startIndex = index + increment; } @@ -845,7 +857,7 @@ return null; } - const NUMBERS = { + const NUMBERS: Record = { '1': 1, '2': 2, '3': 3, @@ -868,8 +880,10 @@ const state = store.getState(); const selectedId = state.conversations.selectedConversation; - const conversation = ConversationController.get(selectedId); - const isSearching = Signal.State.Selectors.search.isSearching(state); + const conversation = window.ConversationController.get(selectedId); + const isSearching = window.Signal.State.Selectors.search.isSearching( + state + ); // NAVIGATION @@ -889,7 +903,7 @@ window.enterKeyboardMode(); const focusedElement = document.activeElement; - const targets = [ + const targets: Array = [ document.querySelector('.module-main-header .module-avatar-button'), document.querySelector('.module-left-pane__to-inbox-button'), document.querySelector('.module-main-header__search__input'), @@ -930,7 +944,8 @@ } } - targets[index].focus(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + targets[index]!.focus(); } // Cancel out of keyboard shortcut screen - has first precedence @@ -948,13 +963,16 @@ // Why? Because React's synthetic events can cause events to be handled twice. const target = document.activeElement; + // We might want to use NamedNodeMap.getNamedItem('class') + /* eslint-disable @typescript-eslint/no-explicit-any */ if ( target && target.attributes && - target.attributes.class && - target.attributes.class.value + (target.attributes as any).class && + (target.attributes as any).class.value ) { - const className = target.attributes.class.value; + const className = (target.attributes as any).class.value; + /* eslint-enable @typescript-eslint/no-explicit-any */ // These want to handle events internally @@ -1012,10 +1030,10 @@ } } - // Close Backbone-based confirmation dialog - if (Whisper.activeConfirmationView && key === 'Escape') { - Whisper.activeConfirmationView.remove(); - Whisper.activeConfirmationView = null; + // Close window.Backbone-based confirmation dialog + if (window.Whisper.activeConfirmationView && key === 'Escape') { + window.Whisper.activeConfirmationView.remove(); + window.Whisper.activeConfirmationView = null; event.preventDefault(); event.stopPropagation(); return; @@ -1031,7 +1049,9 @@ // Change currently selected conversation by index if (!isSearching && commandOrCtrl && NUMBERS[key]) { - const targetId = getConversationByIndex(NUMBERS[key] - 1); + const targetId = getConversationByIndex( + (NUMBERS[key] as WhatIsThis) - 1 + ); if (targetId) { window.Whisper.events.trigger('showConversation', targetId); @@ -1133,12 +1153,14 @@ // to fake up a mouse event to get the menu to show somewhere other than (0,0). const { x, y, width, height } = button.getBoundingClientRect(); const mouseEvent = document.createEvent('MouseEvents'); + // Types do not match signature + /* eslint-disable @typescript-eslint/no-explicit-any */ mouseEvent.initMouseEvent( 'click', true, // bubbles false, // cancelable - null, // view - null, // detail + null as any, // view + null as any, // detail 0, // screenX, 0, // screenY, x + width / 2, @@ -1147,9 +1169,10 @@ false, // altKey, false, // shiftKey, false, // metaKey, - false, // button, + false as any, // button, document.body ); + /* eslint-enable @typescript-eslint/no-explicit-any */ button.dispatchEvent(mouseEvent); @@ -1245,8 +1268,8 @@ ) { conversation.setArchived(true); conversation.trigger('unload', 'keyboard shortcut archive'); - Whisper.ToastView.show( - Whisper.ConversationArchivedToast, + window.Whisper.ToastView.show( + window.Whisper.ConversationArchivedToast, document.body ); @@ -1254,12 +1277,14 @@ // 'none,' or the top-level body element. This resets it to the left pane, // whether in the normal conversation list or search results. if (document.activeElement === document.body) { - const leftPaneEl = document.querySelector('.module-left-pane__list'); + const leftPaneEl: HTMLElement | null = document.querySelector( + '.module-left-pane__list' + ); if (leftPaneEl) { leftPaneEl.focus(); } - const searchResultsEl = document.querySelector( + const searchResultsEl: HTMLElement | null = document.querySelector( '.module-search-results' ); if (searchResultsEl) { @@ -1279,8 +1304,8 @@ (key === 'u' || key === 'U') ) { conversation.setArchived(false); - Whisper.ToastView.show( - Whisper.ConversationUnarchivedToast, + window.Whisper.ToastView.show( + window.Whisper.ConversationUnarchivedToast, document.body ); @@ -1429,14 +1454,14 @@ }); } - Whisper.events.on('setupAsNewDevice', () => { + window.Whisper.events.on('setupAsNewDevice', () => { const { appView } = window.owsDesktopApp; if (appView) { appView.openInstaller(); } }); - Whisper.events.on('setupAsStandalone', () => { + window.Whisper.events.on('setupAsStandalone', () => { const { appView } = window.owsDesktopApp; if (appView) { appView.openStandalone(); @@ -1449,7 +1474,7 @@ window.log.info('Cleanup: starting...'); const messagesForCleanup = await window.Signal.Data.getOutgoingWithoutExpiresAt( { - MessageCollection: Whisper.MessageCollection, + MessageCollection: window.Whisper.MessageCollection, } ); window.log.info( @@ -1481,7 +1506,7 @@ window.log.info(`Cleanup: Deleting unsent message ${sentAt}`); await window.Signal.Data.removeMessage(message.id, { - Message: Whisper.Message, + Message: window.Whisper.Message, }); const conversation = message.getConversation(); if (conversation) { @@ -1492,20 +1517,20 @@ window.log.info('Cleanup: complete'); window.log.info('listening for registration events'); - Whisper.events.on('registration_done', () => { + window.Whisper.events.on('registration_done', () => { window.log.info('handling registration event'); connect(true); }); cancelInitializationMessage(); - const appView = new Whisper.AppView({ + const appView = new window.Whisper.AppView({ el: $('body'), }); window.owsDesktopApp.appView = appView; - Whisper.WallClockListener.init(Whisper.events); - Whisper.ExpiringMessagesListener.init(Whisper.events); - Whisper.TapToViewMessagesListener.init(Whisper.events); + window.Whisper.WallClockListener.init(window.Whisper.events); + window.Whisper.ExpiringMessagesListener.init(window.Whisper.events); + window.Whisper.TapToViewMessagesListener.init(window.Whisper.events); if (window.Signal.Util.Registration.everDone()) { connect(); @@ -1516,28 +1541,30 @@ appView.openInstaller(); } - Whisper.events.on('showDebugLog', () => { + window.Whisper.events.on('showDebugLog', () => { appView.openDebugLog(); }); - Whisper.events.on('unauthorized', () => { + window.Whisper.events.on('unauthorized', () => { appView.inboxView.networkStatusView.update(); }); - Whisper.events.on('contactsync', () => { + window.Whisper.events.on('contactsync', () => { if (appView.installView) { appView.openInbox(); } }); - window.registerForActive(() => Whisper.Notifications.clear()); - window.addEventListener('unload', () => Whisper.Notifications.fastClear()); + window.registerForActive(() => window.Whisper.Notifications.clear()); + window.addEventListener('unload', () => + window.Whisper.Notifications.fastClear() + ); - Whisper.events.on('showConversation', (id, messageId) => { + window.Whisper.events.on('showConversation', (id, messageId) => { if (appView) { appView.openConversation(id, messageId); } }); - Whisper.Notifications.on('click', (id, messageId) => { + window.Whisper.Notifications.on('click', (id, messageId) => { window.showWindow(); if (id) { appView.openConversation(id, messageId); @@ -1609,10 +1636,10 @@ window.textsecure.protobuf.ManifestRecord.Identifier.Type.GROUPV2 ); - // Erase current manifest version so we re-process storage service data + // Erase current manifest version so we re-process window.storage service data await window.storage.remove('manifestVersion'); - // Kick off storage service fetch to grab GroupV2 information + // Kick off window.storage service fetch to grab GroupV2 information await window.Signal.Services.runStorageServiceSyncJob(); // This is a one-time thing @@ -1635,10 +1662,13 @@ } window.getSyncRequest = () => - new textsecure.SyncRequest(textsecure.messaging, messageReceiver); + new window.textsecure.SyncRequest( + window.textsecure.messaging, + messageReceiver + ); - let disconnectTimer = null; - let reconnectTimer = null; + let disconnectTimer: WhatIsThis | null = null; + let reconnectTimer: WhatIsThis | null = null; function onOffline() { window.log.info('offline'); @@ -1691,7 +1721,7 @@ } let connectCount = 0; - async function connect(firstRun) { + async function connect(firstRun?: boolean) { window.log.info('connect', { firstRun, connectCount }); if (reconnectTimer) { @@ -1727,12 +1757,12 @@ messageReceiver = null; } - const OLD_USERNAME = storage.get('number_id'); - const USERNAME = storage.get('uuid_id'); - const PASSWORD = storage.get('password'); - const mySignalingKey = storage.get('signaling_key'); + const OLD_USERNAME = window.storage.get('number_id'); + const USERNAME = window.storage.get('uuid_id'); + const PASSWORD = window.storage.get('password'); + const mySignalingKey = window.storage.get('signaling_key'); - window.textsecure.messaging = new textsecure.MessageSender( + window.textsecure.messaging = new window.textsecure.MessageSender( USERNAME || OLD_USERNAME, PASSWORD ); @@ -1753,18 +1783,19 @@ if (window.Signal.RemoteConfig.isEnabled('desktop.cds')) { const lonelyE164s = window .getConversations() - .filter( - c => + .filter(c => + Boolean( c.isPrivate() && - c.get('e164') && - !c.get('uuid') && - !c.isEverUnregistered() + c.get('e164') && + !c.get('uuid') && + !c.isEverUnregistered() + ) ) .map(c => c.get('e164')); if (lonelyE164s.length > 0) { - const lookup = await textsecure.messaging.getUuidsForE164s( - lonelyE164s + const lookup = await window.textsecure.messaging.getUuidsForE164s( + lonelyE164s as WhatIsThis ); const e164s = Object.keys(lookup); e164s.forEach(e164 => { @@ -1797,17 +1828,17 @@ serverTrustRoot: window.getServerTrustRoot(), }; - Whisper.deliveryReceiptQueue.pause(); // avoid flood of delivery receipts until we catch up - Whisper.Notifications.disable(); // avoid notification flood until empty + window.Whisper.deliveryReceiptQueue.pause(); // avoid flood of delivery receipts until we catch up + window.Whisper.Notifications.disable(); // avoid notification flood until empty // initialize the socket and start listening for messages window.log.info('Initializing socket and listening for messages'); - messageReceiver = new textsecure.MessageReceiver( + messageReceiver = new window.textsecure.MessageReceiver( OLD_USERNAME, USERNAME, PASSWORD, mySignalingKey, - options + options as WhatIsThis ); window.textsecure.messageReceiver = messageReceiver; @@ -1815,8 +1846,8 @@ preMessageReceiverStatus = null; - function addQueuedEventListener(name, handler) { - messageReceiver.addEventListener(name, (...args) => + function addQueuedEventListener(name: WhatIsThis, handler: WhatIsThis) { + messageReceiver.addEventListener(name, (...args: Array) => eventHandlerQueue.add(async () => { try { await handler(...args); @@ -1825,7 +1856,7 @@ // this event itself when complete. // error: Error processing (below) also has its own queue and self-trigger. if (name !== 'message' && name !== 'sent' && name !== 'error') { - Whisper.events.trigger('incrementProgress'); + window.Whisper.events.trigger('incrementProgress'); } } }) @@ -1869,13 +1900,14 @@ connectCount === 1 && newVersion && // eslint-disable-next-line eqeqeq - textsecure.storage.user.getDeviceId() != '1' + window.textsecure.storage.user.getDeviceId() != '1' ) { window.log.info('Boot after upgrading. Requesting contact sync'); window.getSyncRequest(); try { - const manager = window.getAccountManager(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const manager = window.getAccountManager()!; await Promise.all([ manager.maybeUpdateDeviceName(), manager.maybeDeleteSignalingKey(), @@ -1889,14 +1921,14 @@ } const udSupportKey = 'hasRegisterSupportForUnauthenticatedDelivery'; - if (!storage.get(udSupportKey)) { - const server = WebAPI.connect({ + if (!window.storage.get(udSupportKey)) { + const server = window.WebAPI.connect({ username: USERNAME || OLD_USERNAME, password: PASSWORD, }); try { await server.registerSupportForUnauthenticatedDelivery(); - storage.put(udSupportKey, true); + window.storage.put(udSupportKey, true); } catch (error) { window.log.error( 'Error: Unable to register for unauthenticated delivery support.', @@ -1907,16 +1939,16 @@ const hasRegisteredGV23Support = 'hasRegisteredGV23Support'; if ( - !storage.get(hasRegisteredGV23Support) && - textsecure.storage.user.getUuid() + !window.storage.get(hasRegisteredGV23Support) && + window.textsecure.storage.user.getUuid() ) { - const server = WebAPI.connect({ + const server = window.WebAPI.connect({ username: USERNAME || OLD_USERNAME, password: PASSWORD, }); try { await server.registerCapabilities({ 'gv2-3': true }); - storage.put(hasRegisteredGV23Support, true); + window.storage.put(hasRegisteredGV23Support, true); } catch (error) { window.log.error( 'Error: Unable to register support for GV2.', @@ -1925,19 +1957,22 @@ } } - const deviceId = textsecure.storage.user.getDeviceId(); + const deviceId = window.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({ + if (!window.textsecure.storage.user.getUuid()) { + const server = window.WebAPI.connect({ username: OLD_USERNAME, password: PASSWORD, }); try { const { uuid } = await server.whoami(); - textsecure.storage.user.setUuidAndDeviceId(uuid, deviceId); - const ourNumber = textsecure.storage.user.getNumber(); - const me = await ConversationController.getOrCreateAndWait( + window.textsecure.storage.user.setUuidAndDeviceId( + uuid, + deviceId as WhatIsThis + ); + const ourNumber = window.textsecure.storage.user.getNumber(); + const me = await window.ConversationController.getOrCreateAndWait( ourNumber, 'private' ); @@ -1951,37 +1986,40 @@ } if (firstRun === true && deviceId !== '1') { - const hasThemeSetting = Boolean(storage.get('theme-setting')); - if (!hasThemeSetting && textsecure.storage.get('userAgent') === 'OWI') { - storage.put('theme-setting', 'ios'); + const hasThemeSetting = Boolean(window.storage.get('theme-setting')); + if ( + !hasThemeSetting && + window.textsecure.storage.get('userAgent') === 'OWI' + ) { + window.storage.put('theme-setting', 'ios'); onChangeTheme(); } - const syncRequest = new textsecure.SyncRequest( - textsecure.messaging, + const syncRequest = new window.textsecure.SyncRequest( + window.textsecure.messaging, messageReceiver ); - Whisper.events.trigger('contactsync:begin'); + window.Whisper.events.trigger('contactsync:begin'); syncRequest.addEventListener('success', () => { window.log.info('sync successful'); - storage.put('synced_at', Date.now()); - Whisper.events.trigger('contactsync'); + window.storage.put('synced_at', Date.now()); + window.Whisper.events.trigger('contactsync'); }); syncRequest.addEventListener('timeout', () => { window.log.error('sync timed out'); - Whisper.events.trigger('contactsync'); + window.Whisper.events.trigger('contactsync'); }); - const ourId = ConversationController.getOurConversationId(); - const { wrap, sendOptions } = ConversationController.prepareForSend( - ourId, - { - syncMessage: true, - } - ); + const ourId = window.ConversationController.getOurConversationId(); + const { + wrap, + sendOptions, + } = window.ConversationController.prepareForSend(ourId, { + syncMessage: true, + }); const installedStickerPacks = window.Signal.Stickers.getInstalledStickerPacks(); if (installedStickerPacks.length) { - const operations = installedStickerPacks.map(pack => ({ + const operations = installedStickerPacks.map((pack: WhatIsThis) => ({ packId: pack.id, packKey: pack.key, installed: true, @@ -2001,7 +2039,7 @@ } } - storage.onready(async () => { + window.storage.onready(async () => { idleDetector.start(); }); } @@ -2029,8 +2067,8 @@ window.log.info( 'waitForEmptyEventQueue: Waiting for MessageReceiver empty event...' ); - let resolve; - let reject; + let resolve: WhatIsThis; + let reject: WhatIsThis; const promise = new Promise((innerResolve, innerReject) => { resolve = innerResolve; reject = innerReject; @@ -2066,37 +2104,41 @@ window.readyForUpdates(); // Start listeners here, after we get through our queue. - Whisper.RotateSignedPreKeyListener.init(Whisper.events, newVersion); + window.Whisper.RotateSignedPreKeyListener.init( + window.Whisper.events, + newVersion + ); window.Signal.RefreshSenderCertificate.initialize({ - events: Whisper.events, - storage, + events: window.Whisper.events, + storage: window.storage, navigator, logger: window.log, }); - let interval = setInterval(() => { + let interval: NodeJS.Timer | null = setInterval(() => { const view = window.owsDesktopApp.appView; if (view) { - clearInterval(interval); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + clearInterval(interval!); interval = null; view.onEmpty(); } }, 500); - Whisper.deliveryReceiptQueue.start(); - Whisper.Notifications.enable(); + window.Whisper.deliveryReceiptQueue.start(); + window.Whisper.Notifications.enable(); } function onReconnect() { // We disable notifications on first connect, but the same applies to reconnect. In // scenarios where we're coming back from sleep, we can get offline/online events // very fast, and it looks like a network blip. But we need to suppress // notifications in these scenarios too. So we listen for 'reconnect' events. - Whisper.deliveryReceiptQueue.pause(); - Whisper.Notifications.disable(); + window.Whisper.deliveryReceiptQueue.pause(); + window.Whisper.Notifications.disable(); } let initialStartupCount = 0; - Whisper.events.on('incrementProgress', incrementProgress); + window.Whisper.events.on('incrementProgress', incrementProgress); function incrementProgress() { initialStartupCount += 1; @@ -2115,12 +2157,12 @@ } } - Whisper.events.on('manualConnect', manualConnect); + window.Whisper.events.on('manualConnect', manualConnect); function manualConnect() { connect(); } - function onConfiguration(ev) { + function onConfiguration(ev: WhatIsThis) { ev.confirm(); const { configuration } = ev; @@ -2131,47 +2173,47 @@ linkPreviews, } = configuration; - storage.put('read-receipt-setting', readReceipts); + window.storage.put('read-receipt-setting', readReceipts); if ( unidentifiedDeliveryIndicators === true || unidentifiedDeliveryIndicators === false ) { - storage.put( + window.storage.put( 'unidentifiedDeliveryIndicators', unidentifiedDeliveryIndicators ); } if (typingIndicators === true || typingIndicators === false) { - storage.put('typingIndicators', typingIndicators); + window.storage.put('typingIndicators', typingIndicators); } if (linkPreviews === true || linkPreviews === false) { - storage.put('linkPreviews', linkPreviews); + window.storage.put('linkPreviews', linkPreviews); } } - function onTyping(ev) { + function onTyping(ev: WhatIsThis) { // Note: this type of message is automatically removed from cache in MessageReceiver const { typing, sender, senderUuid, senderDevice } = ev; const { groupId, groupV2Id, started } = typing || {}; // We don't do anything with incoming typing messages if the setting is disabled - if (!storage.get('typingIndicators')) { + if (!window.storage.get('typingIndicators')) { return; } - const senderId = ConversationController.ensureContactIds({ + const senderId = window.ConversationController.ensureContactIds({ e164: sender, uuid: senderUuid, highTrust: true, }); - const conversation = ConversationController.get( + const conversation = window.ConversationController.get( groupV2Id || groupId || senderId ); - const ourId = ConversationController.getOurConversationId(); + const ourId = window.ConversationController.getOurConversationId(); if (!conversation) { window.log.warn( @@ -2181,7 +2223,8 @@ } // We drop typing notifications in groups we're not a part of - if (!conversation.isPrivate() && !conversation.hasMember(ourId)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (!conversation.isPrivate() && !conversation.hasMember(ourId!)) { window.log.warn( `Received typing indicator for group ${conversation.idForLogging()}, which we're not a part of. Dropping.` ); @@ -2195,15 +2238,15 @@ senderUuid, senderId, senderDevice, - }); + } as WhatIsThis); } - async function onStickerPack(ev) { + async function onStickerPack(ev: WhatIsThis) { ev.confirm(); const packs = ev.stickerPacks || []; - packs.forEach(pack => { + packs.forEach((pack: WhatIsThis) => { const { id, key, isInstall, isRemove } = pack || {}; if (!id || !key || (!isInstall && !isRemove)) { @@ -2234,42 +2277,44 @@ }); } - async function onContactReceived(ev) { + async function onContactReceived(ev: WhatIsThis) { const details = ev.contactDetails; if ( (details.number && - details.number === textsecure.storage.user.getNumber()) || - (details.uuid && details.uuid === textsecure.storage.user.getUuid()) + details.number === window.textsecure.storage.user.getNumber()) || + (details.uuid && + details.uuid === window.textsecure.storage.user.getUuid()) ) { // special case for syncing details about ourselves if (details.profileKey) { window.log.info('Got sync message with our own profile key'); - storage.put('profileKey', details.profileKey); + window.storage.put('profileKey', details.profileKey); } } - const c = new Whisper.Conversation({ + const c = new window.Whisper.Conversation({ e164: details.number, uuid: details.uuid, type: 'private', - }); + } as WhatIsThis); const validationError = c.validate(); if (validationError) { window.log.error( 'Invalid contact received:', - Errors.toLogFormat(validationError) + Errors.toLogFormat(validationError as WhatIsThis) ); return; } try { - const detailsId = ConversationController.ensureContactIds({ + const detailsId = window.ConversationController.ensureContactIds({ e164: details.number, uuid: details.uuid, highTrust: true, }); - const conversation = ConversationController.get(detailsId); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const conversation = window.ConversationController.get(detailsId)!; let activeAt = conversation.get('active_at'); // The idea is to make any new contact show up in the left pane. If @@ -2328,7 +2373,7 @@ const { expireTimer } = details; const isValidExpireTimer = typeof expireTimer === 'number'; if (isValidExpireTimer) { - const ourId = ConversationController.getOurConversationId(); + const ourId = window.ConversationController.getOurConversationId(); const receivedAt = Date.now(); await conversation.updateExpirationTimer( @@ -2350,7 +2395,7 @@ destinationUuid: verified.destinationUuid, identityKey: verified.identityKey.toArrayBuffer(), }; - verifiedEvent.viaContactSync = true; + (verifiedEvent as WhatIsThis).viaContactSync = true; await onVerified(verifiedEvent); } @@ -2367,7 +2412,7 @@ } // Note: this handler is only for v1 groups received via 'group sync' messages - async function onGroupReceived(ev) { + async function onGroupReceived(ev: WhatIsThis) { const details = ev.groupDetails; const { id } = details; @@ -2380,23 +2425,24 @@ return; } - const conversation = await ConversationController.getOrCreateAndWait( + const conversation = await window.ConversationController.getOrCreateAndWait( id, 'group' ); - if (conversation.get('groupVersion') > 1) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (conversation.get('groupVersion')! > 1) { window.log.warn( 'Got group sync for v2 group: ', - conversation.idForLoggoing() + conversation.idForLogging() ); return; } - const memberConversations = details.membersE164.map(e164 => - ConversationController.getOrCreate(e164, 'private') + const memberConversations = details.membersE164.map((e164: WhatIsThis) => + window.ConversationController.getOrCreate(e164, 'private') ); - const members = memberConversations.map(c => c.get('id')); + const members = memberConversations.map((c: WhatIsThis) => c.get('id')); const updates = { name: details.name, @@ -2404,7 +2450,7 @@ color: details.color, type: 'group', inbox_position: details.inboxPosition, - }; + } as WhatIsThis; if (details.active) { const activeAt = conversation.get('active_at'); @@ -2460,7 +2506,7 @@ const receivedAt = Date.now(); await conversation.updateExpirationTimer( expireTimer, - ConversationController.getOurConversationId(), + window.ConversationController.getOurConversationId(), receivedAt, { fromSync: true, @@ -2473,9 +2519,9 @@ data, confirm, messageDescriptor, - }) { + }: WhatIsThis) { const profileKey = data.message.profileKey.toString('base64'); - const sender = await ConversationController.get(messageDescriptor.id); + const sender = window.ConversationController.get(messageDescriptor.id); if (sender) { // Will do the save for us @@ -2486,10 +2532,14 @@ } // Matches event data from `libtextsecure` `MessageReceiver::handleDataMessage`: - const getDescriptorForReceived = ({ message, source, sourceUuid }) => { + const getDescriptorForReceived = ({ + message, + source, + sourceUuid, + }: WhatIsThis) => { if (message.groupV2) { const { id } = message.groupV2; - const conversationId = ConversationController.ensureGroup(id, { + const conversationId = window.ConversationController.ensureGroup(id, { groupVersion: 2, masterKey: message.groupV2.masterKey, secretParams: message.groupV2.secretParams, @@ -2503,13 +2553,13 @@ } if (message.group) { const { id } = message.group; - const fromContactId = ConversationController.ensureContactIds({ + const fromContactId = window.ConversationController.ensureContactIds({ e164: source, uuid: sourceUuid, highTrust: true, }); - const conversationId = ConversationController.ensureGroup(id, { + const conversationId = window.ConversationController.ensureGroup(id, { addedBy: fromContactId, }); @@ -2521,7 +2571,7 @@ return { type: Message.PRIVATE, - id: ConversationController.ensureContactIds({ + id: window.ConversationController.ensureContactIds({ e164: source, uuid: sourceUuid, highTrust: true, @@ -2532,12 +2582,12 @@ // Note: We do very little in this function, since everything in handleDataMessage is // inside a conversation-specific queue(). Any code here might run before an earlier // message is processed in handleDataMessage(). - function onMessageReceived(event) { + function onMessageReceived(event: WhatIsThis) { const { data, confirm } = event; const messageDescriptor = getDescriptorForReceived(data); - const { PROFILE_KEY_UPDATE } = textsecure.protobuf.DataMessage.Flags; + const { PROFILE_KEY_UPDATE } = window.textsecure.protobuf.DataMessage.Flags; // eslint-disable-next-line no-bitwise const isProfileUpdate = Boolean(data.message.flags & PROFILE_KEY_UPDATE); if (isProfileUpdate) { @@ -2556,20 +2606,20 @@ 'Queuing incoming reaction for', reaction.targetTimestamp ); - const reactionModel = Whisper.Reactions.add({ + const reactionModel = window.Whisper.Reactions.add({ emoji: reaction.emoji, remove: reaction.remove, targetAuthorE164: reaction.targetAuthorE164, targetAuthorUuid: reaction.targetAuthorUuid, targetTimestamp: reaction.targetTimestamp, timestamp: Date.now(), - fromId: ConversationController.ensureContactIds({ + fromId: window.ConversationController.ensureContactIds({ e164: data.source, uuid: data.sourceUuid, }), }); // Note: We do not wait for completion here - Whisper.Reactions.onReaction(reactionModel); + window.Whisper.Reactions.onReaction(reactionModel); confirm(); return Promise.resolve(); } @@ -2577,16 +2627,16 @@ if (data.message.delete) { const { delete: del } = data.message; window.log.info('Queuing incoming DOE for', del.targetSentTimestamp); - const deleteModel = Whisper.Deletes.add({ + const deleteModel = window.Whisper.Deletes.add({ targetSentTimestamp: del.targetSentTimestamp, serverTimestamp: data.serverTimestamp, - fromId: ConversationController.ensureContactIds({ + fromId: window.ConversationController.ensureContactIds({ e164: data.source, uuid: data.sourceUuid, }), }); // Note: We do not wait for completion here - Whisper.Deletes.onDelete(deleteModel); + window.Whisper.Deletes.onDelete(deleteModel); confirm(); return Promise.resolve(); } @@ -2597,13 +2647,13 @@ return Promise.resolve(); } - async function onProfileKeyUpdate({ data, confirm }) { - const conversationId = ConversationController.ensureContactIds({ + async function onProfileKeyUpdate({ data, confirm }: WhatIsThis) { + const conversationId = window.ConversationController.ensureContactIds({ e164: data.source, uuid: data.sourceUuid, highTrust: true, }); - const conversation = ConversationController.get(conversationId); + const conversation = window.ConversationController.get(conversationId); if (!conversation) { window.log.error( @@ -2639,17 +2689,19 @@ data, confirm, messageDescriptor, - }) { + }: WhatIsThis) { // First set profileSharing = true for the conversation we sent to const { id } = messageDescriptor; - const conversation = await ConversationController.get(id); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const conversation = window.ConversationController.get(id)!; conversation.enableProfileSharing(); window.Signal.Data.updateConversation(conversation.attributes); // Then we update our own profileKey if it's different from what we have - const ourId = ConversationController.getOurConversationId(); - const me = ConversationController.get(ourId); + const ourId = window.ConversationController.getOurConversationId(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const me = window.ConversationController.get(ourId)!; const profileKey = data.message.profileKey.toString('base64'); // Will do the save for us if needed @@ -2658,13 +2710,13 @@ return confirm(); } - function createSentMessage(data, descriptor) { + function createSentMessage(data: WhatIsThis, descriptor: WhatIsThis) { const now = Date.now(); let sentTo = []; if (data.unidentifiedStatus && data.unidentifiedStatus.length) { sentTo = data.unidentifiedStatus.map( - item => item.destinationUuid || item.destination + (item: WhatIsThis) => item.destinationUuid || item.destination ); const unidentified = _.filter(data.unidentifiedStatus, item => Boolean(item.unidentified) @@ -2675,9 +2727,9 @@ ); } - return new Whisper.Message({ - source: textsecure.storage.user.getNumber(), - sourceUuid: textsecure.storage.user.getUuid(), + return new window.Whisper.Message({ + source: window.textsecure.storage.user.getNumber(), + sourceUuid: window.textsecure.storage.user.getUuid(), sourceDevice: data.device, sent_at: data.timestamp, serverTimestamp: data.serverTimestamp, @@ -2691,14 +2743,18 @@ data.expirationStartTimestamp || data.timestamp || Date.now(), Date.now() ), - }); + } as WhatIsThis); } // Matches event data from `libtextsecure` `MessageReceiver::handleSentMessage`: - const getDescriptorForSent = ({ message, destination, destinationUuid }) => { + const getDescriptorForSent = ({ + message, + destination, + destinationUuid, + }: WhatIsThis) => { if (message.groupV2) { const { id } = message.groupV2; - const conversationId = ConversationController.ensureGroup(id, { + const conversationId = window.ConversationController.ensureGroup(id, { groupVersion: 2, masterKey: message.groupV2.masterKey, secretParams: message.groupV2.secretParams, @@ -2712,7 +2768,7 @@ } if (message.group) { const { id } = message.group; - const conversationId = ConversationController.ensureGroup(id); + const conversationId = window.ConversationController.ensureGroup(id); return { type: Message.GROUP, @@ -2722,7 +2778,7 @@ return { type: Message.PRIVATE, - id: ConversationController.ensureContactIds({ + id: window.ConversationController.ensureContactIds({ e164: destination, uuid: destinationUuid, highTrust: true, @@ -2733,12 +2789,12 @@ // Note: We do very little in this function, since everything in handleDataMessage is // inside a conversation-specific queue(). Any code here might run before an earlier // message is processed in handleDataMessage(). - function onSentMessage(event) { + function onSentMessage(event: WhatIsThis) { const { data, confirm } = event; const messageDescriptor = getDescriptorForSent(data); - const { PROFILE_KEY_UPDATE } = textsecure.protobuf.DataMessage.Flags; + const { PROFILE_KEY_UPDATE } = window.textsecure.protobuf.DataMessage.Flags; // eslint-disable-next-line no-bitwise const isProfileUpdate = Boolean(data.message.flags & PROFILE_KEY_UPDATE); if (isProfileUpdate) { @@ -2754,18 +2810,18 @@ if (data.message.reaction) { const { reaction } = data.message; window.log.info('Queuing sent reaction for', reaction.targetTimestamp); - const reactionModel = Whisper.Reactions.add({ + const reactionModel = window.Whisper.Reactions.add({ emoji: reaction.emoji, remove: reaction.remove, targetAuthorE164: reaction.targetAuthorE164, targetAuthorUuid: reaction.targetAuthorUuid, targetTimestamp: reaction.targetTimestamp, timestamp: Date.now(), - fromId: ConversationController.getOurConversationId(), + fromId: window.ConversationController.getOurConversationId(), fromSync: true, }); // Note: We do not wait for completion here - Whisper.Reactions.onReaction(reactionModel); + window.Whisper.Reactions.onReaction(reactionModel); event.confirm(); return Promise.resolve(); @@ -2774,13 +2830,13 @@ if (data.message.delete) { const { delete: del } = data.message; window.log.info('Queuing sent DOE for', del.targetSentTimestamp); - const deleteModel = Whisper.Deletes.add({ + const deleteModel = window.Whisper.Deletes.add({ targetSentTimestamp: del.targetSentTimestamp, serverTimestamp: del.serverTimestamp, - fromId: ConversationController.getOurConversationId(), + fromId: window.ConversationController.getOurConversationId(), }); // Note: We do not wait for completion here - Whisper.Deletes.onDelete(deleteModel); + window.Whisper.Deletes.onDelete(deleteModel); confirm(); return Promise.resolve(); } @@ -2793,8 +2849,8 @@ return Promise.resolve(); } - function initIncomingMessage(data, descriptor) { - return new Whisper.Message({ + function initIncomingMessage(data: WhatIsThis, descriptor: WhatIsThis) { + return new window.Whisper.Message({ source: data.source, sourceUuid: data.sourceUuid, sourceDevice: data.sourceDevice, @@ -2805,11 +2861,11 @@ unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived, type: 'incoming', unread: 1, - }); + } as WhatIsThis); } async function unlinkAndDisconnect() { - Whisper.events.trigger('unauthorized'); + window.Whisper.events.trigger('unauthorized'); if (messageReceiver) { await messageReceiver.stopProcessing(); @@ -2832,31 +2888,33 @@ const LAST_PROCESSED_INDEX_KEY = 'attachmentMigration_lastProcessedIndex'; const IS_MIGRATION_COMPLETE_KEY = 'attachmentMigration_isComplete'; - const previousNumberId = textsecure.storage.get(NUMBER_ID_KEY); - const lastProcessedIndex = textsecure.storage.get(LAST_PROCESSED_INDEX_KEY); - const isMigrationComplete = textsecure.storage.get( + const previousNumberId = window.textsecure.storage.get(NUMBER_ID_KEY); + const lastProcessedIndex = window.textsecure.storage.get( + LAST_PROCESSED_INDEX_KEY + ); + const isMigrationComplete = window.textsecure.storage.get( IS_MIGRATION_COMPLETE_KEY ); try { - await textsecure.storage.protocol.removeAllConfiguration(); + await window.textsecure.storage.protocol.removeAllConfiguration(); // These two bits of data are important to ensure that the app loads up // the conversation list, instead of showing just the QR code screen. window.Signal.Util.Registration.markEverDone(); - textsecure.storage.put(NUMBER_ID_KEY, previousNumberId); + window.textsecure.storage.put(NUMBER_ID_KEY, previousNumberId); // These two are important to ensure we don't rip through every message // in the database attempting to upgrade it after starting up again. - textsecure.storage.put( + window.textsecure.storage.put( IS_MIGRATION_COMPLETE_KEY, isMigrationComplete || false ); - textsecure.storage.put( + window.textsecure.storage.put( LAST_PROCESSED_INDEX_KEY, lastProcessedIndex || null ); - textsecure.storage.put(VERSION_KEY, window.getVersion()); + window.textsecure.storage.put(VERSION_KEY, window.getVersion()); window.log.info('Successfully cleared local configuration'); } catch (eraseError) { @@ -2867,7 +2925,7 @@ } } - function onError(ev) { + function onError(ev: WhatIsThis) { const { error } = ev; window.log.error('background onError:', Errors.toLogFormat(error)); @@ -2889,7 +2947,7 @@ window.log.info('retrying in 1 minute'); reconnectTimer = setTimeout(connect, 60000); - Whisper.events.trigger('reconnectTimer'); + window.Whisper.events.trigger('reconnectTimer'); } return Promise.resolve(); } @@ -2906,14 +2964,14 @@ const envelope = ev.proto; const message = initIncomingMessage(envelope, { type: Message.PRIVATE, - id: ConversationController.ensureContactIds({ + id: window.ConversationController.ensureContactIds({ e164: envelope.source, uuid: envelope.sourceUuid, }), }); const conversationId = message.get('conversationId'); - const conversation = ConversationController.get(conversationId); + const conversation = window.ConversationController.get(conversationId); if (!conversation) { window.log.warn( @@ -2928,7 +2986,7 @@ const existingMessage = await window.Signal.Data.getMessageBySender( message.attributes, { - Message: Whisper.Message, + Message: window.Whisper.Message, } ); if (existingMessage) { @@ -2939,7 +2997,7 @@ return; } - const model = new Whisper.Message({ + const model = new window.Whisper.Message({ ...message.attributes, id: window.getGuid(), }); @@ -2947,15 +3005,16 @@ skipSave: true, }); - MessageController.register(model.id, model); + window.MessageController.register(model.id, model); await window.Signal.Data.saveMessage(model.attributes, { - Message: Whisper.Message, + Message: window.Whisper.Message, forceSave: true, }); conversation.set({ active_at: Date.now(), - unreadCount: conversation.get('unreadCount') + 1, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + unreadCount: conversation.get('unreadCount')! + 1, }); const conversationTimestamp = conversation.get('timestamp'); @@ -2970,7 +3029,7 @@ conversation.trigger('newmessage', model); conversation.notify(model); - Whisper.events.trigger('incrementProgress'); + window.Whisper.events.trigger('incrementProgress'); if (ev.confirm) { ev.confirm(); @@ -2983,32 +3042,33 @@ throw error; } - async function onViewSync(ev) { + async function onViewSync(ev: WhatIsThis) { ev.confirm(); const { source, sourceUuid, timestamp } = ev; window.log.info(`view sync ${source} ${timestamp}`); - const sync = Whisper.ViewSyncs.add({ + const sync = window.Whisper.ViewSyncs.add({ source, sourceUuid, timestamp, }); - Whisper.ViewSyncs.onSync(sync); + window.Whisper.ViewSyncs.onSync(sync); } - async function onFetchLatestSync(ev) { + async function onFetchLatestSync(ev: WhatIsThis) { ev.confirm(); const { eventType } = ev; - const FETCH_LATEST_ENUM = textsecure.protobuf.SyncMessage.FetchLatest.Type; + const FETCH_LATEST_ENUM = + window.textsecure.protobuf.SyncMessage.FetchLatest.Type; switch (eventType) { case FETCH_LATEST_ENUM.LOCAL_PROFILE: - // Intentionally do nothing since we'll be receiving the storage manifest request - // and will update local profile along with that. + // Intentionally do nothing since we'll be receiving the + // window.storage manifest request and will update local profile along with that. break; case FETCH_LATEST_ENUM.STORAGE_MANIFEST: window.log.info('onFetchLatestSync: fetching latest manifest'); @@ -3021,14 +3081,14 @@ } } - async function onKeysSync(ev) { + async function onKeysSync(ev: WhatIsThis) { ev.confirm(); const { storageServiceKey } = ev; if (storageServiceKey === null) { - window.log.info('onKeysSync: deleting storageKey'); - storage.remove('storageKey'); + window.log.info('onKeysSync: deleting window.storageKey'); + window.storage.remove('storageKey'); } if (storageServiceKey) { @@ -3036,13 +3096,13 @@ const storageServiceKeyBase64 = window.Signal.Crypto.arrayBufferToBase64( storageServiceKey ); - storage.put('storageKey', storageServiceKeyBase64); + window.storage.put('storageKey', storageServiceKeyBase64); await window.Signal.Services.runStorageServiceSyncJob(); } } - async function onMessageRequestResponse(ev) { + async function onMessageRequestResponse(ev: WhatIsThis) { ev.confirm(); const { threadE164, threadUuid, groupId, messageRequestResponseType } = ev; @@ -3056,15 +3116,15 @@ window.log.info('message request response', args); - const sync = Whisper.MessageRequests.add(args); + const sync = window.Whisper.MessageRequests.add(args); - Whisper.MessageRequests.onResponse(sync); + window.Whisper.MessageRequests.onResponse(sync); } - function onReadReceipt(ev) { + function onReadReceipt(ev: WhatIsThis) { const readAt = ev.timestamp; const { envelopeTimestamp, timestamp, source, sourceUuid } = ev.read; - const reader = ConversationController.ensureContactIds({ + const reader = window.ConversationController.ensureContactIds({ e164: source, uuid: sourceUuid, highTrust: true, @@ -3081,24 +3141,24 @@ ev.confirm(); - if (!storage.get('read-receipt-setting') || !reader) { + if (!window.storage.get('read-receipt-setting') || !reader) { return; } - const receipt = Whisper.ReadReceipts.add({ + const receipt = window.Whisper.ReadReceipts.add({ reader, timestamp, read_at: readAt, }); // Note: We do not wait for completion here - Whisper.ReadReceipts.onReceipt(receipt); + window.Whisper.ReadReceipts.onReceipt(receipt); } - function onReadSync(ev) { + function onReadSync(ev: WhatIsThis) { const readAt = ev.timestamp; const { envelopeTimestamp, sender, senderUuid, timestamp } = ev.read; - const senderId = ConversationController.ensureContactIds({ + const senderId = window.ConversationController.ensureContactIds({ e164: sender, uuid: senderUuid, }); @@ -3113,7 +3173,7 @@ timestamp ); - const receipt = Whisper.ReadSyncs.add({ + const receipt = window.Whisper.ReadSyncs.add({ senderId, sender, senderUuid, @@ -3125,10 +3185,10 @@ // Note: Here we wait, because we want read states to be in the database // before we move on. - return Whisper.ReadSyncs.onReceipt(receipt); + return window.Whisper.ReadSyncs.onReceipt(receipt); } - async function onVerified(ev) { + async function onVerified(ev: WhatIsThis) { const e164 = ev.verified.destination; const uuid = ev.verified.destinationUuid; const key = ev.verified.identityKey; @@ -3138,30 +3198,30 @@ ev.confirm(); } - const c = new Whisper.Conversation({ + const c = new window.Whisper.Conversation({ e164, uuid, type: 'private', - }); + } as WhatIsThis); const error = c.validate(); if (error) { window.log.error( 'Invalid verified sync received:', e164, uuid, - Errors.toLogFormat(error) + Errors.toLogFormat(error as WhatIsThis) ); return; } switch (ev.verified.state) { - case textsecure.protobuf.Verified.State.DEFAULT: + case window.textsecure.protobuf.Verified.State.DEFAULT: state = 'DEFAULT'; break; - case textsecure.protobuf.Verified.State.VERIFIED: + case window.textsecure.protobuf.Verified.State.VERIFIED: state = 'VERIFIED'; break; - case textsecure.protobuf.Verified.State.UNVERIFIED: + case window.textsecure.protobuf.Verified.State.UNVERIFIED: state = 'UNVERIFIED'; break; default: @@ -3176,12 +3236,13 @@ ev.viaContactSync ? 'via contact sync' : '' ); - const verifiedId = ConversationController.ensureContactIds({ + const verifiedId = window.ConversationController.ensureContactIds({ e164, uuid, highTrust: true, }); - const contact = await ConversationController.get(verifiedId, 'private'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const contact = window.ConversationController.get(verifiedId)!; const options = { viaSyncMessage: true, viaContactSync: ev.viaContactSync, @@ -3197,7 +3258,7 @@ } } - function onDeliveryReceipt(ev) { + function onDeliveryReceipt(ev: WhatIsThis) { const { deliveryReceipt } = ev; const { envelopeTimestamp, @@ -3209,7 +3270,7 @@ ev.confirm(); - const deliveredTo = ConversationController.ensureContactIds({ + const deliveredTo = window.ConversationController.ensureContactIds({ e164: source, uuid: sourceUuid, highTrust: true, @@ -3231,12 +3292,12 @@ return; } - const receipt = Whisper.DeliveryReceipts.add({ + const receipt = window.Whisper.DeliveryReceipts.add({ timestamp, deliveredTo, }); // Note: We don't wait for completion here - Whisper.DeliveryReceipts.onReceipt(receipt); + window.Whisper.DeliveryReceipts.onReceipt(receipt); } })(); diff --git a/ts/components/conversation/GroupNotification.tsx b/ts/components/conversation/GroupNotification.tsx index dd5c56bd6..fe16c1edb 100644 --- a/ts/components/conversation/GroupNotification.tsx +++ b/ts/components/conversation/GroupNotification.tsx @@ -15,8 +15,10 @@ interface Contact { isMe?: boolean; } +export type ChangeType = 'add' | 'remove' | 'name' | 'avatar' | 'general'; + interface Change { - type: 'add' | 'remove' | 'name' | 'avatar' | 'general'; + type: ChangeType; newName?: string; contacts?: Array; } diff --git a/ts/components/conversation/TimerNotification.tsx b/ts/components/conversation/TimerNotification.tsx index 7aaea5885..5017c78c8 100644 --- a/ts/components/conversation/TimerNotification.tsx +++ b/ts/components/conversation/TimerNotification.tsx @@ -5,8 +5,14 @@ import { ContactName } from './ContactName'; import { Intl } from '../Intl'; import { LocalizerType } from '../../types/Util'; +export type TimerNotificationType = + | 'fromOther' + | 'fromMe' + | 'fromSync' + | 'fromMember'; + export type PropsData = { - type: 'fromOther' | 'fromMe' | 'fromSync' | 'fromMember'; + type: TimerNotificationType; phoneNumber?: string; profileName?: string; title: string; diff --git a/ts/groups.ts b/ts/groups.ts index c933cae8e..67a35607d 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -15,7 +15,6 @@ import { } from './services/groupCredentialFetcher'; import { ConversationAttributesType, - ConversationModelType, GroupV2MemberType, GroupV2PendingMemberType, MessageAttributesType, @@ -50,6 +49,7 @@ import { } from './textsecure.d'; import { GroupCredentialsType } from './textsecure/WebAPI'; import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from '../js/modules/types/message'; +import { ConversationModel } from './models/conversations'; export type GroupV2AccessAttributesChangeType = { type: 'access-attributes'; @@ -265,7 +265,7 @@ export function deriveGroupFields( // Fetching and applying group changes type MaybeUpdatePropsType = { - conversation: ConversationModelType; + conversation: ConversationModel; groupChangeBase64?: string; newRevision?: number; receivedAt?: number; @@ -548,7 +548,8 @@ function generateBasicMessage() { return { id: getGuid(), schemaVersion: MAX_MESSAGE_SCHEMA, - }; + // this is missing most properties to fulfill this type + } as MessageAttributesType; } function generateLeftGroupChanges( diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index de58f4982..e194deafd 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -1,17 +1,27 @@ import * as Backbone from 'backbone'; import { GroupV2ChangeType } from './groups'; -import { LocalizerType } from './types/Util'; +import { LocalizerType, BodyRangesType } from './types/Util'; import { CallHistoryDetailsType } from './types/Calling'; import { ColorType } from './types/Colors'; -import { ConversationType } from './state/ducks/conversations'; +import { + ConversationType, + MessageType, + LastMessageStatus, +} from './state/ducks/conversations'; import { SendOptionsType } from './textsecure/SendMessage'; import { SyncMessageClass } from './textsecure.d'; +import { UserMessage } from './types/Message'; +import { MessageModel } from './models/messages'; +import { ConversationModel } from './models/conversations'; +import { ProfileNameChangeType } from './util/getStringForProfileChange'; interface ModelAttributesInterface { [key: string]: any; } +export type WhatIsThis = any; + type DeletesAttributesType = { fromId: string; serverTimestamp: number; @@ -21,18 +31,89 @@ type DeletesAttributesType = { export declare class DeletesModelType extends Backbone.Model< DeletesAttributesType > { - forMessage(message: MessageModelType): Array; + forMessage(message: MessageModel): Array; onDelete(doe: DeletesAttributesType): Promise; } type TaskResultType = any; +export interface CustomError extends Error { + identifier?: string; + number?: string; +} + export type MessageAttributesType = { + bodyPending: boolean; + bodyRanges: BodyRangesType; + callHistoryDetails: CallHistoryDetailsType; + changedId: string; + dataMessage: ArrayBuffer | null; + decrypted_at: number; + deletedForEveryone: boolean; + delivered: number; + delivered_to: Array; + errors: Array | null; + expirationStartTimestamp: number | null; + expireTimer: number; + expires_at: number; + group_update: { + avatarUpdated: boolean; + joined: Array; + left: string | 'You'; + name: string; + }; + hasAttachments: boolean; + hasFileAttachments: boolean; + hasVisualMediaAttachments: boolean; + isErased: boolean; + isTapToViewInvalid: boolean; + isViewOnce: boolean; + key_changed: string; + local: boolean; + logger: unknown; + message: unknown; + messageTimer: unknown; + profileChange: ProfileNameChangeType; + quote: { + attachments: Array; + author: string; + authorUuid: string; + bodyRanges: BodyRangesType; + id: string; + referencedMessageNotFound: boolean; + text: string; + } | null; + reactions: Array<{ fromId: string; emoji: unknown; timestamp: unknown }>; + read_by: Array; + requiredProtocolVersion: number; + sent: boolean; + sourceDevice: string | number; + snippet: unknown; + supportedVersionAtReceive: unknown; + synced: boolean; + unidentifiedDeliveryReceived: boolean; + verified: boolean; + verifiedChanged: string; + id: string; type?: string; + body: string; + attachments: Array; + preview: Array; + sticker: WhatIsThis; + sent_at: WhatIsThis; + sent_to: Array; + unidentifiedDeliveries: Array; + contact: Array; + conversationId: string; + recipients: Array; + reaction: WhatIsThis; + destination?: WhatIsThis; + destinationUuid?: string; expirationTimerUpdate?: { expireTimer: number; + fromSync?: unknown; source?: string; sourceUuid?: string; }; @@ -46,39 +127,47 @@ export type MessageAttributesType = { // We set this so that the idle message upgrade process doesn't pick this message up schemaVersion: number; serverTimestamp?: number; + source?: string; sourceUuid?: string; + + unread: number; + timestamp: number; }; -export declare class MessageModelType extends Backbone.Model< - MessageAttributesType -> { - id: string; - - static updateTimers(): void; - - getContact(): ConversationModelType | undefined | null; - getConversation(): ConversationModelType | undefined | null; - getPropsForSearchResult(): any; - getPropsForBubble(): any; - cleanup(): Promise; - handleDeleteForEveryone( - doe: DeletesModelType, - shouldPersist: boolean - ): Promise; -} - -export type ConversationTypeType = 'private' | 'group'; +export type ConversationAttributesTypeType = 'private' | 'group'; export type ConversationAttributesType = { + accessKey: string | null; + addedBy: string; + capabilities: { uuid: string }; + color?: ColorType; + discoveredUnregisteredAt: number; + draftAttachments: Array; + draftTimestamp: number | null; + inbox_position: number; + lastMessageDeletedForEveryone: unknown; + lastMessageStatus: LastMessageStatus | null; + messageCount: number; + messageCountBeforeMessageRequests: number; + messageRequestResponseType: number; + muteExpiresAt: number; + profileAvatar: WhatIsThis; + profileKeyCredential: unknown | null; + profileKeyVersion: string; + quotedMessageId: string; + sealedSender: unknown; + sentMessageCount: number; + sharedGroupNames: Array; + id: string; - type: ConversationTypeType; - timestamp: number; + type: ConversationAttributesTypeType; + timestamp: number | null; // Shared fields active_at?: number | null; - draft?: string; + draft?: string | null; isArchived?: boolean; - lastMessage?: string; + lastMessage?: string | null; name?: string; needsStorageServiceSync?: boolean; needsVerification?: boolean; @@ -93,9 +182,9 @@ export type ConversationAttributesType = { e164?: string; // Private other fields - profileFamilyName?: string | null; - profileKey?: string | null; - profileName?: string | null; + profileFamilyName?: string; + profileKey?: string; + profileName?: string; verified?: number; // Group-only @@ -121,7 +210,7 @@ export type ConversationAttributesType = { url: string; path: string; hash: string; - }; + } | null; expireTimer?: number; membersV2?: Array; pendingMembersV2?: Array; @@ -138,80 +227,19 @@ export type GroupV2PendingMemberType = { timestamp: number; }; -type VerificationOptions = { +export type VerificationOptions = { key?: null | ArrayBuffer; viaContactSync?: boolean; viaStorageServiceSync?: boolean; viaSyncMessage?: boolean; }; -export declare class ConversationModelType extends Backbone.Model< - ConversationAttributesType -> { - id: string; - cachedProps: ConversationType; - initialPromise: Promise; - messageRequestEnum: typeof SyncMessageClass.MessageRequestResponse.Type; - - addCallHistory(details: CallHistoryDetailsType): void; - applyMessageRequestResponse( - response: number, - options?: { fromSync: boolean; viaStorageServiceSync?: boolean } - ): void; - cleanup(): Promise; - disableProfileSharing(options?: { viaStorageServiceSync?: boolean }): void; - dropProfileKey(): Promise; - enableProfileSharing(options?: { viaStorageServiceSync?: boolean }): void; - generateProps(): void; - getAccepted(): boolean; - getAvatarPath(): string | undefined; - getColor(): ColorType | undefined; - getName(): string | undefined; - getNumber(): string; - getProfileName(): string | undefined; - getProfiles(): Promise>>; - getRecipients: () => Array; - getSendOptions(options?: any): SendOptionsType | undefined; - getTitle(): string; - idForLogging(): string; - debugID(): string; - isFromOrAddedByTrustedContact(): boolean; - isBlocked(): boolean; - isMe(): boolean; - isMuted(): boolean; - isPrivate(): boolean; - isVerified(): boolean; - maybeRepairGroupV2(data: { - masterKey: string; - secretParams: string; - publicParams: string; - }): void; - queueJob(job: () => Promise): Promise; - safeGetVerified(): Promise; - setArchived(isArchived: boolean): void; - setProfileKey( - profileKey?: string | null, - options?: { viaStorageServiceSync?: boolean } - ): Promise; - setProfileAvatar(avatarPath: string): Promise; - setUnverified(options: VerificationOptions): Promise; - setVerified(options: VerificationOptions): Promise; - setVerifiedDefault(options: VerificationOptions): Promise; - toggleVerified(): Promise; - block(options?: { viaStorageServiceSync?: boolean }): void; - unblock(options?: { viaStorageServiceSync?: boolean }): boolean; - updateE164: (e164?: string) => void; - updateLastMessage: () => Promise; - updateUuid: (uuid?: string) => void; - wrapSend: (sendPromise: Promise) => Promise; -} - export declare class ConversationModelCollectionType extends Backbone.Collection< - ConversationModelType + ConversationModel > { resetLookups(): void; } -declare class MessageModelCollectionType extends Backbone.Collection< - MessageModelType +export declare class MessageModelCollectionType extends Backbone.Collection< + MessageModel > {} diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts new file mode 100644 index 000000000..ed63058f4 --- /dev/null +++ b/ts/models/conversations.ts @@ -0,0 +1,3673 @@ +/* eslint-disable class-methods-use-this */ +/* eslint-disable camelcase */ +import { + MessageModelCollectionType, + WhatIsThis, + MessageAttributesType, + ConversationAttributesType, + VerificationOptions, +} from '../model-types.d'; +import { CallbackResultType } from '../textsecure/SendMessage'; +import { + ConversationType, + ConversationTypeType, +} from '../state/ducks/conversations'; +import { ColorType } from '../types/Colors'; +import { MessageModel } from './messages'; + +/* eslint-disable more/no-then */ +window.Whisper = window.Whisper || {}; + +const SEALED_SENDER = { + UNKNOWN: 0, + ENABLED: 1, + DISABLED: 2, + UNRESTRICTED: 3, +}; + +const { Services, Util } = window.Signal; +const { Contact, Message } = window.Signal.Types; +const { + deleteAttachmentData, + doesAttachmentExist, + getAbsoluteAttachmentPath, + loadAttachmentData, + readStickerData, + upgradeMessageSchema, + writeNewAttachmentData, +} = window.Signal.Migrations; +const { addStickerPackReference } = window.Signal.Data; +const { + arrayBufferToBase64, + base64ToArrayBuffer, + deriveAccessKey, + getRandomBytes, + stringFromBytes, + verifyAccessKey, +} = window.Signal.Crypto; + +const COLORS = [ + 'red', + 'deep_orange', + 'brown', + 'pink', + 'purple', + 'indigo', + 'blue', + 'teal', + 'green', + 'light_green', + 'blue_grey', + 'ultramarine', +]; + +interface CustomError extends Error { + identifier?: string; + number?: string; +} + +export class ConversationModel extends window.Backbone.Model< + ConversationAttributesType +> { + static COLORS: string; + + cachedProps?: ConversationType | null; + + contactTypingTimers?: Record< + string, + { senderId: string; timer: NodeJS.Timer } + >; + + contactCollection?: Backbone.Collection; + + debouncedUpdateLastMessage?: () => void; + + // backbone ensures this exists in initialize() + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + generateProps: () => void; + + // backbone ensures this exists + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + id: string; + + initialPromise?: Promise; + + inProgressFetch?: Promise; + + jobQueue?: typeof window.PQueueType; + + messageCollection?: MessageModelCollectionType; + + // backbone ensures this exists in initialize() + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + messageRequestEnum: typeof window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; + + ourNumber?: string; + + ourUuid?: string; + + storeName?: string | null; + + throttledBumpTyping: unknown; + + typingRefreshTimer?: NodeJS.Timer | null; + + typingPauseTimer?: NodeJS.Timer | null; + + verifiedEnum?: typeof window.textsecure.storage.protocol.VerifiedStatus; + + // eslint-disable-next-line class-methods-use-this + defaults(): Partial { + return { + unreadCount: 0, + verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT, + messageCount: 0, + sentMessageCount: 0, + }; + } + + idForLogging(): string { + if (this.isPrivate()) { + const uuid = this.get('uuid'); + const e164 = this.get('e164'); + return `${uuid || e164} (${this.id})`; + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (this.get('groupVersion')! > 1) { + return `groupv2(${this.get('groupId')})`; + } + + const groupId = this.get('groupId'); + return `group(${groupId})`; + } + + debugID(): string { + const uuid = this.get('uuid'); + const e164 = this.get('e164'); + const groupId = this.get('groupId'); + return `group(${groupId}), sender(${uuid || e164}), id(${this.id})`; + } + + // 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(): string | undefined { + return this.get('uuid') || this.get('e164'); + } + + handleMessageError(message: unknown, errors: unknown): void { + this.trigger('messageError', message, errors); + } + + // eslint-disable-next-line class-methods-use-this + getContactCollection(): Backbone.Collection { + const collection = new window.Backbone.Collection(); + const collator = new Intl.Collator(); + collection.comparator = ( + left: ConversationModel, + right: ConversationModel + ) => { + const leftLower = left.getTitle().toLowerCase(); + const rightLower = right.getTitle().toLowerCase(); + return collator.compare(leftLower, rightLower); + }; + return collection; + } + + initialize(attributes: Partial = {}): void { + if (window.isValidE164(attributes.id)) { + this.set({ id: window.getGuid(), e164: attributes.id }); + } + + this.storeName = 'conversations'; + + this.ourNumber = window.textsecure.storage.user.getNumber(); + this.ourUuid = window.textsecure.storage.user.getUuid(); + this.verifiedEnum = window.textsecure.storage.protocol.VerifiedStatus; + this.messageRequestEnum = + window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; + + // This may be overridden by window.ConversationController.getOrCreate, and signify + // our first save to the database. Or first fetch from the database. + this.initialPromise = Promise.resolve(); + + this.contactCollection = this.getContactCollection(); + this.messageCollection = new window.Whisper.MessageCollection([], { + conversation: this, + }); + + this.messageCollection.on('change:errors', this.handleMessageError, this); + this.messageCollection.on('send-error', this.onMessageError, this); + + this.throttledBumpTyping = window._.throttle(this.bumpTyping, 300); + this.debouncedUpdateLastMessage = window._.debounce( + this.updateLastMessage.bind(this), + 200 + ); + + this.listenTo( + this.messageCollection, + 'add remove destroy content-changed', + this.debouncedUpdateLastMessage + ); + this.listenTo(this.messageCollection, 'sent', this.updateLastMessage); + this.listenTo(this.messageCollection, 'send-error', this.updateLastMessage); + + this.on('newmessage', this.onNewMessage); + this.on('change:profileKey', this.onChangeProfileKey); + + // Listening for out-of-band data updates + this.on('delivered', this.updateAndMerge); + this.on('read', this.updateAndMerge); + this.on('expiration-change', this.updateAndMerge); + this.on('expired', this.onExpired); + + const sealedSender = this.get('sealedSender'); + if (sealedSender === undefined) { + this.set({ sealedSender: SEALED_SENDER.UNKNOWN }); + } + this.unset('unidentifiedDelivery'); + this.unset('unidentifiedDeliveryUnrestricted'); + this.unset('hasFetchedProfile'); + this.unset('tokens'); + + this.typingRefreshTimer = null; + this.typingPauseTimer = null; + + // Keep props ready + this.generateProps = () => { + this.cachedProps = this.getProps(); + }; + this.on('change', this.generateProps); + this.generateProps(); + } + + isMe(): boolean { + const e164 = this.get('e164'); + const uuid = this.get('uuid'); + return ((e164 && e164 === this.ourNumber) || + (uuid && uuid === this.ourUuid)) as boolean; + } + + isEverUnregistered(): boolean { + return Boolean(this.get('discoveredUnregisteredAt')); + } + + isUnregistered(): boolean { + const now = Date.now(); + const sixHoursAgo = now - 1000 * 60 * 60 * 6; + const discoveredUnregisteredAt = this.get('discoveredUnregisteredAt'); + + if (discoveredUnregisteredAt && discoveredUnregisteredAt > sixHoursAgo) { + return true; + } + + return false; + } + + setUnregistered(): void { + window.log.info(`Conversation ${this.idForLogging()} is now unregistered`); + this.set({ + discoveredUnregisteredAt: Date.now(), + }); + window.Signal.Data.updateConversation(this.attributes); + } + + setRegistered(): void { + window.log.info( + `Conversation ${this.idForLogging()} is registered once again` + ); + this.set({ + discoveredUnregisteredAt: undefined, + }); + window.Signal.Data.updateConversation(this.attributes); + } + + isBlocked(): boolean { + const uuid = this.get('uuid'); + if (uuid) { + return window.storage.isUuidBlocked(uuid); + } + + const e164 = this.get('e164'); + if (e164) { + return window.storage.isBlocked(e164); + } + + const groupId = this.get('groupId'); + if (groupId) { + return window.storage.isGroupBlocked(groupId); + } + + return false; + } + + block({ viaStorageServiceSync = false } = {}): void { + let blocked = false; + const isBlocked = this.isBlocked(); + + const uuid = this.get('uuid'); + if (uuid) { + window.storage.addBlockedUuid(uuid); + blocked = true; + } + + const e164 = this.get('e164'); + if (e164) { + window.storage.addBlockedNumber(e164); + blocked = true; + } + + const groupId = this.get('groupId'); + if (groupId) { + window.storage.addBlockedGroup(groupId); + blocked = true; + } + + if (!viaStorageServiceSync && !isBlocked && blocked) { + this.captureChange(); + } + } + + unblock({ viaStorageServiceSync = false } = {}): boolean { + let unblocked = false; + const isBlocked = this.isBlocked(); + + const uuid = this.get('uuid'); + if (uuid) { + window.storage.removeBlockedUuid(uuid); + unblocked = true; + } + + const e164 = this.get('e164'); + if (e164) { + window.storage.removeBlockedNumber(e164); + unblocked = true; + } + + const groupId = this.get('groupId'); + if (groupId) { + window.storage.removeBlockedGroup(groupId); + unblocked = true; + } + + if (!viaStorageServiceSync && isBlocked && unblocked) { + this.captureChange(); + } + + return unblocked; + } + + enableProfileSharing({ viaStorageServiceSync = false } = {}): void { + const before = this.get('profileSharing'); + + this.set({ profileSharing: true }); + + const after = this.get('profileSharing'); + + if (!viaStorageServiceSync && Boolean(before) !== Boolean(after)) { + this.captureChange(); + } + } + + disableProfileSharing({ viaStorageServiceSync = false } = {}): void { + const before = this.get('profileSharing'); + + this.set({ profileSharing: false }); + + const after = this.get('profileSharing'); + + if (!viaStorageServiceSync && Boolean(before) !== Boolean(after)) { + this.captureChange(); + } + } + + hasDraft(): boolean { + const draftAttachments = this.get('draftAttachments') || []; + return (this.get('draft') || + this.get('quotedMessageId') || + draftAttachments.length > 0) as boolean; + } + + getDraftPreview(): string { + const draft = this.get('draft'); + if (draft) { + return draft; + } + + const draftAttachments = this.get('draftAttachments') || []; + if (draftAttachments.length > 0) { + return window.i18n('Conversation--getDraftPreview--attachment'); + } + + const quotedMessageId = this.get('quotedMessageId'); + if (quotedMessageId) { + return window.i18n('Conversation--getDraftPreview--quote'); + } + + return window.i18n('Conversation--getDraftPreview--draft'); + } + + bumpTyping(): void { + // We don't send typing messages if the setting is disabled + if (!window.storage.get('typingIndicators')) { + return; + } + + if (!this.typingRefreshTimer) { + const isTyping = true; + this.setTypingRefreshTimer(); + this.sendTypingMessage(isTyping); + } + + this.setTypingPauseTimer(); + } + + setTypingRefreshTimer(): void { + if (this.typingRefreshTimer) { + clearTimeout(this.typingRefreshTimer); + } + this.typingRefreshTimer = setTimeout( + this.onTypingRefreshTimeout.bind(this), + 10 * 1000 + ); + } + + onTypingRefreshTimeout(): void { + const isTyping = true; + this.sendTypingMessage(isTyping); + + // This timer will continue to reset itself until the pause timer stops it + this.setTypingRefreshTimer(); + } + + setTypingPauseTimer(): void { + if (this.typingPauseTimer) { + clearTimeout(this.typingPauseTimer); + } + this.typingPauseTimer = setTimeout( + this.onTypingPauseTimeout.bind(this), + 3 * 1000 + ); + } + + onTypingPauseTimeout(): void { + const isTyping = false; + this.sendTypingMessage(isTyping); + + this.clearTypingTimers(); + } + + clearTypingTimers(): void { + if (this.typingPauseTimer) { + clearTimeout(this.typingPauseTimer); + this.typingPauseTimer = null; + } + if (this.typingRefreshTimer) { + clearTimeout(this.typingRefreshTimer); + this.typingRefreshTimer = null; + } + } + + async fetchLatestGroupV2Data(): Promise { + if (this.get('groupVersion') !== 2) { + return; + } + + await window.Signal.Groups.waitThenMaybeUpdateGroup({ + conversation: this, + }); + } + + maybeRepairGroupV2(data: { + masterKey: string; + secretParams: string; + publicParams: string; + }): void { + if ( + this.get('groupVersion') && + this.get('masterKey') && + this.get('secretParams') && + this.get('publicParams') + ) { + return; + } + + window.log.info(`Repairing GroupV2 conversation ${this.idForLogging()}`); + const { masterKey, secretParams, publicParams } = data; + + this.set({ masterKey, secretParams, publicParams, groupVersion: 2 }); + + window.Signal.Data.updateConversation(this.attributes); + } + + getGroupV2Info(groupChange?: ArrayBuffer): WhatIsThis { + if (this.isPrivate() || this.get('groupVersion') !== 2) { + return undefined; + } + return { + masterKey: window.Signal.Crypto.base64ToArrayBuffer( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.get('masterKey')! + ), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + revision: this.get('revision')!, + members: this.getRecipients(), + groupChange, + }; + } + + getGroupV1Info(): WhatIsThis { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (this.isPrivate() || this.get('groupVersion')! > 0) { + return undefined; + } + + return { + id: this.get('groupId'), + members: this.getRecipients(), + }; + } + + sendTypingMessage(isTyping: boolean): void { + if (!window.textsecure.messaging) { + return; + } + + // We don't send typing messages to our other devices + if (this.isMe()) { + return; + } + + const recipientId = this.isPrivate() ? this.getSendTarget() : undefined; + const groupId = !this.isPrivate() ? this.get('groupId') : undefined; + const groupMembers = this.getRecipients(); + + // We don't send typing messages if our recipients list is empty + if (!this.isPrivate() && !groupMembers.length) { + return; + } + + const sendOptions = this.getSendOptions(); + this.wrapSend( + window.textsecure.messaging.sendTypingMessage( + { + isTyping, + recipientId, + groupId, + groupMembers, + }, + sendOptions + ) + ); + } + + async cleanup(): Promise { + await window.Signal.Types.Conversation.deleteExternalFiles( + this.attributes, + { + deleteAttachmentData, + } + ); + } + + async updateAndMerge(message: MessageModel): Promise { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.debouncedUpdateLastMessage!(); + + const mergeMessage = () => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const existing = this.messageCollection!.get(message.id); + if (!existing) { + return; + } + + existing.merge(message.attributes); + }; + + await this.inProgressFetch; + mergeMessage(); + } + + async onExpired(message: MessageModel): Promise { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.debouncedUpdateLastMessage!(); + + const removeMessage = () => { + const { id } = message; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const existing = this.messageCollection!.get(id); + if (!existing) { + return; + } + + window.log.info('Remove expired message from collection', { + sentAt: existing.get('sent_at'), + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.messageCollection!.remove(id); + existing.trigger('expired'); + existing.cleanup(); + + // An expired message only counts as decrementing the message count, not + // the sent message count + this.decrementMessageCount(); + }; + + // If a fetch is in progress, then we need to wait until that's complete to + // do this removal. Otherwise we could remove from messageCollection, then + // the async database fetch could include the removed message. + + await this.inProgressFetch; + removeMessage(); + } + + async onNewMessage(message: WhatIsThis): Promise { + const uuid = message.get ? message.get('sourceUuid') : message.sourceUuid; + const e164 = message.get ? message.get('source') : message.source; + const sourceDevice = message.get + ? message.get('sourceDevice') + : message.sourceDevice; + + const sourceId = window.ConversationController.ensureContactIds({ + uuid, + e164, + }); + const typingToken = `${sourceId}.${sourceDevice}`; + + // Clear typing indicator for a given contact if we receive a message from them + this.clearContactTypingTimer(typingToken); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.debouncedUpdateLastMessage!(); + } + + // For outgoing messages, we can call this directly. We're already loaded. + addSingleMessage(message: MessageModel): MessageModel { + const { id } = message; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const existing = this.messageCollection!.get(id); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const model = this.messageCollection!.add(message, { merge: true }); + model.setToExpire(); + + if (!existing) { + const { messagesAdded } = window.reduxActions.conversations; + const isNewMessage = true; + messagesAdded( + this.id, + [model.getReduxData()], + isNewMessage, + window.isActive() + ); + } + + return model; + } + + // For incoming messages, they might arrive while we're in the middle of a bulk fetch + // from the database. We'll wait until that is done to process this newly-arrived + // message. + async addIncomingMessage(message: MessageModel): Promise { + await this.inProgressFetch; + + this.addSingleMessage(message); + } + + format(): ConversationType | null | undefined { + return this.cachedProps; + } + + getProps(): ConversationType | null { + // This is to prevent race conditions on startup; Conversation models are created + // but the full window.ConversationController.load() sequence isn't complete. So, we + // don't cache props on create, but we do later when load() calls generateProps() + // for us. + if (!window.ConversationController.isFetchComplete()) { + return null; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const color = this.getColor()!; + + const typingValues = window._.values(this.contactTypingTimers || {}); + const typingMostRecent = window._.first( + window._.sortBy(typingValues, 'timestamp') + ); + const typingContact = typingMostRecent + ? window.ConversationController.get(typingMostRecent.senderId) + : null; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const timestamp = this.get('timestamp')!; + const draftTimestamp = this.get('draftTimestamp'); + const draftPreview = this.getDraftPreview(); + const draftText = this.get('draft'); + const shouldShowDraft = (this.hasDraft() && + draftTimestamp && + draftTimestamp >= timestamp) as boolean; + const inboxPosition = this.get('inbox_position'); + const messageRequestsEnabled = window.Signal.RemoteConfig.isEnabled( + 'desktop.messageRequests' + ); + + // TODO: DESKTOP-720 + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + const result = { + id: this.id, + uuid: this.get('uuid'), + e164: this.get('e164'), + + acceptedMessageRequest: this.getAccepted(), + activeAt: this.get('active_at')!, + avatarPath: this.getAvatarPath()!, + color, + draftPreview, + draftText, + firstName: this.get('profileName')!, + inboxPosition, + isAccepted: this.getAccepted(), + isArchived: this.get('isArchived')!, + isBlocked: this.isBlocked(), + isMe: this.isMe(), + isVerified: this.isVerified(), + lastMessage: { + status: this.get('lastMessageStatus')!, + text: this.get('lastMessage')!, + deletedForEveryone: this.get('lastMessageDeletedForEveryone')!, + }, + lastUpdated: this.get('timestamp')!, + membersCount: this.isPrivate() + ? undefined + : (this.get('membersV2')! || this.get('members')! || []).length, + messageRequestsEnabled, + muteExpiresAt: this.get('muteExpiresAt')!, + name: this.get('name')!, + phoneNumber: this.getNumber()!, + profileName: this.getProfileName()!, + sharedGroupNames: this.get('sharedGroupNames')!, + shouldShowDraft, + timestamp, + title: this.getTitle()!, + type: (this.isPrivate() ? 'direct' : 'group') as ConversationTypeType, + typingContact: typingContact ? typingContact.format() : null, + unreadCount: this.get('unreadCount')! || 0, + }; + /* eslint-enable @typescript-eslint/no-non-null-assertion */ + + return result; + } + + updateE164(e164?: string | null): void { + const oldValue = this.get('e164'); + if (e164 && e164 !== oldValue) { + this.set('e164', e164); + window.Signal.Data.updateConversation(this.attributes); + this.trigger('idUpdated', this, 'e164', oldValue); + } + } + + updateUuid(uuid?: string): void { + const oldValue = this.get('uuid'); + if (uuid && uuid !== oldValue) { + this.set('uuid', uuid.toLowerCase()); + window.Signal.Data.updateConversation(this.attributes); + this.trigger('idUpdated', this, 'uuid', oldValue); + } + } + + updateGroupId(groupId?: string): void { + const oldValue = this.get('groupId'); + if (groupId && groupId !== oldValue) { + this.set('groupId', groupId); + window.Signal.Data.updateConversation(this.attributes); + this.trigger('idUpdated', this, 'groupId', oldValue); + } + } + + incrementMessageCount(): void { + this.set({ + messageCount: (this.get('messageCount') || 0) + 1, + }); + window.Signal.Data.updateConversation(this.attributes); + } + + decrementMessageCount(): void { + this.set({ + messageCount: Math.max((this.get('messageCount') || 0) - 1, 0), + }); + window.Signal.Data.updateConversation(this.attributes); + } + + incrementSentMessageCount(): void { + this.set({ + messageCount: (this.get('messageCount') || 0) + 1, + sentMessageCount: (this.get('sentMessageCount') || 0) + 1, + }); + window.Signal.Data.updateConversation(this.attributes); + } + + decrementSentMessageCount(): void { + this.set({ + messageCount: Math.max((this.get('messageCount') || 0) - 1, 0), + sentMessageCount: Math.max((this.get('sentMessageCount') || 0) - 1, 0), + }); + window.Signal.Data.updateConversation(this.attributes); + } + + /** + * This function is called when a message request is accepted in order to + * handle sending read receipts and download any pending attachments. + */ + async handleReadAndDownloadAttachments(): Promise { + let messages: MessageModelCollectionType | undefined; + do { + const first = messages ? messages.first() : undefined; + + // eslint-disable-next-line no-await-in-loop + messages = await window.Signal.Data.getOlderMessagesByConversation( + this.get('id'), + { + MessageCollection: window.Whisper.MessageCollection, + limit: 100, + receivedAt: first ? first.get('received_at') : undefined, + messageId: first ? first.id : undefined, + } + ); + + if (!messages.length) { + return; + } + + const readMessages = messages.filter( + m => !m.hasErrors() && m.isIncoming() + ); + const receiptSpecs = readMessages.map(m => ({ + senderE164: m.get('source'), + senderUuid: m.get('sourceUuid'), + senderId: window.ConversationController.ensureContactIds({ + e164: m.get('source'), + uuid: m.get('sourceUuid'), + }), + timestamp: m.get('sent_at'), + hasErrors: m.hasErrors(), + })); + // eslint-disable-next-line no-await-in-loop + await this.sendReadReceiptsFor(receiptSpecs); + // eslint-disable-next-line no-await-in-loop + await Promise.all(readMessages.map(m => m.queueAttachmentDownloads())); + } while (messages.length > 0); + } + + async applyMessageRequestResponse( + response: number, + { fromSync = false, viaStorageServiceSync = false } = {} + ): Promise { + // Apply message request response locally + this.set({ + messageRequestResponseType: response, + }); + window.Signal.Data.updateConversation(this.attributes); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (response === this.messageRequestEnum!.ACCEPT) { + this.unblock({ viaStorageServiceSync }); + this.enableProfileSharing({ viaStorageServiceSync }); + + if (!fromSync) { + this.sendProfileKeyUpdate(); + // Locally accepted + await this.handleReadAndDownloadAttachments(); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + } else if (response === this.messageRequestEnum!.BLOCK) { + // Block locally, other devices should block upon receiving the sync message + this.block({ viaStorageServiceSync }); + this.disableProfileSharing({ viaStorageServiceSync }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + } else if (response === this.messageRequestEnum!.DELETE) { + // Delete messages locally, other devices should delete upon receiving + // the sync message + this.destroyMessages(); + this.disableProfileSharing({ viaStorageServiceSync }); + this.updateLastMessage(); + if (!fromSync) { + this.trigger('unload', 'deleted from message request'); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + } else if (response === this.messageRequestEnum!.BLOCK_AND_DELETE) { + // Delete messages locally, other devices should delete upon receiving + // the sync message + this.destroyMessages(); + this.disableProfileSharing({ viaStorageServiceSync }); + this.updateLastMessage(); + // Block locally, other devices should block upon receiving the sync message + this.block({ viaStorageServiceSync }); + // Leave group if this was a local action + if (!fromSync) { + // TODO: DESKTOP-721 + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + this.leaveGroup(); + this.trigger('unload', 'blocked and deleted from message request'); + } + } + } + + async syncMessageRequestResponse(response: number): Promise { + // Let this run, no await + this.applyMessageRequestResponse(response); + + const { ourNumber, ourUuid } = this; + const { wrap, sendOptions } = window.ConversationController.prepareForSend( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ourNumber || ourUuid!, + { + syncMessage: true, + } + ); + + await wrap( + window.textsecure.messaging.syncMessageRequestResponse( + { + threadE164: this.get('e164'), + threadUuid: this.get('uuid'), + groupId: this.get('groupId'), + type: response, + }, + sendOptions + ) + ); + } + + onMessageError(): void { + this.updateVerified(); + } + + async safeGetVerified(): Promise { + const promise = window.textsecure.storage.protocol.getVerified(this.id); + return promise.catch( + () => window.textsecure.storage.protocol.VerifiedStatus.DEFAULT + ); + } + + async updateVerified(): Promise { + if (this.isPrivate()) { + await this.initialPromise; + const verified = await this.safeGetVerified(); + + if (this.get('verified') !== verified) { + this.set({ verified }); + window.Signal.Data.updateConversation(this.attributes); + } + + return; + } + + this.fetchContacts(); + + await Promise.all( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.contactCollection!.map(async contact => { + if (!contact.isMe()) { + await contact.updateVerified(); + } + }) + ); + + this.onMemberVerifiedChange(); + } + + setVerifiedDefault(options?: VerificationOptions): Promise { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const { DEFAULT } = this.verifiedEnum!; + return this.queueJob(() => this._setVerified(DEFAULT, options)); + } + + setVerified(options?: VerificationOptions): Promise { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const { VERIFIED } = this.verifiedEnum!; + return this.queueJob(() => this._setVerified(VERIFIED, options)); + } + + setUnverified(options: VerificationOptions): Promise { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const { UNVERIFIED } = this.verifiedEnum!; + return this.queueJob(() => this._setVerified(UNVERIFIED, options)); + } + + async _setVerified( + verified: number, + providedOptions?: VerificationOptions + ): Promise { + const options = providedOptions || {}; + window._.defaults(options, { + viaStorageServiceSync: false, + viaSyncMessage: false, + viaContactSync: false, + key: null, + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const { VERIFIED, UNVERIFIED } = this.verifiedEnum!; + + if (!this.isPrivate()) { + throw new Error( + 'You cannot verify a group conversation. ' + + 'You must verify individual contacts.' + ); + } + + const beginningVerified = this.get('verified'); + let keyChange; + if (options.viaSyncMessage) { + // handle the incoming key from the sync messages - need different + // behavior if that key doesn't match the current key + keyChange = await window.textsecure.storage.protocol.processVerifiedMessage( + this.id, + verified, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + options.key! + ); + } else { + keyChange = await window.textsecure.storage.protocol.setVerified( + this.id, + verified + ); + } + + this.set({ verified }); + window.Signal.Data.updateConversation(this.attributes); + + if ( + !options.viaStorageServiceSync && + !keyChange && + beginningVerified !== verified + ) { + this.captureChange(); + } + + // Three situations result in a verification notice in the conversation: + // 1) The message came from an explicit verification in another client (not + // a contact sync) + // 2) The verification value received by the contact sync is different + // from what we have on record (and it's not a transition to UNVERIFIED) + // 3) Our local verification status is VERIFIED and it hasn't changed, + // but the key did change (Key1/VERIFIED to Key2/VERIFIED - but we don't + // want to show DEFAULT->DEFAULT or UNVERIFIED->UNVERIFIED) + if ( + !options.viaContactSync || + (beginningVerified !== verified && verified !== UNVERIFIED) || + (keyChange && verified === VERIFIED) + ) { + await this.addVerifiedChange(this.id, verified === VERIFIED, { + local: !options.viaSyncMessage, + }); + } + if (!options.viaSyncMessage) { + await this.sendVerifySyncMessage( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.get('e164')!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.get('uuid')!, + verified + ); + } + + return keyChange; + } + + async sendVerifySyncMessage( + e164: string, + uuid: string, + state: number + ): Promise { + // Because syncVerification sends a (null) message to the target of the verify and + // a sync message to our own devices, we need to send the accessKeys down for both + // contacts. So we merge their sendOptions. + const { sendOptions } = window.ConversationController.prepareForSend( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.ourNumber || this.ourUuid!, + { syncMessage: true } + ); + const contactSendOptions = this.getSendOptions(); + const options = { ...sendOptions, ...contactSendOptions }; + + const promise = window.textsecure.storage.protocol.loadIdentityKey(e164); + return promise.then(key => + this.wrapSend( + window.textsecure.messaging.syncVerification( + e164, + uuid, + state, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + key!, + options + ) + ) + ); + } + + isVerified(): boolean { + if (this.isPrivate()) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.get('verified') === this.verifiedEnum!.VERIFIED; + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (!this.contactCollection!.length) { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.contactCollection!.every(contact => { + if (contact.isMe()) { + return true; + } + return contact.isVerified(); + }); + } + + isUnverified(): boolean { + if (this.isPrivate()) { + const verified = this.get('verified'); + return ( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + verified !== this.verifiedEnum!.VERIFIED && + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + verified !== this.verifiedEnum!.DEFAULT + ); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (!this.contactCollection!.length) { + return true; + } + + // Array.any does not exist. This is probably broken. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.contactCollection!.any(contact => { + if (contact.isMe()) { + return false; + } + return contact.isUnverified(); + }); + } + + getUnverified(): Backbone.Collection { + if (this.isPrivate()) { + return this.isUnverified() + ? new window.Backbone.Collection([this]) + : new window.Backbone.Collection(); + } + return new window.Backbone.Collection( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.contactCollection!.filter(contact => { + if (contact.isMe()) { + return false; + } + return contact.isUnverified(); + }) + ); + } + + setApproved(): boolean | void { + if (!this.isPrivate()) { + throw new Error( + 'You cannot set a group conversation as trusted. ' + + 'You must set individual contacts as trusted.' + ); + } + + return window.textsecure.storage.protocol.setApproval(this.id, true); + } + + async safeIsUntrusted(): Promise { + return window.textsecure.storage.protocol + .isUntrusted(this.id) + .catch(() => false); + } + + async isUntrusted(): Promise { + if (this.isPrivate()) { + return this.safeIsUntrusted(); + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (!this.contactCollection!.length) { + return Promise.resolve(false); + } + + return Promise.all( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.contactCollection!.map(contact => { + if (contact.isMe()) { + return false; + } + return contact.safeIsUntrusted(); + }) + ).then(results => window._.any(results, result => result)); + } + + async getUntrusted(): Promise { + // This is a bit ugly because isUntrusted() is async. Could do the work to cache + // it locally, but we really only need it for this call. + if (this.isPrivate()) { + return this.isUntrusted().then(untrusted => { + if (untrusted) { + return new window.Backbone.Collection([this]); + } + + return new window.Backbone.Collection(); + }); + } + return Promise.all( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.contactCollection!.map(contact => { + if (contact.isMe()) { + return [false, contact]; + } + return Promise.all([contact.isUntrusted(), contact]); + }) + ).then(results => { + const filtered = window._.filter(results, result => { + const untrusted = result[0]; + return untrusted; + }); + return new window.Backbone.Collection( + window._.map(filtered, result => { + const contact = result[1]; + return contact; + }) + ); + }); + } + + getSentMessageCount(): number { + return this.get('sentMessageCount') || 0; + } + + getMessageRequestResponseType(): number { + return this.get('messageRequestResponseType') || 0; + } + + /** + * Determine if this conversation should be considered "accepted" in terms + * of message requests + */ + getAccepted(): boolean { + const messageRequestsEnabled = window.Signal.RemoteConfig.isEnabled( + 'desktop.messageRequests' + ); + + if (!messageRequestsEnabled) { + return true; + } + + if (this.isMe()) { + return true; + } + + if ( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.getMessageRequestResponseType() === this.messageRequestEnum!.ACCEPT + ) { + return true; + } + + const isFromOrAddedByTrustedContact = this.isFromOrAddedByTrustedContact(); + const hasSentMessages = this.getSentMessageCount() > 0; + const hasMessagesBeforeMessageRequests = + (this.get('messageCountBeforeMessageRequests') || 0) > 0; + const hasNoMessages = (this.get('messageCount') || 0) === 0; + + const isEmptyPrivateConvo = hasNoMessages && this.isPrivate(); + const isEmptyWhitelistedGroup = + hasNoMessages && !this.isPrivate() && this.get('profileSharing'); + + return ( + isFromOrAddedByTrustedContact || + hasSentMessages || + hasMessagesBeforeMessageRequests || + // an empty group is the scenario where we need to rely on + // whether the profile has already been shared or not + isEmptyPrivateConvo || + isEmptyWhitelistedGroup + ); + } + + onMemberVerifiedChange(): void { + // If the verified state of a member changes, our aggregate state changes. + // We trigger both events to replicate the behavior of window.Backbone.Model.set() + this.trigger('change:verified', this); + this.trigger('change', this); + } + + async toggleVerified(): Promise { + if (this.isVerified()) { + return this.setVerifiedDefault(); + } + return this.setVerified(); + } + + async addKeyChange(keyChangedId: string): Promise { + window.log.info( + 'adding key change advisory for', + this.idForLogging(), + keyChangedId, + this.get('timestamp') + ); + + const timestamp = Date.now(); + const message = ({ + conversationId: this.id, + type: 'keychange', + sent_at: this.get('timestamp'), + received_at: timestamp, + key_changed: keyChangedId, + unread: 1, + // TODO: DESKTOP-722 + // this type does not fully implement the interface it is expected to + } as unknown) as typeof window.Whisper.MessageAttributesType; + + const id = await window.Signal.Data.saveMessage(message, { + Message: window.Whisper.Message, + }); + const model = window.MessageController.register( + id, + new window.Whisper.Message({ + ...message, + id, + }) + ); + + this.trigger('newmessage', model); + } + + async addVerifiedChange( + verifiedChangeId: string, + verified: boolean, + providedOptions: Record + ): Promise { + const options = providedOptions || {}; + window._.defaults(options, { local: true }); + + if (this.isMe()) { + window.log.info( + 'refusing to add verified change advisory for our own number' + ); + return; + } + + const lastMessage = this.get('timestamp') || Date.now(); + + window.log.info( + 'adding verified change advisory for', + this.idForLogging(), + verifiedChangeId, + lastMessage + ); + + const timestamp = Date.now(); + const message = ({ + conversationId: this.id, + type: 'verified-change', + sent_at: lastMessage, + received_at: timestamp, + verifiedChanged: verifiedChangeId, + verified, + local: options.local, + unread: 1, + // TODO: DESKTOP-722 + } as unknown) as typeof window.Whisper.MessageAttributesType; + + const id = await window.Signal.Data.saveMessage(message, { + Message: window.Whisper.Message, + }); + const model = window.MessageController.register( + id, + new window.Whisper.Message({ + ...message, + id, + }) + ); + + this.trigger('newmessage', model); + + if (this.isPrivate()) { + window.ConversationController.getAllGroupsInvolvingId(this.id).then( + groups => { + window._.forEach(groups, group => { + group.addVerifiedChange(this.id, verified, options); + }); + } + ); + } + } + + async addCallHistory( + callHistoryDetails: Record + ): Promise { + const { acceptedTime, endedTime, wasDeclined } = callHistoryDetails; + const message = ({ + conversationId: this.id, + type: 'call-history', + sent_at: endedTime, + received_at: endedTime, + unread: !wasDeclined && !acceptedTime, + callHistoryDetails, + // TODO: DESKTOP-722 + } as unknown) as typeof window.Whisper.MessageAttributesType; + + const id = await window.Signal.Data.saveMessage(message, { + Message: window.Whisper.Message, + }); + const model = window.MessageController.register( + id, + new window.Whisper.Message({ + ...message, + id, + }) + ); + + this.trigger('newmessage', model); + } + + async addProfileChange( + profileChange: unknown, + conversationId?: string + ): Promise { + const message = ({ + conversationId: this.id, + type: 'profile-change', + sent_at: Date.now(), + received_at: Date.now(), + unread: true, + changedId: conversationId || this.id, + profileChange, + // TODO: DESKTOP-722 + } as unknown) as typeof window.Whisper.MessageAttributesType; + + const id = await window.Signal.Data.saveMessage(message, { + Message: window.Whisper.Message, + }); + const model = window.MessageController.register( + id, + new window.Whisper.Message({ + ...message, + id, + }) + ); + + this.trigger('newmessage', model); + + if (this.isPrivate()) { + window.ConversationController.getAllGroupsInvolvingId(this.id).then( + groups => { + window._.forEach(groups, group => { + group.addProfileChange(profileChange, this.id); + }); + } + ); + } + } + + async onReadMessage( + message: MessageModel, + readAt?: number + ): Promise { + // We mark as read everything older than this message - to clean up old stuff + // still marked unread in the database. If the user generally doesn't read in + // the desktop app, so the desktop app only gets read syncs, we can very + // easily end up with messages never marked as read (our previous early read + // sync handling, read syncs never sent because app was offline) + + // We queue it because we often get a whole lot of read syncs at once, and + // their markRead calls could very easily overlap given the async pull from DB. + + // Lastly, we don't send read syncs for any message marked read due to a read + // sync. That's a notification explosion we don't need. + return this.queueJob(() => + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.markRead(message.get('received_at')!, { + sendReadReceipts: false, + readAt, + }) + ); + } + + getUnread(): Promise { + return window.Signal.Data.getUnreadByConversation(this.id, { + MessageCollection: window.Whisper.MessageCollection, + }); + } + + validate(attributes = this.attributes): string | null { + const required = ['type']; + const missing = window._.filter(required, attr => !attributes[attr]); + if (missing.length) { + return `Conversation must have ${missing}`; + } + + if (attributes.type !== 'private' && attributes.type !== 'group') { + return `Invalid conversation type: ${attributes.type}`; + } + + const atLeastOneOf = ['e164', 'uuid', 'groupId']; + const hasAtLeastOneOf = + window._.filter(atLeastOneOf, attr => attributes[attr]).length > 0; + + if (!hasAtLeastOneOf) { + return 'Missing one of e164, uuid, or groupId'; + } + + const error = this.validateNumber() || this.validateUuid(); + + if (error) { + return error; + } + + return null; + } + + validateNumber(): string | null { + if (this.isPrivate() && this.get('e164')) { + const regionCode = window.storage.get('regionCode'); + const number = window.libphonenumber.util.parseNumber( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.get('e164')!, + regionCode + ); + // TODO: DESKTOP-723 + // This is valid, but the typing thinks it's a function. + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if (number.isValidNumber) { + this.set({ e164: number.e164 }); + return null; + } + + return number.error || 'Invalid phone number'; + } + + return null; + } + + validateUuid(): string | null { + if (this.isPrivate() && this.get('uuid')) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (window.isValidGuid(this.get('uuid')!)) { + return null; + } + + return 'Invalid UUID'; + } + + return null; + } + + queueJob(callback: () => unknown | Promise): Promise { + this.jobQueue = this.jobQueue || new window.PQueue({ concurrency: 1 }); + + const taskWithTimeout = window.textsecure.createTaskWithTimeout( + callback, + `conversation ${this.idForLogging()}` + ); + + return this.jobQueue.add(taskWithTimeout); + } + + getMembers(): Array { + if (this.isPrivate()) { + return [this]; + } + + if (this.get('membersV2')) { + return window._.compact( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.get('membersV2')!.map(member => { + const c = window.ConversationController.get(member.conversationId); + + // In groups we won't sent to contacts we believe are unregistered + if (c && c.isUnregistered()) { + return null; + } + + return c; + }) + ); + } + + if (this.get('members')) { + return window._.compact( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.get('members')!.map(id => { + const c = window.ConversationController.get(id); + + // In groups we won't sent to contacts we believe are unregistered + if (c && c.isUnregistered()) { + return null; + } + + return c; + }) + ); + } + + window.log.warn( + 'getMembers: Group conversation had neither membersV2 nor members' + ); + + return []; + } + + getMemberIds(): Array { + const members = this.getMembers(); + return members.map(member => member.id); + } + + getRecipients(): Array { + const members = this.getMembers(); + + // Eliminate our + return window._.compact( + members.map(member => (member.isMe() ? null : member.getSendTarget())) + ); + } + + async getQuoteAttachment( + attachments: Array, + preview: Array, + sticker: WhatIsThis + ): Promise { + if (attachments && attachments.length) { + return Promise.all( + attachments + .filter( + attachment => + attachment && + attachment.contentType && + !attachment.pending && + !attachment.error + ) + .slice(0, 1) + .map(async attachment => { + const { fileName, thumbnail, contentType } = attachment; + + return { + contentType, + // Our protos library complains about this field being undefined, so we + // force it to null + fileName: fileName || null, + thumbnail: thumbnail + ? { + ...(await loadAttachmentData(thumbnail)), + objectUrl: getAbsoluteAttachmentPath(thumbnail.path), + } + : null, + }; + }) + ); + } + + if (preview && preview.length) { + return Promise.all( + preview + .filter(item => item && item.image) + .slice(0, 1) + .map(async attachment => { + const { image } = attachment; + const { contentType } = image; + + return { + contentType, + // Our protos library complains about this field being undefined, so we + // force it to null + fileName: null, + thumbnail: image + ? { + ...(await loadAttachmentData(image)), + objectUrl: getAbsoluteAttachmentPath(image.path), + } + : null, + }; + }) + ); + } + + if (sticker && sticker.data && sticker.data.path) { + const { path, contentType } = sticker.data; + + return [ + { + contentType, + // Our protos library complains about this field being undefined, so we + // force it to null + fileName: null, + thumbnail: { + ...(await loadAttachmentData(sticker.data)), + objectUrl: getAbsoluteAttachmentPath(path), + }, + }, + ]; + } + + return []; + } + + async makeQuote( + quotedMessage: typeof window.Whisper.MessageType + ): Promise { + const { getName } = Contact; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const contact = quotedMessage.getContact()!; + const attachments = quotedMessage.get('attachments'); + const preview = quotedMessage.get('preview'); + const sticker = quotedMessage.get('sticker'); + + const body = quotedMessage.get('body'); + const embeddedContact = quotedMessage.get('contact'); + const embeddedContactName = + embeddedContact && embeddedContact.length > 0 + ? getName(embeddedContact[0]) + : ''; + + return { + author: contact.get('e164'), + authorUuid: contact.get('uuid'), + bodyRanges: quotedMessage.get('bodyRanges'), + id: quotedMessage.get('sent_at'), + text: body || embeddedContactName, + attachments: quotedMessage.isTapToView() + ? [{ contentType: 'image/jpeg', fileName: null }] + : await this.getQuoteAttachment(attachments, preview, sticker), + }; + } + + async sendStickerMessage(packId: string, stickerId: number): Promise { + const packData = window.Signal.Stickers.getStickerPack(packId); + const stickerData = window.Signal.Stickers.getSticker(packId, stickerId); + if (!stickerData || !packData) { + window.log.warn( + `Attempted to send nonexistent (${packId}, ${stickerId}) sticker!` + ); + return; + } + + const { key } = packData; + const { path, width, height } = stickerData; + const arrayBuffer = await readStickerData(path); + + const sticker = { + packId, + stickerId, + packKey: key, + data: { + size: arrayBuffer.byteLength, + data: arrayBuffer, + contentType: 'image/webp', + width, + height, + }, + }; + + this.sendMessage(null, [], null, [], sticker); + window.reduxActions.stickers.useSticker(packId, stickerId); + } + + async sendReactionMessage( + reaction: { emoji: string; remove: boolean }, + target: { + targetAuthorE164: string; + targetAuthorUuid: string; + targetTimestamp: number; + } + ): Promise { + const timestamp = Date.now(); + const outgoingReaction = { ...reaction, ...target }; + const expireTimer = this.get('expireTimer'); + + const reactionModel = window.Whisper.Reactions.add({ + ...outgoingReaction, + fromId: window.ConversationController.getOurConversationId(), + timestamp, + fromSync: true, + }); + window.Whisper.Reactions.onReaction(reactionModel); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const destination = this.getSendTarget()!; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const recipients = this.getRecipients()!; + + let profileKey: ArrayBuffer | undefined; + if (this.get('profileSharing')) { + profileKey = window.storage.get('profileKey'); + } + + return this.queueJob(async () => { + window.log.info( + 'Sending reaction to conversation', + this.idForLogging(), + 'with timestamp', + timestamp + ); + + const attributes = ({ + id: window.getGuid(), + type: 'outgoing', + conversationId: this.get('id'), + sent_at: timestamp, + received_at: timestamp, + recipients, + reaction: outgoingReaction, + // TODO: DESKTOP-722 + } as unknown) as typeof window.Whisper.MessageAttributesType; + + if (this.isPrivate()) { + attributes.destination = destination; + } + + // We are only creating this model so we can use its sync message + // sending functionality. It will not be saved to the datbase. + const message = new window.Whisper.Message(attributes); + + // We're offline! + if (!window.textsecure.messaging) { + throw new Error('Cannot send reaction while offline!'); + } + + // Special-case the self-send case - we send only a sync message + if (this.isMe()) { + const dataMessage = await window.textsecure.messaging.getMessageProto( + destination, + undefined, // body + [], // attachments + undefined, // quote + [], // preview + undefined, // sticker + outgoingReaction, + timestamp, + expireTimer, + profileKey + ); + return message.sendSyncMessageOnly(dataMessage); + } + + const options = this.getSendOptions(); + + const promise = (() => { + if (this.isPrivate()) { + return window.textsecure.messaging.sendMessageToIdentifier( + destination, + undefined, // body + [], // attachments + undefined, // quote + [], // preview + undefined, // sticker + outgoingReaction, + timestamp, + expireTimer, + profileKey, + options + ); + } + + return window.textsecure.messaging.sendMessageToGroup( + { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + groupV1: this.getGroupV1Info()!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + groupV2: this.getGroupV2Info()!, + reaction: outgoingReaction, + timestamp, + expireTimer, + profileKey, + }, + options + ); + })(); + + return message.send(this.wrapSend(promise)); + }).catch(error => { + window.log.error('Error sending reaction', reaction, target, error); + + const reverseReaction = reactionModel.clone(); + reverseReaction.set('remove', !reverseReaction.get('remove')); + window.Whisper.Reactions.onReaction(reverseReaction); + + throw error; + }); + } + + async sendProfileKeyUpdate(): Promise { + const id = this.get('id'); + const recipients = this.getRecipients(); + if (!this.get('profileSharing')) { + window.log.error( + 'Attempted to send profileKeyUpdate to conversation without profileSharing enabled', + id, + recipients + ); + return; + } + window.log.info('Sending profileKeyUpdate to conversation', id, recipients); + const profileKey = window.storage.get('profileKey'); + await window.textsecure.messaging.sendProfileKeyUpdate( + profileKey, + recipients, + this.getSendOptions(), + this.get('groupId') + ); + } + + sendMessage( + body: string | null, + attachments: Array, + quote: WhatIsThis, + preview: WhatIsThis, + sticker: WhatIsThis + ): void { + this.clearTypingTimers(); + + const { clearUnreadMetrics } = window.reduxActions.conversations; + clearUnreadMetrics(this.id); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const destination = this.getSendTarget()!; + const expireTimer = this.get('expireTimer'); + const recipients = this.getRecipients(); + + let profileKey: ArrayBuffer | undefined; + if (this.get('profileSharing')) { + profileKey = window.storage.get('profileKey'); + } + + this.queueJob(async () => { + const now = Date.now(); + + window.log.info( + 'Sending message to conversation', + this.idForLogging(), + 'with timestamp', + now + ); + + // Here we move attachments to disk + const messageWithSchema = await upgradeMessageSchema({ + type: 'outgoing', + body, + conversationId: this.id, + quote, + preview, + attachments, + sent_at: now, + received_at: now, + expireTimer, + recipients, + sticker, + }); + + if (this.isPrivate()) { + messageWithSchema.destination = destination; + } + const attributes = { + ...messageWithSchema, + id: window.getGuid(), + }; + + const model = this.addSingleMessage(attributes); + if (sticker) { + await addStickerPackReference(model.id, sticker.packId); + } + const message = window.MessageController.register(model.id, model); + await window.Signal.Data.saveMessage(message.attributes, { + forceSave: true, + Message: window.Whisper.Message, + }); + + this.set({ + lastMessage: model.getNotificationText(), + lastMessageStatus: 'sending', + active_at: now, + timestamp: now, + isArchived: false, + draft: null, + draftTimestamp: null, + }); + this.incrementSentMessageCount(); + window.Signal.Data.updateConversation(this.attributes); + + // We're offline! + if (!window.textsecure.messaging) { + const errors = [ + ...(this.contactCollection && this.contactCollection.length + ? this.contactCollection + : [this]), + ].map(contact => { + const error = new Error('Network is not available') as CustomError; + error.name = 'SendMessageNetworkError'; + error.identifier = contact.get('id'); + return error; + }); + await message.saveErrors(errors); + return null; + } + + const attachmentsWithData = await Promise.all( + messageWithSchema.attachments.map(loadAttachmentData) + ); + + const { + body: messageBody, + attachments: finalAttachments, + } = window.Whisper.Message.getLongMessageAttachment({ + body, + attachments: attachmentsWithData, + now, + }); + + // Special-case the self-send case - we send only a sync message + if (this.isMe()) { + const dataMessage = await window.textsecure.messaging.getMessageProto( + destination, + messageBody, + finalAttachments, + quote, + preview, + sticker, + null, // reaction + now, + expireTimer, + profileKey + ); + return message.sendSyncMessageOnly(dataMessage); + } + + const conversationType = this.get('type'); + const options = this.getSendOptions(); + + let promise; + if (conversationType === Message.GROUP) { + promise = window.textsecure.messaging.sendMessageToGroup( + { + attachments: finalAttachments, + expireTimer, + groupV1: this.getGroupV1Info(), + groupV2: this.getGroupV2Info(), + messageText: messageBody, + preview, + profileKey, + quote, + sticker, + timestamp: now, + }, + options + ); + } else { + promise = window.textsecure.messaging.sendMessageToIdentifier( + destination, + messageBody, + finalAttachments, + quote, + preview, + sticker, + null, // reaction + now, + expireTimer, + profileKey, + options + ); + } + + return message.send(this.wrapSend(promise)); + }); + } + + async wrapSend( + promise: Promise + ): Promise { + return promise.then( + async result => { + // success + if (result) { + await this.handleMessageSendResult( + result.failoverIdentifiers, + result.unidentifiedDeliveries, + result.discoveredIdentifierPairs + ); + } + return result; + }, + async result => { + // failure + if (result) { + await this.handleMessageSendResult( + result.failoverIdentifiers, + result.unidentifiedDeliveries, + result.discoveredIdentifierPairs + ); + } + throw result; + } + ); + } + + async handleMessageSendResult( + failoverIdentifiers: Array | undefined, + unidentifiedDeliveries: Array | undefined, + discoveredIdentifierPairs: Array<{ + uuid: string | null; + e164: string | null; + }> + ): Promise { + discoveredIdentifierPairs.forEach(item => { + const { uuid, e164 } = item; + window.ConversationController.ensureContactIds({ + uuid, + e164, + highTrust: true, + }); + }); + + await Promise.all( + (failoverIdentifiers || []).map(async identifier => { + const conversation = window.ConversationController.get(identifier); + + if ( + conversation && + conversation.get('sealedSender') !== SEALED_SENDER.DISABLED + ) { + window.log.info( + `Setting sealedSender to DISABLED for conversation ${conversation.idForLogging()}` + ); + conversation.set({ + sealedSender: SEALED_SENDER.DISABLED, + }); + window.Signal.Data.updateConversation(conversation.attributes); + } + }) + ); + + await Promise.all( + (unidentifiedDeliveries || []).map(async identifier => { + const conversation = window.ConversationController.get(identifier); + + if ( + conversation && + conversation.get('sealedSender') === SEALED_SENDER.UNKNOWN + ) { + if (conversation.get('accessKey')) { + window.log.info( + `Setting sealedSender to ENABLED for conversation ${conversation.idForLogging()}` + ); + conversation.set({ + sealedSender: SEALED_SENDER.ENABLED, + }); + } else { + window.log.info( + `Setting sealedSender to UNRESTRICTED for conversation ${conversation.idForLogging()}` + ); + conversation.set({ + sealedSender: SEALED_SENDER.UNRESTRICTED, + }); + } + window.Signal.Data.updateConversation(conversation.attributes); + } + }) + ); + } + + getSendOptions(options = {}): WhatIsThis { + const senderCertificate = window.storage.get('senderCertificate'); + const sendMetadata = this.getSendMetadata(options); + + return { + senderCertificate, + sendMetadata, + }; + } + + getUuidCapable(): boolean { + return Boolean(window._.property('uuid')(this.get('capabilities'))); + } + + getSendMetadata( + options: { syncMessage?: string; disableMeCheck?: boolean } = {} + ): WhatIsThis | null { + const { syncMessage, disableMeCheck } = options; + + // 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 = window.ConversationController.getOurConversationId(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const me = window.ConversationController.get(myId)!; + if (!disableMeCheck && me.get('sealedSender') === SEALED_SENDER.DISABLED) { + return null; + } + // END + + if (!this.isPrivate()) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const infoArray = this.contactCollection!.map(conversation => + conversation.getSendMetadata(options) + ); + return Object.assign({}, ...infoArray); + } + + const accessKey = this.get('accessKey'); + const sealedSender = this.get('sealedSender'); + const uuidCapable = this.getUuidCapable(); + + // We never send sync messages as sealed sender + if (syncMessage && this.isMe()) { + return null; + } + + const e164 = this.get('e164'); + const uuid = this.get('uuid'); + + // If we've never fetched user's profile, we default to what we have + if (sealedSender === SEALED_SENDER.UNKNOWN) { + const info = { + accessKey: accessKey || arrayBufferToBase64(getRandomBytes(16)), + // Indicates that a client is capable of receiving uuid-only messages. + // Not used yet. + uuidCapable, + }; + return { + ...(e164 ? { [e164]: info } : {}), + ...(uuid ? { [uuid]: info } : {}), + }; + } + + if (sealedSender === SEALED_SENDER.DISABLED) { + return null; + } + + const info = { + accessKey: + accessKey && sealedSender === SEALED_SENDER.ENABLED + ? accessKey + : arrayBufferToBase64(getRandomBytes(16)), + // Indicates that a client is capable of receiving uuid-only messages. + // Not used yet. + uuidCapable, + }; + + return { + ...(e164 ? { [e164]: info } : {}), + ...(uuid ? { [uuid]: info } : {}), + }; + } + + // Is this someone who is a contact, or are we sharing our profile with them? + // Or is the person who added us to this group a contact or are we sharing profile + // with them? + isFromOrAddedByTrustedContact(): boolean { + if (this.isPrivate()) { + return Boolean(this.get('name')) || this.get('profileSharing'); + } + + const addedBy = this.get('addedBy'); + if (!addedBy) { + return false; + } + + const conv = window.ConversationController.get(addedBy); + if (!conv) { + return false; + } + + return Boolean(conv.get('name')) || conv.get('profileSharing'); + } + + async updateLastMessage(): Promise { + if (!this.id) { + return; + } + + const [previewMessage, activityMessage] = await Promise.all([ + window.Signal.Data.getLastConversationPreview(this.id, { + Message: window.Whisper.Message, + }), + window.Signal.Data.getLastConversationActivity(this.id, { + Message: window.Whisper.Message, + }), + ]); + + if ( + this.hasDraft() && + this.get('draftTimestamp') && + (!previewMessage || + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + previewMessage.get('sent_at') < this.get('draftTimestamp')!) + ) { + return; + } + + const currentTimestamp = this.get('timestamp') || null; + const timestamp = activityMessage + ? activityMessage.get('sent_at') || + activityMessage.get('received_at') || + currentTimestamp + : currentTimestamp; + + this.set({ + lastMessage: + (previewMessage ? previewMessage.getNotificationText() : '') || '', + lastMessageStatus: + (previewMessage ? previewMessage.getMessagePropStatus() : null) || null, + timestamp, + lastMessageDeletedForEveryone: previewMessage + ? previewMessage.deletedForEveryone + : false, + }); + + window.Signal.Data.updateConversation(this.attributes); + } + + setArchived(isArchived: boolean): void { + const before = this.get('isArchived'); + + this.set({ isArchived }); + window.Signal.Data.updateConversation(this.attributes); + + const after = this.get('isArchived'); + + if (Boolean(before) !== Boolean(after)) { + this.captureChange(); + } + } + + async updateExpirationTimerInGroupV2(seconds?: number): Promise { + // Make change on the server + const actions = window.Signal.Groups.buildDisappearingMessagesTimerChange({ + expireTimer: seconds || 0, + group: this.attributes, + }); + let signedGroupChange; + try { + signedGroupChange = await window.Signal.Groups.uploadGroupChange({ + actions, + group: this.attributes, + serverPublicParamsBase64: window.getServerPublicParams(), + }); + } catch (error) { + // Get latest GroupV2 data, since we ran into trouble updating it + this.fetchLatestGroupV2Data(); + throw error; + } + + // Update local conversation + this.set({ + expireTimer: seconds || 0, + revision: actions.version, + }); + window.Signal.Data.updateConversation(this.attributes); + + // Create local notification + const timestamp = Date.now(); + const id = window.getGuid(); + const message = window.MessageController.register( + id, + new window.Whisper.Message(({ + id, + conversationId: this.id, + sent_at: timestamp, + received_at: timestamp, + flags: + window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, + expirationTimerUpdate: { + expireTimer: seconds, + sourceUuid: this.ourUuid, + }, + // TODO: DESKTOP-722 + } as unknown) as typeof window.Whisper.MessageAttributesType) + ); + await window.Signal.Data.saveMessage(message.attributes, { + Message: window.Whisper.Message, + forceSave: true, + }); + this.trigger('newmessage', message); + + // Send message to all group members + const profileKey = this.get('profileSharing') + ? window.storage.get('profileKey') + : undefined; + const sendOptions = this.getSendOptions(); + const promise = window.textsecure.messaging.sendMessageToGroup( + { + groupV2: this.getGroupV2Info(signedGroupChange.toArrayBuffer()), + timestamp, + profileKey, + }, + sendOptions + ); + + message.send(promise); + } + + async updateExpirationTimer( + providedExpireTimer: number | undefined, + providedSource: unknown, + receivedAt: number, + options: { fromSync?: unknown; fromGroupUpdate?: unknown } = {} + ): Promise { + if (this.get('groupVersion') === 2) { + if (providedSource || receivedAt) { + throw new Error( + 'updateExpirationTimer: GroupV2 timers are not updated this way' + ); + } + await this.updateExpirationTimerInGroupV2(providedExpireTimer); + return false; + } + + let expireTimer: number | undefined = providedExpireTimer; + let source = providedSource; + if (this.get('left')) { + return false; + } + + window._.defaults(options, { fromSync: false, fromGroupUpdate: false }); + + if (!expireTimer) { + expireTimer = undefined; + } + if ( + this.get('expireTimer') === expireTimer || + (!expireTimer && !this.get('expireTimer')) + ) { + return null; + } + + window.log.info("Update conversation 'expireTimer'", { + id: this.idForLogging(), + expireTimer, + source, + }); + + source = source || window.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. + const timestamp = (receivedAt || Date.now()) - 1; + + this.set({ expireTimer }); + window.Signal.Data.updateConversation(this.attributes); + + const model = new window.Whisper.Message(({ + // Even though this isn't reflected to the user, we want to place the last seen + // indicator above it. We set it to 'unread' to trigger that placement. + unread: 1, + conversationId: this.id, + // No type; 'incoming' messages are specially treated by conversation.markRead() + sent_at: timestamp, + received_at: timestamp, + flags: + window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, + expirationTimerUpdate: { + expireTimer, + source, + fromSync: options.fromSync, + fromGroupUpdate: options.fromGroupUpdate, + }, + // TODO: DESKTOP-722 + } as unknown) as MessageAttributesType); + + if (this.isPrivate()) { + model.set({ destination: this.getSendTarget() }); + } + if (model.isOutgoing()) { + model.set({ recipients: this.getRecipients() }); + } + const id = await window.Signal.Data.saveMessage(model.attributes, { + Message: window.Whisper.Message, + }); + + model.set({ id }); + + const message = window.MessageController.register(id, model); + this.addSingleMessage(message); + + // if change was made remotely, don't send it to the number/group + if (receivedAt) { + return message; + } + + let profileKey; + if (this.get('profileSharing')) { + profileKey = window.storage.get('profileKey'); + } + const sendOptions = this.getSendOptions(); + let promise; + + if (this.isMe()) { + const flags = + window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE; + const dataMessage = await window.textsecure.messaging.getMessageProto( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.getSendTarget()!, + undefined, // body + [], // attachments + undefined, // quote + [], // preview + undefined, // sticker + undefined, // reaction + message.get('sent_at'), + expireTimer, + profileKey, + flags + ); + return message.sendSyncMessageOnly(dataMessage); + } + + if (this.get('type') === 'private') { + promise = window.textsecure.messaging.sendExpirationTimerUpdateToIdentifier( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.getSendTarget()!, + expireTimer, + message.get('sent_at'), + profileKey, + sendOptions + ); + } else { + promise = window.textsecure.messaging.sendExpirationTimerUpdateToGroup( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.get('groupId')!, + this.getRecipients(), + expireTimer, + message.get('sent_at'), + profileKey, + sendOptions + ); + } + + await message.send(this.wrapSend(promise)); + + return message; + } + + async addMessageHistoryDisclaimer(): Promise { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const lastMessage = this.messageCollection!.last(); + if (lastMessage && lastMessage.get('type') === 'message-history-unsynced') { + // We do not need another message history disclaimer + return lastMessage; + } + + const timestamp = Date.now(); + + const model = new window.Whisper.Message(({ + type: 'message-history-unsynced', + // Even though this isn't reflected to the user, we want to place the last seen + // indicator above it. We set it to 'unread' to trigger that placement. + unread: 1, + conversationId: this.id, + // No type; 'incoming' messages are specially treated by conversation.markRead() + sent_at: timestamp, + received_at: timestamp, + // TODO: DESKTOP-722 + } as unknown) as MessageAttributesType); + + if (this.isPrivate()) { + model.set({ destination: this.id }); + } + if (model.isOutgoing()) { + model.set({ recipients: this.getRecipients() }); + } + const id = await window.Signal.Data.saveMessage(model.attributes, { + Message: window.Whisper.Message, + }); + + model.set({ id }); + + const message = window.MessageController.register(id, model); + this.addSingleMessage(message); + + return message; + } + + isSearchable(): boolean { + return !this.get('left'); + } + + async endSession(): Promise { + if (this.isPrivate()) { + const now = Date.now(); + const model = new window.Whisper.Message(({ + conversationId: this.id, + type: 'outgoing', + sent_at: now, + received_at: now, + destination: this.get('e164'), + destinationUuid: this.get('uuid'), + recipients: this.getRecipients(), + flags: window.textsecure.protobuf.DataMessage.Flags.END_SESSION, + // TODO: DESKTOP-722 + } as unknown) as MessageAttributesType); + + const id = await window.Signal.Data.saveMessage(model.attributes, { + Message: window.Whisper.Message, + }); + model.set({ id }); + + const message = window.MessageController.register(model.id, model); + this.addSingleMessage(message); + + const options = this.getSendOptions(); + message.send( + this.wrapSend( + // TODO: DESKTOP-724 + // resetSession returns `Array` which is incompatible with the + // expected promise return values. `[]` is truthy and wrapSend assumes + // it's a valid callback result type + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + window.textsecure.messaging.resetSession( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.get('uuid')!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.get('e164')!, + now, + options + ) + ) + ); + } + } + + async markRead( + newestUnreadDate: number, + providedOptions: { readAt?: number; sendReadReceipts: boolean } + ): Promise { + const options = providedOptions || {}; + window._.defaults(options, { sendReadReceipts: true }); + + const conversationId = this.id; + window.Whisper.Notifications.removeBy({ conversationId }); + + let unreadMessages: + | MessageModelCollectionType + | Array = await this.getUnread(); + const oldUnread = unreadMessages.filter( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + message => message.get('received_at')! <= newestUnreadDate + ); + + let read = await Promise.all( + window._.map(oldUnread, async providedM => { + const m = window.MessageController.register(providedM.id, providedM); + + // Note that this will update the message in the database + await m.markRead(options.readAt); + + return { + senderE164: m.get('source'), + senderUuid: m.get('sourceUuid'), + senderId: window.ConversationController.ensureContactIds({ + e164: m.get('source'), + uuid: m.get('sourceUuid'), + }), + timestamp: m.get('sent_at'), + hasErrors: m.hasErrors(), + }; + }) + ); + + // Some messages we're marking read are local notifications with no sender + read = window._.filter(read, m => Boolean(m.senderId)); + unreadMessages = unreadMessages.filter(m => Boolean(m.isIncoming())); + + const unreadCount = unreadMessages.length - read.length; + this.set({ unreadCount }); + window.Signal.Data.updateConversation(this.attributes); + + // If a message has errors, we don't want to send anything out about it. + // read syncs - let's wait for a client that really understands the message + // to mark it read. we'll mark our local error read locally, though. + // read receipts - here we can run into infinite loops, where each time the + // conversation is viewed, another error message shows up for the contact + read = read.filter(item => !item.hasErrors); + + if (read.length && options.sendReadReceipts) { + window.log.info(`Sending ${read.length} read syncs`); + // Because syncReadMessages sends to our other devices, and sendReadReceipts goes + // to a contact, we need accessKeys for both. + const { + sendOptions, + } = window.ConversationController.prepareForSend( + window.ConversationController.getOurConversationId(), + { syncMessage: true } + ); + await this.wrapSend( + window.textsecure.messaging.syncReadMessages(read, sendOptions) + ); + await this.sendReadReceiptsFor(read); + } + } + + async sendReadReceiptsFor(items: Array): Promise { + // Only send read receipts for accepted conversations + if (window.storage.get('read-receipt-setting') && this.getAccepted()) { + window.log.info(`Sending ${items.length} read receipts`); + const convoSendOptions = this.getSendOptions(); + const receiptsBySender = window._.groupBy(items, 'senderId'); + + await Promise.all( + window._.map(receiptsBySender, async (receipts, senderId) => { + const timestamps = window._.map(receipts, 'timestamp'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const c = window.ConversationController.get(senderId)!; + await this.wrapSend( + window.textsecure.messaging.sendReadReceipts( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + c.get('e164')!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + c.get('uuid')!, + timestamps, + convoSendOptions + ) + ); + }) + ); + } + } + + // This is an expensive operation we use to populate the message request hero row. It + // shows groups the current user has in common with this potential new contact. + async updateSharedGroups(): Promise { + if (!this.isPrivate()) { + return; + } + if (this.isMe()) { + return; + } + + const ourGroups = await window.ConversationController.getAllGroupsInvolvingId( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + window.ConversationController.getOurConversationId()! + ); + const theirGroups = await window.ConversationController.getAllGroupsInvolvingId( + this.id + ); + + const sharedGroups = window._.intersection(ourGroups, theirGroups); + const sharedGroupNames = sharedGroups.map(conversation => + conversation.getTitle() + ); + + this.set({ sharedGroupNames }); + } + + onChangeProfileKey(): void { + if (this.isPrivate()) { + this.getProfiles(); + } + } + + getProfiles(): Promise> { + // request all conversation members' keys + const conversations = (this.getMembers() as unknown) as Array< + ConversationModel + >; + return Promise.all( + window._.map(conversations, conversation => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.getProfile(conversation.get('uuid')!, conversation.get('e164')!); + }) + ); + } + + async getProfile(providedUuid: string, providedE164: string): Promise { + if (!window.textsecure.messaging) { + throw new Error( + 'Conversation.getProfile: window.textsecure.messaging not available' + ); + } + + const id = window.ConversationController.ensureContactIds({ + uuid: providedUuid, + e164: providedE164, + }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const c = window.ConversationController.get(id)!; + const { + generateProfileKeyCredentialRequest, + getClientZkProfileOperations, + handleProfileKeyCredential, + } = Util.zkgroup; + + const clientZkProfileCipher = getClientZkProfileOperations( + window.getServerPublicParams() + ); + + let profile; + + try { + await Promise.all([ + c.deriveAccessKeyIfNeeded(), + c.deriveProfileKeyVersionIfNeeded(), + ]); + + const profileKey = c.get('profileKey'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const uuid = c.get('uuid')!; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const identifier = c.getSendTarget()!; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const profileKeyVersionHex = c.get('profileKeyVersion')!; + const existingProfileKeyCredential = c.get('profileKeyCredential'); + + const weHaveVersion = Boolean(profileKey && uuid && profileKeyVersionHex); + let profileKeyCredentialRequestHex; + let profileCredentialRequestContext; + + if (weHaveVersion && !existingProfileKeyCredential) { + window.log.info('Generating request...'); + ({ + requestHex: profileKeyCredentialRequestHex, + context: profileCredentialRequestContext, + } = generateProfileKeyCredentialRequest( + clientZkProfileCipher, + uuid, + profileKey + )); + } + + const sendMetadata = c.getSendMetadata({ disableMeCheck: true }) || {}; + const getInfo = + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + sendMetadata[c.get('uuid')!] || sendMetadata[c.get('e164')!] || {}; + + if (getInfo.accessKey) { + try { + profile = await window.textsecure.messaging.getProfile(identifier, { + accessKey: getInfo.accessKey, + profileKeyVersion: profileKeyVersionHex, + profileKeyCredentialRequest: profileKeyCredentialRequestHex, + }); + } catch (error) { + if (error.code === 401 || error.code === 403) { + window.log.info( + `Setting sealedSender to DISABLED for conversation ${c.idForLogging()}` + ); + c.set({ sealedSender: SEALED_SENDER.DISABLED }); + profile = await window.textsecure.messaging.getProfile(identifier, { + profileKeyVersion: profileKeyVersionHex, + profileKeyCredentialRequest: profileKeyCredentialRequestHex, + }); + } else { + throw error; + } + } + } else { + profile = await window.textsecure.messaging.getProfile(identifier, { + profileKeyVersion: profileKeyVersionHex, + profileKeyCredentialRequest: profileKeyCredentialRequestHex, + }); + } + + const identityKey = base64ToArrayBuffer(profile.identityKey); + const changed = await window.textsecure.storage.protocol.saveIdentity( + `${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 window.libsignal.SignalProtocolAddress( + identifier, + 1 + ); + window.log.info('closing session for', address.toString()); + const sessionCipher = new window.libsignal.SessionCipher( + window.textsecure.storage.protocol, + address + ); + await sessionCipher.closeOpenSessionForDevice(); + } + + const accessKey = c.get('accessKey'); + if ( + profile.unrestrictedUnidentifiedAccess && + profile.unidentifiedAccess + ) { + window.log.info( + `Setting sealedSender to UNRESTRICTED for conversation ${c.idForLogging()}` + ); + c.set({ + sealedSender: SEALED_SENDER.UNRESTRICTED, + }); + } else if (accessKey && profile.unidentifiedAccess) { + const haveCorrectKey = await verifyAccessKey( + base64ToArrayBuffer(accessKey), + base64ToArrayBuffer(profile.unidentifiedAccess) + ); + + if (haveCorrectKey) { + window.log.info( + `Setting sealedSender to ENABLED for conversation ${c.idForLogging()}` + ); + c.set({ + sealedSender: SEALED_SENDER.ENABLED, + }); + } else { + window.log.info( + `Setting sealedSender to DISABLED for conversation ${c.idForLogging()}` + ); + c.set({ + sealedSender: SEALED_SENDER.DISABLED, + }); + } + } else { + window.log.info( + `Setting sealedSender to DISABLED for conversation ${c.idForLogging()}` + ); + c.set({ + sealedSender: SEALED_SENDER.DISABLED, + }); + } + + if (profile.capabilities) { + c.set({ capabilities: profile.capabilities }); + } + if (profileCredentialRequestContext && profile.credential) { + const profileKeyCredential = handleProfileKeyCredential( + clientZkProfileCipher, + profileCredentialRequestContext, + profile.credential + ); + c.set({ profileKeyCredential }); + } + } catch (error) { + if (error.code !== 403 && error.code !== 404) { + window.log.warn( + 'getProfile failure:', + c.idForLogging(), + error && error.stack ? error.stack : error + ); + } else { + await c.dropProfileKey(); + } + return; + } + + try { + await c.setEncryptedProfileName(profile.name); + } catch (error) { + window.log.warn( + 'getProfile decryption failure:', + c.idForLogging(), + error && error.stack ? error.stack : error + ); + await c.dropProfileKey(); + } + + try { + await c.setProfileAvatar(profile.avatar); + } catch (error) { + if (error.code === 403 || error.code === 404) { + window.log.info( + `Clearing profile avatar for conversation ${c.idForLogging()}` + ); + c.set({ + profileAvatar: null, + }); + } + } + + window.Signal.Data.updateConversation(c.attributes); + } + + async setEncryptedProfileName(encryptedName: string): Promise { + if (!encryptedName) { + return; + } + // isn't this already an ArrayBuffer? + const key = (this.get('profileKey') as unknown) as string; + if (!key) { + return; + } + + // decode + const keyBuffer = base64ToArrayBuffer(key); + + // decrypt + const { given, family } = await window.textsecure.crypto.decryptProfileName( + encryptedName, + keyBuffer + ); + + // encode + const profileName = given ? stringFromBytes(given) : undefined; + const profileFamilyName = family ? stringFromBytes(family) : undefined; + + // set then check for changes + const oldName = this.getProfileName(); + const hadPreviousName = Boolean(oldName); + this.set({ profileName, profileFamilyName }); + + const newName = this.getProfileName(); + + // Note that we compare the combined names to ensure that we don't present the exact + // same before/after string, even if someone is moving from just first name to + // first/last name in their profile data. + const nameChanged = oldName !== newName; + + if (!this.isMe() && hadPreviousName && nameChanged) { + const change = { + type: 'name', + oldName, + newName, + }; + + await this.addProfileChange(change); + } + } + + async setProfileAvatar(avatarPath: string): Promise { + if (!avatarPath) { + return; + } + + if (this.isMe()) { + window.storage.put('avatarUrl', avatarPath); + } + + const avatar = await window.textsecure.messaging.getAvatar(avatarPath); + // isn't this already an ArrayBuffer? + const key = (this.get('profileKey') as unknown) as string; + if (!key) { + return; + } + const keyBuffer = base64ToArrayBuffer(key); + + // decrypt + const decrypted = await window.textsecure.crypto.decryptProfile( + avatar, + keyBuffer + ); + + // update the conversation avatar only if hash differs + if (decrypted) { + const newAttributes = await window.Signal.Types.Conversation.maybeUpdateProfileAvatar( + this.attributes, + decrypted, + { + writeNewAttachmentData, + deleteAttachmentData, + doesAttachmentExist, + } + ); + this.set(newAttributes); + } + } + + async setProfileKey( + profileKey: string, + { viaStorageServiceSync = false } = {} + ): Promise { + // profileKey is a string so we can compare it directly + if (this.get('profileKey') !== profileKey) { + window.log.info( + `Setting sealedSender to UNKNOWN for conversation ${this.idForLogging()}` + ); + this.set({ + profileKey, + profileKeyVersion: undefined, + profileKeyCredential: null, + accessKey: null, + sealedSender: SEALED_SENDER.UNKNOWN, + }); + + if (!viaStorageServiceSync) { + this.captureChange(); + } + + await Promise.all([ + this.deriveAccessKeyIfNeeded(), + this.deriveProfileKeyVersionIfNeeded(), + ]); + + window.Signal.Data.updateConversation(this.attributes, { + Conversation: window.Whisper.Conversation, + }); + } + } + + async dropProfileKey(): Promise { + if (this.get('profileKey')) { + window.log.info( + `Dropping profileKey, setting sealedSender to UNKNOWN for conversation ${this.idForLogging()}` + ); + const profileAvatar = this.get('profileAvatar'); + if (profileAvatar && profileAvatar.path) { + await deleteAttachmentData(profileAvatar.path); + } + + this.set({ + profileKey: undefined, + profileKeyVersion: undefined, + profileKeyCredential: null, + accessKey: null, + profileName: undefined, + profileFamilyName: undefined, + profileAvatar: null, + sealedSender: SEALED_SENDER.UNKNOWN, + }); + + window.Signal.Data.updateConversation(this.attributes); + } + } + + async deriveAccessKeyIfNeeded(): Promise { + // isn't this already an array buffer? + const profileKey = (this.get('profileKey') as unknown) as string; + if (!profileKey) { + return; + } + if (this.get('accessKey')) { + return; + } + + const profileKeyBuffer = base64ToArrayBuffer(profileKey); + const accessKeyBuffer = await deriveAccessKey(profileKeyBuffer); + const accessKey = arrayBufferToBase64(accessKeyBuffer); + this.set({ accessKey }); + } + + async deriveProfileKeyVersionIfNeeded(): Promise { + const profileKey = this.get('profileKey'); + if (!profileKey) { + return; + } + + const uuid = this.get('uuid'); + if (!uuid || this.get('profileKeyVersion')) { + return; + } + + const profileKeyVersion = Util.zkgroup.deriveProfileKeyVersion( + profileKey, + uuid + ); + + this.set({ profileKeyVersion }); + } + + hasMember(identifier: string): boolean { + const id = window.ConversationController.getConversationId(identifier); + const memberIds = this.getMemberIds(); + + return window._.contains(memberIds, id); + } + + fetchContacts(): void { + if (this.isPrivate()) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.contactCollection!.reset([this]); + } + const members = this.getMembers(); + window._.forEach(members, member => { + this.listenTo(member, 'change:verified', this.onMemberVerifiedChange); + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.contactCollection!.reset(members); + } + + async destroyMessages(): Promise { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.messageCollection!.reset([]); + + this.set({ + lastMessage: null, + timestamp: null, + active_at: null, + }); + window.Signal.Data.updateConversation(this.attributes); + + await window.Signal.Data.removeAllMessagesInConversation(this.id, { + MessageCollection: window.Whisper.MessageCollection, + }); + } + + getTitle(): string { + if (this.isPrivate()) { + return ( + this.get('name') || + this.getProfileName() || + this.getNumber() || + window.i18n('unknownContact') + ); + } + return this.get('name') || window.i18n('unknownGroup'); + } + + getProfileName(): string | null { + if (this.isPrivate()) { + return Util.combineNames( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.get('profileName')!, + this.get('profileFamilyName') + ); + } + return null; + } + + getNumber(): string { + if (!this.isPrivate()) { + return ''; + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const number = this.get('e164')!; + try { + const parsedNumber = window.libphonenumber.parse(number); + const regionCode = window.libphonenumber.getRegionCodeForNumber( + parsedNumber + ); + if (regionCode === window.storage.get('regionCode')) { + return window.libphonenumber.format( + parsedNumber, + window.libphonenumber.PhoneNumberFormat.NATIONAL + ); + } + return window.libphonenumber.format( + parsedNumber, + window.libphonenumber.PhoneNumberFormat.INTERNATIONAL + ); + } catch (e) { + return number; + } + } + + getInitials(name: string): string | null { + if (!name) { + return null; + } + + const cleaned = name.replace(/[^A-Za-z\s]+/g, '').replace(/\s+/g, ' '); + const parts = cleaned.split(' '); + const initials = parts.map(part => part.trim()[0]); + if (!initials.length) { + return null; + } + + return initials.slice(0, 2).join(''); + } + + isPrivate(): boolean { + return this.get('type') === 'private'; + } + + getColor(): ColorType { + if (!this.isPrivate()) { + return 'signal-blue'; + } + + const { migrateColor } = Util; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return migrateColor(this.get('color')!); + } + + getAvatarPath(): string | null { + const avatar = this.isMe() + ? this.get('profileAvatar') || this.get('avatar') + : this.get('avatar') || this.get('profileAvatar'); + + if (avatar && avatar.path) { + return getAbsoluteAttachmentPath(avatar.path); + } + + return null; + } + + canChangeTimer(): boolean { + if (this.isPrivate()) { + return true; + } + + if (this.get('groupVersion') !== 2) { + return true; + } + + const accessControlEnum = + window.textsecure.protobuf.AccessControl.AccessRequired; + const accessControl = this.get('accessControl'); + const canAnyoneChangeTimer = + accessControl && + (accessControl.attributes === accessControlEnum.ANY || + accessControl.attributes === accessControlEnum.MEMBER); + if (canAnyoneChangeTimer) { + return true; + } + + const memberEnum = window.textsecure.protobuf.Member.Role; + const members = this.get('membersV2') || []; + const myId = window.ConversationController.getOurConversationId(); + const me = members.find(item => item.conversationId === myId); + if (!me) { + return false; + } + + const isAdministrator = me.role === memberEnum.ADMINISTRATOR; + if (isAdministrator) { + return true; + } + + return false; + } + + // Set of items to captureChanges on: + // [-] uuid + // [-] e164 + // [X] profileKey + // [-] identityKey + // [X] verified! + // [-] profileName + // [-] profileFamilyName + // [X] blocked + // [X] whitelisted + // [X] archived + captureChange(): void { + if (!window.Signal.RemoteConfig.isEnabled('desktop.storageWrite')) { + window.log.info( + 'conversation.captureChange: Returning early; desktop.storageWrite is falsey' + ); + + return; + } + + window.log.info( + `storageService[captureChange] marking ${this.debugID()} as needing sync` + ); + this.set({ needsStorageServiceSync: true }); + + this.queueJob(() => { + Services.storageServiceUploadJob(); + }); + } + + isMuted(): boolean { + return (this.get('muteExpiresAt') && + Date.now() < this.get('muteExpiresAt')) as boolean; + } + + async notify(message: WhatIsThis, reaction?: WhatIsThis): Promise { + if (this.isMuted()) { + return; + } + + if (!message.isIncoming() && !reaction) { + return; + } + + const conversationId = this.id; + + const sender = reaction + ? window.ConversationController.get(reaction.get('fromId')) + : message.getContact(); + const senderName = sender + ? sender.getTitle() + : window.i18n('unknownContact'); + const senderTitle = this.isPrivate() + ? senderName + : window.i18n('notificationSenderInGroup', { + sender: senderName, + group: this.getTitle(), + }); + + let notificationIconUrl; + const avatar = this.get('avatar') || this.get('profileAvatar'); + if (avatar && avatar.path) { + notificationIconUrl = getAbsoluteAttachmentPath(avatar.path); + } else if (this.isPrivate()) { + notificationIconUrl = await new window.Whisper.IdenticonSVGView({ + color: this.getColor(), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + content: this.getInitials(this.get('name')!) || '#', + }).getDataUrl(); + } else { + // Not technically needed, but helps us be explicit: we don't show an icon for a + // group that doesn't have an icon. + notificationIconUrl = undefined; + } + + const messageJSON = message.toJSON(); + const messageId = message.id; + const isExpiringMessage = Message.hasExpiration(messageJSON); + + window.Whisper.Notifications.add({ + senderTitle, + conversationId, + notificationIconUrl, + isExpiringMessage, + message: message.getNotificationText(), + messageId, + reaction: reaction ? reaction.toJSON() : null, + }); + } + + notifyTyping( + options: { + isTyping: boolean; + senderId: string; + isMe: boolean; + senderDevice: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } = ({} as unknown) as any + ): void { + const { isTyping, senderId, isMe, senderDevice } = options; + + // We don't do anything with typing messages from our other devices + if (isMe) { + return; + } + + const typingToken = `${senderId}.${senderDevice}`; + + this.contactTypingTimers = this.contactTypingTimers || {}; + const record = this.contactTypingTimers[typingToken]; + + if (record) { + clearTimeout(record.timer); + } + + if (isTyping) { + this.contactTypingTimers[typingToken] = this.contactTypingTimers[ + typingToken + ] || { + timestamp: Date.now(), + senderId, + senderDevice, + }; + + this.contactTypingTimers[typingToken].timer = setTimeout( + this.clearContactTypingTimer.bind(this, typingToken), + 15 * 1000 + ); + if (!record) { + // User was not previously typing before. State change! + this.trigger('change', this); + } + } else { + delete this.contactTypingTimers[typingToken]; + if (record) { + // User was previously typing, and is no longer. State change! + this.trigger('change', this); + } + } + } + + clearContactTypingTimer(typingToken: string): void { + this.contactTypingTimers = this.contactTypingTimers || {}; + const record = this.contactTypingTimers[typingToken]; + + if (record) { + clearTimeout(record.timer); + delete this.contactTypingTimers[typingToken]; + + // User was previously typing, but timed out or we received message. State change! + this.trigger('change', this); + } + } + + getName(): string | undefined { + // eslint-disable-next-line no-useless-return + return; + } +} + +window.Whisper.Conversation = ConversationModel; + +window.Whisper.ConversationCollection = window.Backbone.Collection.extend({ + model: window.Whisper.Conversation, + + /** + * window.Backbone defines a `_byId` field. Here we set up additional `_byE164`, + * `_byUuid`, and `_byGroupId` fields so we can track conversations by more + * than just their id. + */ + initialize() { + this.eraseLookups(); + this.on( + 'idUpdated', + (model: WhatIsThis, idProp: string, oldValue: WhatIsThis) => { + if (oldValue) { + if (idProp === 'e164') { + delete this._byE164[oldValue]; + } + if (idProp === 'uuid') { + delete this._byUuid[oldValue]; + } + if (idProp === 'groupId') { + delete this._byGroupid[oldValue]; + } + } + if (model.get('e164')) { + this._byE164[model.get('e164')] = model; + } + if (model.get('uuid')) { + this._byUuid[model.get('uuid')] = model; + } + if (model.get('groupId')) { + this._byGroupid[model.get('groupId')] = model; + } + } + ); + }, + + reset(...args: Array) { + window.Backbone.Collection.prototype.reset.apply(this, args as WhatIsThis); + this.resetLookups(); + }, + + resetLookups() { + this.eraseLookups(); + this.generateLookups(this.models); + }, + + generateLookups(models: Array) { + models.forEach(model => { + const e164 = model.get('e164'); + if (e164) { + 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) { + 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'); + if (groupId) { + this._byGroupId[groupId] = model; + } + }); + }, + + eraseLookups() { + this._byE164 = Object.create(null); + this._byUuid = Object.create(null); + this._byGroupId = Object.create(null); + }, + + add(...models: Array) { + const result = window.Backbone.Collection.prototype.add.apply( + this, + models as WhatIsThis + ); + + this.generateLookups(Array.isArray(result) ? result.slice(0) : [result]); + + return result; + }, + + /** + * window.Backbone collections have a `_byId` field that `get` defers to. Here, we + * override `get` to first access our custom `_byE164`, `_byUuid`, and + * `_byGroupId` functions, followed by falling back to the original + * window.Backbone implementation. + */ + get(id: string) { + return ( + this._byE164[id] || + this._byE164[`+${id}`] || + this._byUuid[id] || + this._byGroupId[id] || + window.Backbone.Collection.prototype.get.call(this, id) + ); + }, + + comparator(m: WhatIsThis) { + return -m.get('timestamp'); + }, +}); + +window.Whisper.Conversation.COLORS = COLORS.concat(['grey', 'default']).join( + ' ' +); + +// This is a wrapper model used to display group members in the member list view, within +// the world of backbone, but layering another bit of group-specific data top of base +// conversation data. +window.Whisper.GroupMemberConversation = window.Backbone.Model.extend({ + initialize(attributes: { conversation: boolean; isAdmin: boolean }) { + const { conversation, isAdmin } = attributes; + + if (!conversation) { + throw new Error( + 'GroupMemberConversation.initialze: conversation required!' + ); + } + if (!window._.isBoolean(isAdmin)) { + throw new Error('GroupMemberConversation.initialze: isAdmin required!'); + } + + // If our underlying conversation changes, we change too + this.listenTo(conversation, 'change', () => { + this.trigger('change', this); + }); + + this.conversation = conversation; + this.isAdmin = isAdmin; + }, + + format() { + return { + ...this.conversation.format(), + isAdmin: this.isAdmin, + }; + }, + + get(...params: Array) { + return this.conversation.get(...params); + }, + + getTitle() { + return this.conversation.getTitle(); + }, + + isMe() { + return this.conversation.isMe(); + }, +}); + +// We need a custom collection here to get the sorting we need +window.Whisper.GroupConversationCollection = window.Backbone.Collection.extend({ + model: window.Whisper.GroupMemberConversation, + + initialize() { + this.collator = new Intl.Collator(); + }, + + comparator(left: WhatIsThis, right: WhatIsThis) { + if (left.isAdmin && !right.isAdmin) { + return -1; + } + if (!left.isAdmin && right.isAdmin) { + return 1; + } + + const leftLower = left.getTitle().toLowerCase(); + const rightLower = right.getTitle().toLowerCase(); + return this.collator.compare(leftLower, rightLower); + }, +}); diff --git a/ts/models/messages.ts b/ts/models/messages.ts new file mode 100644 index 000000000..147fd459e --- /dev/null +++ b/ts/models/messages.ts @@ -0,0 +1,3420 @@ +import { + WhatIsThis, + MessageAttributesType, + CustomError, +} from '../model-types.d'; +import { ConversationModel } from './conversations'; +import { + LastMessageStatus, + ConversationType, +} from '../state/ducks/conversations'; +import { CallbackResultType } from '../textsecure/SendMessage'; +import { BodyRangesType } from '../types/Util'; +import { PropsDataType as GroupsV2Props } from '../components/conversation/GroupV2Change'; +import { + PropsData as TimerNotificationProps, + TimerNotificationType, +} from '../components/conversation/TimerNotification'; +import { PropsData as SafetyNumberNotificationProps } from '../components/conversation/SafetyNumberNotification'; +import { PropsData as VerificationNotificationProps } from '../components/conversation/VerificationNotification'; +import { + PropsData as GroupNotificationProps, + ChangeType, +} from '../components/conversation/GroupNotification'; +import { Props as ResetSessionNotificationProps } from '../components/conversation/ResetSessionNotification'; +import { PropsData as CallingNotificationProps } from '../components/conversation/CallingNotification'; +import { PropsType as ProfileChangeNotificationPropsType } from '../components/conversation/ProfileChangeNotification'; + +/* eslint-disable camelcase */ +/* eslint-disable more/no-then */ + +declare const _: typeof window._; + +window.Whisper = window.Whisper || {}; + +const { + Message: TypedMessage, + Attachment, + MIME, + Contact, + PhoneNumber, + Errors, +} = window.Signal.Types; +const { + deleteExternalMessageFiles, + getAbsoluteAttachmentPath, + loadAttachmentData, + loadQuoteData, + loadPreviewData, + loadStickerData, + upgradeMessageSchema, +} = window.Signal.Migrations; +const { + copyStickerToAttachments, + deletePackReference, + savePackMetadata, + getStickerPackStatus, +} = window.Signal.Stickers; +const { getTextWithMentions, GoogleChrome } = window.Signal.Util; + +const { addStickerPackReference, getMessageBySender } = window.Signal.Data; +const { bytesFromString } = window.Signal.Crypto; +const PLACEHOLDER_CONTACT = { + title: window.i18n('unknownContact'), +}; + +window.AccountCache = Object.create(null); +window.AccountJobs = Object.create(null); + +window.doesAccountCheckJobExist = number => Boolean(window.AccountJobs[number]); +window.checkForSignalAccount = number => { + if (window.AccountJobs[number]) { + return window.AccountJobs[number]; + } + + let job; + if (window.textsecure.messaging) { + // eslint-disable-next-line more/no-then + job = window.textsecure.messaging + .getProfile(number) + .then(() => { + window.AccountCache[number] = true; + }) + .catch(() => { + window.AccountCache[number] = false; + }); + } else { + // We're offline! + job = Promise.resolve().then(() => { + window.AccountCache[number] = false; + }); + } + + window.AccountJobs[number] = job; + + return job; +}; + +window.isSignalAccountCheckComplete = number => + window.AccountCache[number] !== undefined; +window.hasSignalAccount = number => window.AccountCache[number]; + +const includesAny = (haystack: Array, ...needles: Array) => + needles.some(needle => haystack.includes(needle)); + +export class MessageModel extends window.Backbone.Model { + static updateTimers: () => void; + + static getLongMessageAttachment: ( + attachment: typeof window.WhatIsThis + ) => typeof window.WhatIsThis; + + static LONG_MESSAGE_CONTENT_TYPE: string; + + CURRENT_PROTOCOL_VERSION?: number; + + INITIAL_PROTOCOL_VERSION?: number; + + OUR_NUMBER?: string; + + OUR_UUID?: string; + + deletedForEveryone?: boolean; + + isSelected?: boolean; + + hasExpired?: boolean; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + quotedMessage: any; + + syncPromise?: Promise; + + initialize(attributes: unknown): void { + if (_.isObject(attributes)) { + this.set( + TypedMessage.initializeSchemaVersion({ + message: attributes, + logger: window.log, + }) + ); + } + + this.CURRENT_PROTOCOL_VERSION = + window.textsecure.protobuf.DataMessage.ProtocolVersion.CURRENT; + this.INITIAL_PROTOCOL_VERSION = + window.textsecure.protobuf.DataMessage.ProtocolVersion.INITIAL; + this.OUR_NUMBER = window.textsecure.storage.user.getNumber(); + this.OUR_UUID = window.textsecure.storage.user.getUuid(); + + this.on('destroy', this.onDestroy); + this.on('change:expirationStartTimestamp', this.setToExpire); + this.on('change:expireTimer', this.setToExpire); + this.on('unload', this.unload); + this.on('expired', this.onExpired); + this.setToExpire(); + + this.on('change', this.notifyRedux); + } + + notifyRedux(): void { + const { messageChanged } = window.reduxActions.conversations; + + if (messageChanged) { + const conversationId = this.get('conversationId'); + // Note: The clone is important for triggering a re-run of selectors + messageChanged(this.id, conversationId, this.getReduxData()); + } + } + + getReduxData(): WhatIsThis { + const contact = this.getPropsForEmbeddedContact(); + + return { + ...this.attributes, + // We need this in the reducer to detect if the message's height has changed + hasSignalAccount: contact ? Boolean(contact.signalAccount) : null, + }; + } + + isNormalBubble(): boolean { + return ( + !this.isCallHistory() && + !this.isEndSession() && + !this.isExpirationTimerUpdate() && + !this.isGroupUpdate() && + !this.isGroupV2Change() && + !this.isKeyChange() && + !this.isMessageHistoryUnsynced() && + !this.isProfileChange() && + !this.isUnsupportedMessage() && + !this.isVerifiedChange() + ); + } + + // Top-level prop generation for the message bubble + getPropsForBubble(): WhatIsThis { + if (this.isUnsupportedMessage()) { + return { + type: 'unsupportedMessage', + data: this.getPropsForUnsupportedMessage(), + }; + } + if (this.isGroupV2Change()) { + return { + type: 'groupV2Change', + data: this.getPropsForGroupV2Change(), + }; + } + if (this.isMessageHistoryUnsynced()) { + return { + type: 'linkNotification', + data: null, + }; + } + if (this.isExpirationTimerUpdate()) { + return { + type: 'timerNotification', + data: this.getPropsForTimerNotification(), + }; + } + if (this.isKeyChange()) { + return { + type: 'safetyNumberNotification', + data: this.getPropsForSafetyNumberNotification(), + }; + } + if (this.isVerifiedChange()) { + return { + type: 'verificationNotification', + data: this.getPropsForVerificationNotification(), + }; + } + if (this.isGroupUpdate()) { + return { + type: 'groupNotification', + data: this.getPropsForGroupNotification(), + }; + } + if (this.isEndSession()) { + return { + type: 'resetSessionNotification', + data: this.getPropsForResetSessionNotification(), + }; + } + if (this.isCallHistory()) { + return { + type: 'callHistory', + data: this.getPropsForCallHistory(), + }; + } + if (this.isProfileChange()) { + return { + type: 'profileChange', + data: this.getPropsForProfileChange(), + }; + } + + return { + type: 'message', + data: this.getPropsForMessage(), + }; + } + + // Other top-level prop-generation + getPropsForSearchResult(): WhatIsThis { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const sourceId = this.getContactId()!; + const from = this.findAndFormatContact(sourceId); + const conversationId = this.get('conversationId'); + const to = this.findAndFormatContact(conversationId); + + return { + from, + to, + + isSelected: this.isSelected, + + id: this.id, + conversationId, + sentAt: this.get('sent_at'), + snippet: this.get('snippet'), + }; + } + + getPropsForMessageDetail(): WhatIsThis { + const newIdentity = window.i18n('newIdentity'); + const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError'; + + const unidentifiedLookup = ( + this.get('unidentifiedDeliveries') || [] + ).reduce((accumulator: Record, identifier: string) => { + accumulator[ + window.ConversationController.getConversationId(identifier) as string + ] = true; + return accumulator; + }, Object.create(null) as Record); + + // We include numbers we didn't successfully send to so we can display errors. + // Older messages don't have the recipients included on the message, so we fall + // back to the conversation's current recipients + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + const conversationIds = this.isIncoming() + ? [this.getContactId()!] + : _.union( + (this.get('sent_to') || []).map( + (id: string) => window.ConversationController.getConversationId(id)! + ), + ( + this.get('recipients') || this.getConversation()!.getRecipients() + ).map( + (id: string) => window.ConversationController.getConversationId(id)! + ) + ); + /* eslint-enable @typescript-eslint/no-non-null-assertion */ + + // This will make the error message for outgoing key errors a bit nicer + const allErrors = (this.get('errors') || []).map(error => { + if (error.name === OUTGOING_KEY_ERROR) { + // eslint-disable-next-line no-param-reassign + error.message = newIdentity; + } + + return error; + }); + + // If an error has a specific number it's associated with, we'll show it next to + // that contact. Otherwise, it will be a standalone entry. + const errors = _.reject(allErrors, error => + Boolean(error.identifier || error.number) + ); + const errorsGroupedById = _.groupBy(allErrors, 'number'); + const finalContacts = (conversationIds || []).map(id => { + const errorsForContact = errorsGroupedById[id]; + const isOutgoingKeyError = Boolean( + _.find(errorsForContact, error => error.name === OUTGOING_KEY_ERROR) + ); + const isUnidentifiedDelivery = + window.storage.get('unidentifiedDeliveryIndicators') && + this.isUnidentifiedDelivery(id, unidentifiedLookup); + + return { + ...this.findAndFormatContact(id), + + status: this.getStatus(id), + errors: errorsForContact, + isOutgoingKeyError, + isUnidentifiedDelivery, + onSendAnyway: () => + this.trigger('force-send', { contactId: id, messageId: this.id }), + onShowSafetyNumber: () => this.trigger('show-identity', id), + }; + }); + + // The prefix created here ensures that contacts with errors are listed + // first; otherwise it's alphabetical + const sortedContacts = _.sortBy( + finalContacts, + contact => `${contact.errors ? '0' : '1'}${contact.title}` + ); + + return { + sentAt: this.get('sent_at'), + receivedAt: this.get('received_at'), + message: { + ...this.getPropsForMessage(), + disableMenu: true, + disableScroll: true, + // To ensure that group avatar doesn't show up + conversationType: 'direct', + downloadNewVersion: () => { + this.trigger('download-new-version'); + }, + deleteMessage: (messageId: string) => { + this.trigger('delete', messageId); + }, + showVisualAttachment: (options: unknown) => { + this.trigger('show-visual-attachment', options); + }, + displayTapToViewMessage: (messageId: string) => { + this.trigger('display-tap-to-view-message', messageId); + }, + openLink: (url: string) => { + this.trigger('navigate-to', url); + }, + reactWith: (emoji: string) => { + this.trigger('react-with', emoji); + }, + }, + errors, + contacts: sortedContacts, + }; + } + + // Bucketing messages + isUnsupportedMessage(): boolean { + const versionAtReceive = this.get('supportedVersionAtReceive'); + const requiredVersion = this.get('requiredProtocolVersion'); + + return ( + _.isNumber(versionAtReceive) && + _.isNumber(requiredVersion) && + versionAtReceive < requiredVersion + ); + } + + isGroupV2Change(): boolean { + return Boolean(this.get('groupV2Change')); + } + + isExpirationTimerUpdate(): boolean { + const flag = + window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE; + // eslint-disable-next-line no-bitwise, @typescript-eslint/no-non-null-assertion + return Boolean(this.get('flags')! & flag); + } + + isKeyChange(): boolean { + return this.get('type') === 'keychange'; + } + + isVerifiedChange(): boolean { + return this.get('type') === 'verified-change'; + } + + isMessageHistoryUnsynced(): boolean { + return this.get('type') === 'message-history-unsynced'; + } + + isGroupUpdate(): boolean { + return !!this.get('group_update'); + } + + isEndSession(): boolean { + const flag = window.textsecure.protobuf.DataMessage.Flags.END_SESSION; + // eslint-disable-next-line no-bitwise, @typescript-eslint/no-non-null-assertion + return !!(this.get('flags')! & flag); + } + + isCallHistory(): boolean { + return this.get('type') === 'call-history'; + } + + isProfileChange(): boolean { + return this.get('type') === 'profile-change'; + } + + // Props for each message type + getPropsForUnsupportedMessage(): WhatIsThis { + const requiredVersion = this.get('requiredProtocolVersion'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const canProcessNow = this.CURRENT_PROTOCOL_VERSION! >= requiredVersion!; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const sourceId = this.getContactId()!; + + return { + canProcessNow, + contact: this.findAndFormatContact(sourceId), + }; + } + + getPropsForGroupV2Change(): GroupsV2Props { + const { protobuf } = window.textsecure; + + const ourConversationId = window.ConversationController.getOurConversationId(); + const change = this.get('groupV2Change'); + + if (ourConversationId === undefined) { + throw new Error('ourConversationId is undefined'); + } + + if (change === undefined) { + throw new Error('change is undefined'); + } + + return { + AccessControlEnum: protobuf.AccessControl.AccessRequired, + RoleEnum: protobuf.Member.Role, + ourConversationId, + change, + }; + } + + getPropsForTimerNotification(): TimerNotificationProps | undefined { + const timerUpdate = this.get('expirationTimerUpdate'); + if (!timerUpdate) { + return undefined; + } + + const { expireTimer, fromSync, source, sourceUuid } = timerUpdate; + const timespan = window.Whisper.ExpirationTimerOptions.getName( + expireTimer || 0 + ); + const disabled = !expireTimer; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const sourceId = window.ConversationController.ensureContactIds({ + e164: source, + uuid: sourceUuid, + })!; + const ourId = window.ConversationController.getOurConversationId(); + const formattedContact = this.findAndFormatContact(sourceId); + + const basicProps = { + ...formattedContact, + type: 'fromOther' as TimerNotificationType, + timespan, + disabled, + }; + + if (fromSync) { + return { + ...basicProps, + type: 'fromSync' as TimerNotificationType, + }; + } + if (sourceId && sourceId === ourId) { + return { + ...basicProps, + type: 'fromMe' as TimerNotificationType, + }; + } + if (!sourceId) { + return { + ...basicProps, + type: 'fromMember' as TimerNotificationType, + }; + } + + return basicProps; + } + + getPropsForSafetyNumberNotification(): SafetyNumberNotificationProps { + const conversation = this.getConversation(); + const isGroup = Boolean(conversation && !conversation.isPrivate()); + const identifier = this.get('key_changed'); + const contact = this.findAndFormatContact(identifier); + + if (contact.id === undefined) { + throw new Error('contact id is undefined'); + } + + return { + isGroup, + contact, + } as SafetyNumberNotificationProps; + } + + getPropsForVerificationNotification(): VerificationNotificationProps { + const type = this.get('verified') ? 'markVerified' : 'markNotVerified'; + const isLocal = this.get('local'); + const identifier = this.get('verifiedChanged'); + + return { + type, + isLocal, + contact: this.findAndFormatContact(identifier), + }; + } + + getPropsForGroupNotification(): GroupNotificationProps { + const groupUpdate = this.get('group_update'); + const changes = []; + + if ( + !groupUpdate.avatarUpdated && + !groupUpdate.left && + !groupUpdate.joined && + !groupUpdate.name + ) { + changes.push({ + type: 'general' as ChangeType, + }); + } + + if (groupUpdate.joined) { + changes.push({ + type: 'add' as ChangeType, + contacts: _.map( + Array.isArray(groupUpdate.joined) + ? groupUpdate.joined + : [groupUpdate.joined], + identifier => this.findAndFormatContact(identifier) + ), + }); + } + + if (groupUpdate.left === 'You') { + changes.push({ + type: 'remove' as ChangeType, + }); + } else if (groupUpdate.left) { + changes.push({ + type: 'remove' as ChangeType, + contacts: _.map( + Array.isArray(groupUpdate.left) + ? groupUpdate.left + : [groupUpdate.left], + identifier => this.findAndFormatContact(identifier) + ), + }); + } + + if (groupUpdate.name) { + changes.push({ + type: 'name' as ChangeType, + newName: groupUpdate.name, + }); + } + + if (groupUpdate.avatarUpdated) { + changes.push({ + type: 'avatar' as ChangeType, + }); + } + + const sourceId = this.getContactId(); + const from = this.findAndFormatContact(sourceId); + + return { + from, + changes, + }; + } + + // eslint-disable-next-line class-methods-use-this + getPropsForResetSessionNotification(): ResetSessionNotificationProps { + return { + i18n: window.i18n, + }; + } + + getPropsForCallHistory(): CallingNotificationProps { + return { + callHistoryDetails: this.get('callHistoryDetails'), + }; + } + + getPropsForProfileChange(): ProfileChangeNotificationPropsType { + const change = this.get('profileChange'); + const changedId = this.get('changedId'); + const changedContact = this.findAndFormatContact(changedId); + + if (!changedContact.id) { + throw new Error('changed contact id is undefined'); + } + + if (!change) { + throw new Error('change is undefined'); + } + + return { + changedContact, + change, + } as ProfileChangeNotificationPropsType; + } + + getAttachmentsForMessage(): Array { + const sticker = this.get('sticker'); + if (sticker && sticker.data) { + const { data } = sticker; + + // We don't show anything if we're still loading a sticker + if (data.pending || !data.path) { + return []; + } + + return [ + { + ...data, + url: getAbsoluteAttachmentPath(data.path), + }, + ]; + } + + const attachments = this.get('attachments') || []; + return attachments + .filter(attachment => !attachment.error) + .map(attachment => this.getPropsForAttachment(attachment)); + } + + getPropsForMessage(): WhatIsThis { + const sourceId = this.getContactId(); + const contact = this.findAndFormatContact(sourceId); + const contactModel = this.findContact(sourceId); + + const authorColor = contactModel ? contactModel.getColor() : null; + const authorAvatarPath = contactModel ? contactModel.getAvatarPath() : null; + + const expirationLength = this.get('expireTimer') * 1000; + const expireTimerStart = this.get('expirationStartTimestamp'); + const expirationTimestamp = + expirationLength && expireTimerStart + ? expireTimerStart + expirationLength + : null; + + const conversation = this.getConversation(); + const isGroup = conversation && !conversation.isPrivate(); + const conversationAccepted = Boolean( + conversation && conversation.getAccepted() + ); + const sticker = this.get('sticker'); + + const isTapToView = this.isTapToView(); + + const reactions = (this.get('reactions') || []).map(re => { + const c = this.findAndFormatContact(re.fromId); + + return { + emoji: re.emoji, + timestamp: re.timestamp, + from: c, + }; + }); + + const selectedReaction = ( + (this.get('reactions') || []).find( + re => re.fromId === window.ConversationController.getOurConversationId() + ) || {} + ).emoji; + + return { + text: this.createNonBreakingLastSeparator(this.get('body')), + textPending: this.get('bodyPending'), + id: this.id, + conversationId: this.get('conversationId'), + conversationAccepted, + isSticker: Boolean(sticker), + direction: this.isIncoming() ? 'incoming' : 'outgoing', + timestamp: this.get('sent_at'), + status: this.getMessagePropStatus(), + contact: this.getPropsForEmbeddedContact(), + canReply: this.canReply(), + authorTitle: contact.title, + authorColor, + authorName: contact.name, + authorProfileName: contact.profileName, + authorPhoneNumber: contact.phoneNumber, + conversationType: isGroup ? 'group' : 'direct', + attachments: this.getAttachmentsForMessage(), + previews: this.getPropsForPreview(), + quote: this.getPropsForQuote(), + authorAvatarPath, + isExpired: this.hasExpired, + expirationLength, + expirationTimestamp, + reactions, + selectedReaction, + + isTapToView, + isTapToViewExpired: isTapToView && this.get('isErased'), + isTapToViewError: + isTapToView && this.isIncoming() && this.get('isTapToViewInvalid'), + + deletedForEveryone: this.get('deletedForEveryone') || false, + bodyRanges: this.processBodyRanges(), + }; + } + + processBodyRanges( + bodyRanges = this.get('bodyRanges') + ): BodyRangesType | undefined { + if (!bodyRanges) { + return undefined; + } + + return bodyRanges + .filter(range => range.mentionUuid) + .map(range => { + const contactID = window.ConversationController.ensureContactIds({ + uuid: range.mentionUuid, + }); + const conversation = this.findContact(contactID); + + return { + ...range, + conversationID: contactID, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + replacementText: conversation!.getTitle(), + }; + }) + .sort((a, b) => b.start - a.start); + } + + // Dependencies of prop-generation functions + findAndFormatContact( + identifier?: string + ): Partial & Pick { + if (!identifier) { + return PLACEHOLDER_CONTACT; + } + + const contactModel = this.findContact(identifier); + if (contactModel) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return contactModel.format()!; + } + + const { format, isValidNumber } = PhoneNumber; + const regionCode = window.storage.get('regionCode'); + + if (!isValidNumber(identifier, { regionCode })) { + return PLACEHOLDER_CONTACT; + } + + const phoneNumber = format(identifier, { + ourRegionCode: regionCode, + }); + + return { + title: phoneNumber, + phoneNumber, + }; + } + + // eslint-disable-next-line class-methods-use-this + findContact(identifier?: string): ConversationModel | undefined { + return window.ConversationController.get(identifier); + } + + getConversation(): ConversationModel | undefined { + return window.ConversationController.get(this.get('conversationId')); + } + + // eslint-disable-next-line class-methods-use-this + createNonBreakingLastSeparator(text: string): string | null { + if (!text) { + return null; + } + + const nbsp = '\xa0'; + const regex = /(\S)( +)(\S+\s*)$/; + return text.replace(regex, (_match, start, spaces, end) => { + const newSpaces = + end.length < 12 + ? _.reduce(spaces, accumulator => accumulator + nbsp, '') + : spaces; + return `${start}${newSpaces}${end}`; + }); + } + + isIncoming(): boolean { + return this.get('type') === 'incoming'; + } + + getMessagePropStatus(): LastMessageStatus | null { + const sent = this.get('sent'); + const sentTo = this.get('sent_to') || []; + + if (this.hasErrors()) { + if (sent || sentTo.length > 0) { + return 'partial-sent'; + } + return 'error'; + } + if (!this.isOutgoing()) { + return null; + } + + const readBy = this.get('read_by') || []; + if (window.storage.get('read-receipt-setting') && readBy.length > 0) { + return 'read'; + } + const delivered = this.get('delivered'); + const deliveredTo = this.get('delivered_to') || []; + if (delivered || deliveredTo.length > 0) { + return 'delivered'; + } + if (sent || sentTo.length > 0) { + return 'sent'; + } + + return 'sending'; + } + + getPropsForEmbeddedContact(): WhatIsThis { + const contacts = this.get('contact'); + if (!contacts || !contacts.length) { + return null; + } + + const regionCode = window.storage.get('regionCode'); + const { contactSelector } = Contact; + const contact = contacts[0]; + const firstNumber = + contact.number && contact.number[0] && contact.number[0].value; + + // Would be nice to do this before render, on initial load of message + if (!window.isSignalAccountCheckComplete(firstNumber)) { + window.checkForSignalAccount(firstNumber).then(() => { + this.trigger('change', this); + }); + } + + return contactSelector(contact, { + regionCode, + getAbsoluteAttachmentPath, + signalAccount: window.hasSignalAccount(firstNumber) ? firstNumber : null, + }); + } + + // eslint-disable-next-line class-methods-use-this + getPropsForAttachment(attachment: typeof Attachment): WhatIsThis { + if (!attachment) { + return null; + } + + const { path, pending, flags, size, screenshot, thumbnail } = attachment; + + return { + ...attachment, + fileSize: size ? window.filesize(size) : null, + isVoiceMessage: + flags && + // eslint-disable-next-line no-bitwise + flags & + window.textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE, + pending, + url: path ? getAbsoluteAttachmentPath(path) : null, + screenshot: screenshot + ? { + ...screenshot, + url: getAbsoluteAttachmentPath(screenshot.path), + } + : null, + thumbnail: thumbnail + ? { + ...thumbnail, + url: getAbsoluteAttachmentPath(thumbnail.path), + } + : null, + }; + } + + getPropsForPreview(): WhatIsThis { + const previews = this.get('preview') || []; + + return previews.map(preview => ({ + ...preview, + isStickerPack: window.Signal.LinkPreviews.isStickerPack(preview.url), + domain: window.Signal.LinkPreviews.getDomain(preview.url), + image: preview.image ? this.getPropsForAttachment(preview.image) : null, + })); + } + + getPropsForQuote(): WhatIsThis { + const quote = this.get('quote'); + if (!quote) { + return null; + } + + const { format } = PhoneNumber; + const regionCode = window.storage.get('regionCode'); + + const { + author, + authorUuid, + bodyRanges, + id: sentAt, + referencedMessageNotFound, + text, + } = quote; + + const contact = + (author || authorUuid) && + window.ConversationController.get( + window.ConversationController.ensureContactIds({ + e164: author, + uuid: authorUuid, + }) + ); + const authorColor = contact ? contact.getColor() : 'grey'; + + const authorPhoneNumber = format(author, { + ourRegionCode: regionCode, + }); + const authorProfileName = contact ? contact.getProfileName() : null; + const authorName = contact ? contact.get('name') : null; + const authorTitle = contact ? contact.getTitle() : null; + const isFromMe = contact ? contact.isMe() : false; + const firstAttachment = quote.attachments && quote.attachments[0]; + + return { + text: this.createNonBreakingLastSeparator(text), + attachment: firstAttachment + ? this.processQuoteAttachment(firstAttachment) + : null, + bodyRanges: this.processBodyRanges(bodyRanges), + isFromMe, + sentAt, + authorId: author, + authorPhoneNumber, + authorProfileName, + authorTitle, + authorName, + authorColor, + referencedMessageNotFound, + onClick: () => this.trigger('scroll-to-message'), + }; + } + + getStatus(identifier: string): string | null { + const conversation = window.ConversationController.get(identifier); + + if (!conversation) { + return null; + } + + const e164 = conversation.get('e164'); + const uuid = conversation.get('uuid'); + const conversationId = conversation.get('id'); + + const readBy = this.get('read_by') || []; + if (includesAny(readBy, conversationId, e164, uuid)) { + return 'read'; + } + const deliveredTo = this.get('delivered_to') || []; + if (includesAny(deliveredTo, conversationId, e164, uuid)) { + return 'delivered'; + } + const sentTo = this.get('sent_to') || []; + if (includesAny(sentTo, conversationId, e164, uuid)) { + return 'sent'; + } + + return null; + } + + // eslint-disable-next-line class-methods-use-this + processQuoteAttachment( + attachment: typeof window.Signal.Types.Attachment + ): WhatIsThis { + const { thumbnail } = attachment; + const path = + thumbnail && thumbnail.path && getAbsoluteAttachmentPath(thumbnail.path); + const objectUrl = thumbnail && thumbnail.objectUrl; + + const thumbnailWithObjectUrl = + !path && !objectUrl + ? null + : { ...(attachment.thumbnail || {}), objectUrl: path || objectUrl }; + + return { + ...attachment, + isVoiceMessage: window.Signal.Types.Attachment.isVoiceMessage(attachment), + thumbnail: thumbnailWithObjectUrl, + }; + } + + getNotificationData(): { emoji?: string; text: string } { + if (this.isUnsupportedMessage()) { + return { + text: window.i18n('message--getDescription--unsupported-message'), + }; + } + + if (this.isProfileChange()) { + const change = this.get('profileChange'); + const changedId = this.get('changedId'); + const changedContact = this.findAndFormatContact(changedId); + + return { + text: window.Signal.Util.getStringForProfileChange( + change, + changedContact, + window.i18n + ), + }; + } + + if (this.isGroupV2Change()) { + const { protobuf } = window.textsecure; + const change = this.get('groupV2Change'); + + const lines = window.Signal.GroupChange.renderChange(change, { + AccessControlEnum: protobuf.AccessControl.AccessRequired, + i18n: window.i18n, + ourConversationId: window.ConversationController.getOurConversationId(), + renderContact: (conversationId: string) => { + const conversation = window.ConversationController.get( + conversationId + ); + return conversation + ? conversation.getTitle() + : window.i18n('unknownUser'); + }, + renderString: ( + key: string, + _i18n: unknown, + placeholders: Array + ) => window.i18n(key, placeholders), + RoleEnum: protobuf.Member.Role, + }); + + return { text: lines.join(' ') }; + } + + const attachments = this.get('attachments') || []; + + if (this.isTapToView()) { + if (this.isErased()) { + return { + text: window.i18n('message--getDescription--disappearing-media'), + }; + } + + if (Attachment.isImage(attachments)) { + return { + text: window.i18n('message--getDescription--disappearing-photo'), + emoji: '📷', + }; + } + if (Attachment.isVideo(attachments)) { + return { + text: window.i18n('message--getDescription--disappearing-video'), + emoji: '🎥', + }; + } + // There should be an image or video attachment, but we have a fallback just in + // case. + return { text: window.i18n('mediaMessage'), emoji: '📎' }; + } + + if (this.isGroupUpdate()) { + const groupUpdate = this.get('group_update'); + const fromContact = this.getContact(); + const messages = []; + + if (groupUpdate.left === 'You') { + return { text: window.i18n('youLeftTheGroup') }; + } + if (groupUpdate.left) { + return { + text: window.i18n('leftTheGroup', [ + this.getNameForNumber(groupUpdate.left), + ]), + }; + } + + if (!fromContact) { + return { text: '' }; + } + + if (fromContact.isMe()) { + messages.push(window.i18n('youUpdatedTheGroup')); + } else { + messages.push(window.i18n('updatedTheGroup', [fromContact.getTitle()])); + } + + if (groupUpdate.joined && groupUpdate.joined.length) { + const joinedContacts = _.map(groupUpdate.joined, item => + window.ConversationController.getOrCreate(item, 'private') + ); + const joinedWithoutMe = joinedContacts.filter( + contact => !contact.isMe() + ); + + if (joinedContacts.length > 1) { + messages.push( + window.i18n('multipleJoinedTheGroup', [ + _.map(joinedWithoutMe, contact => contact.getTitle()).join(', '), + ]) + ); + + if (joinedWithoutMe.length < joinedContacts.length) { + messages.push(window.i18n('youJoinedTheGroup')); + } + } else { + const joinedContact = window.ConversationController.getOrCreate( + groupUpdate.joined[0], + 'private' + ); + if (joinedContact.isMe()) { + messages.push(window.i18n('youJoinedTheGroup')); + } else { + messages.push( + window.i18n('joinedTheGroup', [joinedContacts[0].getTitle()]) + ); + } + } + } + + if (groupUpdate.name) { + messages.push(window.i18n('titleIsNow', [groupUpdate.name])); + } + if (groupUpdate.avatarUpdated) { + messages.push(window.i18n('updatedGroupAvatar')); + } + + return { text: messages.join(' ') }; + } + if (this.isEndSession()) { + return { text: window.i18n('sessionEnded') }; + } + if (this.isIncoming() && this.hasErrors()) { + return { text: window.i18n('incomingError') }; + } + + const body = (this.get('body') || '').trim(); + + if (attachments.length) { + // This should never happen but we want to be extra-careful. + const attachment = attachments[0] || {}; + const { contentType } = attachment; + + if (contentType === MIME.IMAGE_GIF) { + return { + text: body || window.i18n('message--getNotificationText--gif'), + emoji: '🎡', + }; + } + if (Attachment.isImage(attachments)) { + return { + text: body || window.i18n('message--getNotificationText--photo'), + emoji: '📷', + }; + } + if (Attachment.isVideo(attachments)) { + return { + text: body || window.i18n('message--getNotificationText--video'), + emoji: '🎥', + }; + } + if (Attachment.isVoiceMessage(attachment)) { + return { + text: + body || window.i18n('message--getNotificationText--voice-message'), + emoji: '🎤', + }; + } + if (Attachment.isAudio(attachments)) { + return { + text: + body || window.i18n('message--getNotificationText--audio-message'), + emoji: '🔈', + }; + } + return { + text: body || window.i18n('message--getNotificationText--file'), + emoji: '📎', + }; + } + + const stickerData = this.get('sticker'); + if (stickerData) { + const sticker = window.Signal.Stickers.getSticker( + stickerData.packId, + stickerData.stickerId + ); + const { emoji } = sticker || {}; + if (!emoji) { + window.log.warn('Unable to get emoji for sticker'); + } + return { + text: window.i18n('message--getNotificationText--stickers'), + emoji, + }; + } + + if (this.isCallHistory()) { + return { + text: window.Signal.Components.getCallingNotificationText( + this.get('callHistoryDetails'), + window.i18n + ), + }; + } + if (this.isExpirationTimerUpdate()) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const { expireTimer } = this.get('expirationTimerUpdate')!; + if (!expireTimer) { + return { text: window.i18n('disappearingMessagesDisabled') }; + } + + return { + text: window.i18n('timerSetTo', [ + window.Whisper.ExpirationTimerOptions.getAbbreviated( + expireTimer || 0 + ), + ]), + }; + } + + if (this.isKeyChange()) { + const identifier = this.get('key_changed'); + const conversation = this.findContact(identifier); + return { + text: window.i18n('safetyNumberChangedGroup', [ + conversation ? conversation.getTitle() : null, + ]), + }; + } + const contacts = this.get('contact'); + if (contacts && contacts.length) { + return { text: Contact.getName(contacts[0]), emoji: '👤' }; + } + + if (body) { + return { text: body }; + } + + return { text: '' }; + } + + getNotificationText(): string { + const { text, emoji } = this.getNotificationData(); + + let modifiedText = text; + + const hasMentions = Boolean(this.get('bodyRanges')); + + if (hasMentions) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const bodyRanges = this.processBodyRanges()!; + modifiedText = getTextWithMentions(bodyRanges, modifiedText); + } + + // Linux emoji support is mixed, so we disable it. (Note that this doesn't touch + // the `text`, which can contain emoji.) + const shouldIncludeEmoji = Boolean(emoji) && !window.Signal.OS.isLinux(); + if (shouldIncludeEmoji) { + return window.i18n('message--getNotificationText--text-with-emoji', { + text: modifiedText, + emoji, + }); + } + return modifiedText; + } + + // General + idForLogging(): string { + const source = this.getSource(); + const device = this.getSourceDevice(); + const timestamp = this.get('sent_at'); + + return `${source}.${device} ${timestamp}`; + } + + // eslint-disable-next-line class-methods-use-this + defaults(): Partial { + return { + timestamp: new Date().getTime(), + attachments: [], + }; + } + + // eslint-disable-next-line class-methods-use-this + validate(attributes: Record): void { + const required = ['conversationId', 'received_at', 'sent_at']; + const missing = _.filter(required, attr => !attributes[attr]); + if (missing.length) { + window.log.warn(`Message missing attributes: ${missing}`); + } + } + + isUnread(): boolean { + return !!this.get('unread'); + } + + merge(model: MessageModel): void { + const attributes = model.attributes || model; + this.set(attributes); + } + + // eslint-disable-next-line class-methods-use-this + getNameForNumber(number: string): string { + const conversation = window.ConversationController.get(number); + if (!conversation) { + return number; + } + return conversation.getTitle(); + } + + onDestroy(): void { + this.cleanup(); + } + + async cleanup(): Promise { + const { messageDeleted } = window.reduxActions.conversations; + messageDeleted(this.id, this.get('conversationId')); + window.MessageController.unregister(this.id); + this.unload(); + await this.deleteData(); + } + + async deleteData(): Promise { + await deleteExternalMessageFiles(this.attributes); + + const sticker = this.get('sticker'); + if (!sticker) { + return; + } + + const { packId } = sticker; + if (packId) { + await deletePackReference(this.id, packId); + } + } + + isTapToView(): boolean { + return Boolean(this.get('isViewOnce') || this.get('messageTimer')); + } + + isValidTapToView(): boolean { + const body = this.get('body'); + if (body) { + return false; + } + + const attachments = this.get('attachments'); + if (!attachments || attachments.length !== 1) { + return false; + } + + const firstAttachment = attachments[0]; + if ( + !window.Signal.Util.GoogleChrome.isImageTypeSupported( + firstAttachment.contentType + ) && + !window.Signal.Util.GoogleChrome.isVideoTypeSupported( + firstAttachment.contentType + ) + ) { + return false; + } + + const quote = this.get('quote'); + const sticker = this.get('sticker'); + const contact = this.get('contact'); + const preview = this.get('preview'); + + if ( + quote || + sticker || + (contact && contact.length > 0) || + (preview && preview.length > 0) + ) { + return false; + } + + return true; + } + + async markViewed(options?: { fromSync?: boolean }): Promise { + const { fromSync } = options || {}; + + if (!this.isValidTapToView()) { + window.log.warn( + `markViewed: Message ${this.idForLogging()} is not a valid tap to view message!` + ); + return; + } + if (this.isErased()) { + window.log.warn( + `markViewed: Message ${this.idForLogging()} is already erased!` + ); + return; + } + + if (this.get('unread')) { + await this.markRead(); + } + + await this.eraseContents(); + + if (!fromSync) { + const sender = this.getSource(); + + if (sender === undefined) { + throw new Error('sender is undefined'); + } + + const senderUuid = this.getSourceUuid(); + + if (senderUuid === undefined) { + throw new Error('senderUuid is undefined'); + } + + const timestamp = this.get('sent_at'); + const ourNumber = window.textsecure.storage.user.getNumber(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const ourUuid = window.textsecure.storage.user.getUuid()!; + const { + wrap, + sendOptions, + } = window.ConversationController.prepareForSend(ourNumber || ourUuid, { + syncMessage: true, + }); + + await wrap( + window.textsecure.messaging.syncViewOnceOpen( + sender, + senderUuid, + timestamp, + sendOptions + ) + ); + } + } + + isErased(): boolean { + return Boolean(this.get('isErased')); + } + + async eraseContents( + additionalProperties = {}, + shouldPersist = true + ): Promise { + if (this.get('isErased')) { + return; + } + + window.log.info(`Erasing data for message ${this.idForLogging()}`); + + try { + await this.deleteData(); + } catch (error) { + window.log.error( + `Error erasing data for message ${this.idForLogging()}:`, + error && error.stack ? error.stack : error + ); + } + + this.set({ + isErased: true, + body: '', + attachments: [], + quote: null, + contact: [], + sticker: null, + preview: [], + ...additionalProperties, + }); + this.trigger('content-changed'); + + if (shouldPersist) { + await window.Signal.Data.saveMessage(this.attributes, { + Message: window.Whisper.Message, + }); + } + } + + isEmpty(): boolean { + // Core message types - we check for all four because they can each stand alone + const hasBody = Boolean(this.get('body')); + const hasAttachment = (this.get('attachments') || []).length > 0; + const hasEmbeddedContact = (this.get('contact') || []).length > 0; + const isSticker = Boolean(this.get('sticker')); + + // Rendered sync messages + const isCallHistory = this.isCallHistory(); + const isGroupUpdate = this.isGroupUpdate(); + const isGroupV2Change = this.isGroupV2Change(); + const isEndSession = this.isEndSession(); + const isExpirationTimerUpdate = this.isExpirationTimerUpdate(); + const isVerifiedChange = this.isVerifiedChange(); + + // Placeholder messages + const isUnsupportedMessage = this.isUnsupportedMessage(); + const isTapToView = this.isTapToView(); + + // Errors + const hasErrors = this.hasErrors(); + + // Locally-generated notifications + const isKeyChange = this.isKeyChange(); + const isMessageHistoryUnsynced = this.isMessageHistoryUnsynced(); + const isProfileChange = this.isProfileChange(); + + // Note: not all of these message types go through message.handleDataMessage + + const hasSomethingToDisplay = + // Core message types + hasBody || + hasAttachment || + hasEmbeddedContact || + isSticker || + // Rendered sync messages + isCallHistory || + isGroupUpdate || + isGroupV2Change || + isEndSession || + isExpirationTimerUpdate || + isVerifiedChange || + // Placeholder messages + isUnsupportedMessage || + isTapToView || + // Errors + hasErrors || + // Locally-generated notifications + isKeyChange || + isMessageHistoryUnsynced || + isProfileChange; + + return !hasSomethingToDisplay; + } + + unload(): void { + if (this.quotedMessage) { + this.quotedMessage = null; + } + } + + onExpired(): void { + this.hasExpired = true; + } + + isUnidentifiedDelivery( + contactId: string, + lookup: Record + ): boolean { + if (this.isIncoming()) { + return this.get('unidentifiedDeliveryReceived'); + } + + return Boolean(lookup[contactId]); + } + + getSource(): string | undefined { + if (this.isIncoming()) { + return this.get('source'); + } + + return this.OUR_NUMBER; + } + + getSourceDevice(): string | number | undefined { + if (this.isIncoming()) { + return this.get('sourceDevice'); + } + + return window.textsecure.storage.user.getDeviceId(); + } + + getSourceUuid(): string | undefined { + if (this.isIncoming()) { + return this.get('sourceUuid'); + } + + return this.OUR_UUID; + } + + getContactId(): string | undefined { + const source = this.getSource(); + const sourceUuid = this.getSourceUuid(); + + if (!source && !sourceUuid) { + return window.ConversationController.getOurConversationId(); + } + + return window.ConversationController.ensureContactIds({ + e164: source, + uuid: sourceUuid, + }); + } + + getContact(): ConversationModel | undefined { + const id = this.getContactId(); + return window.ConversationController.get(id); + } + + isOutgoing(): boolean { + return this.get('type') === 'outgoing'; + } + + hasErrors(): boolean { + return _.size(this.get('errors')) > 0; + } + + async saveErrors( + providedErrors: Error | Array, + options: { skipSave?: boolean } = {} + ): Promise { + const { skipSave } = options; + + let errors: Array; + + if (!(providedErrors instanceof Array)) { + errors = [providedErrors]; + } else { + errors = providedErrors; + } + + errors.forEach(e => { + window.log.error( + 'Message.saveErrors:', + e && e.reason ? e.reason : null, + e && e.stack ? e.stack : e + ); + }); + errors = errors.map(e => { + if ( + e.constructor === Error || + e.constructor === TypeError || + e.constructor === ReferenceError + ) { + return _.pick( + e, + 'name', + 'message', + 'code', + 'number', + 'reason' + ) as Required; + } + return e; + }); + errors = errors.concat(this.get('errors') || []); + + this.set({ errors }); + + if (!skipSave) { + await window.Signal.Data.saveMessage(this.attributes, { + Message: window.Whisper.Message, + }); + } + } + + async markRead( + readAt?: number, + options: { skipSave?: boolean } = {} + ): Promise { + const { skipSave } = options; + + this.unset('unread'); + + if (this.get('expireTimer') && !this.get('expirationStartTimestamp')) { + const expirationStartTimestamp = Math.min( + Date.now(), + readAt || Date.now() + ); + this.set({ expirationStartTimestamp }); + } + + window.Whisper.Notifications.removeBy({ messageId: this.id }); + + if (!skipSave) { + await window.Signal.Data.saveMessage(this.attributes, { + Message: window.Whisper.Message, + }); + } + } + + isExpiring(): number | null { + return this.get('expireTimer') && this.get('expirationStartTimestamp'); + } + + isExpired(): boolean { + return this.msTilExpire() <= 0; + } + + msTilExpire(): number { + if (!this.isExpiring()) { + return Infinity; + } + const now = Date.now(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const start = this.get('expirationStartTimestamp')!; + const delta = this.get('expireTimer') * 1000; + let msFromNow = start + delta - now; + if (msFromNow < 0) { + msFromNow = 0; + } + return msFromNow; + } + + async setToExpire( + force = false, + options: { skipSave?: boolean } = {} + ): Promise { + const { skipSave } = options || {}; + + if (this.isExpiring() && (force || !this.get('expires_at'))) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const start = this.get('expirationStartTimestamp')!; + const delta = this.get('expireTimer') * 1000; + const expiresAt = start + delta; + + this.set({ expires_at: expiresAt }); + const id = this.get('id'); + if (id && !skipSave) { + await window.Signal.Data.saveMessage(this.attributes, { + Message: window.Whisper.Message, + }); + } + + window.log.info('Set message expiration', { + expiresAt, + sentAt: this.get('sent_at'), + }); + } + } + + getIncomingContact(): ConversationModel | undefined | null { + if (!this.isIncoming()) { + return null; + } + const source = this.get('source'); + if (!source) { + return null; + } + + return window.ConversationController.getOrCreate(source, 'private'); + } + + getQuoteContact(): ConversationModel | undefined | null { + const quote = this.get('quote'); + if (!quote) { + return null; + } + const { author } = quote; + if (!author) { + return null; + } + + return window.ConversationController.get(author); + } + + // Send infrastructure + // One caller today: event handler for the 'Retry Send' entry in triple-dot menu + async retrySend(): Promise> { + if (!window.textsecure.messaging) { + window.log.error('retrySend: Cannot retry since we are offline!'); + return null; + } + + this.set({ errors: null }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const conversation = this.getConversation()!; + const exists = (v: string | null): v is string => Boolean(v); + const intendedRecipients = (this.get('recipients') || []) + .map(identifier => + window.ConversationController.getConversationId(identifier) + ) + .filter(exists); + const successfulRecipients = (this.get('sent_to') || []) + .map(identifier => + window.ConversationController.getConversationId(identifier) + ) + .filter(exists); + const currentRecipients = conversation + .getRecipients() + .map(identifier => + window.ConversationController.getConversationId(identifier) + ) + .filter(exists); + + const profileKey = conversation.get('profileSharing') + ? window.storage.get('profileKey') + : null; + + // Determine retry recipients and get their most up-to-date addressing information + let recipients = _.intersection(intendedRecipients, currentRecipients); + recipients = _.without(recipients, ...successfulRecipients) + .map(id => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const c = window.ConversationController.get(id)!; + return c.getSendTarget(); + }) + .filter((recipient): recipient is string => recipient !== undefined); + + if (!recipients.length) { + window.log.warn('retrySend: Nobody to send to!'); + + return window.Signal.Data.saveMessage(this.attributes, { + Message: window.Whisper.Message, + }); + } + + const attachmentsWithData = await Promise.all( + (this.get('attachments') || []).map(loadAttachmentData) + ); + const { + body, + attachments, + } = window.Whisper.Message.getLongMessageAttachment({ + body: this.get('body'), + attachments: attachmentsWithData, + now: this.get('sent_at'), + }); + + const quoteWithData = await loadQuoteData(this.get('quote')); + const previewWithData = await loadPreviewData(this.get('preview')); + const stickerWithData = await loadStickerData(this.get('sticker')); + + // Special-case the self-send case - we send only a sync message + if ( + recipients.length === 1 && + (recipients[0] === this.OUR_NUMBER || recipients[0] === this.OUR_UUID) + ) { + const [identifier] = recipients; + const dataMessage = await window.textsecure.messaging.getMessageProto( + identifier, + body, + attachments, + quoteWithData, + previewWithData, + stickerWithData, + null, + this.get('sent_at'), + this.get('expireTimer'), + profileKey + ); + return this.sendSyncMessageOnly(dataMessage); + } + + let promise; + const options = conversation.getSendOptions(); + + if (conversation.isPrivate()) { + const [identifier] = recipients; + promise = window.textsecure.messaging.sendMessageToIdentifier( + identifier, + body, + attachments, + quoteWithData, + previewWithData, + stickerWithData, + null, + this.get('sent_at'), + this.get('expireTimer'), + profileKey, + options + ); + } else { + // Because this is a partial group send, we manually construct the request like + // sendMessageToGroup does. + + const groupV2 = conversation.getGroupV2Info(); + + promise = window.textsecure.messaging.sendMessage( + { + recipients, + body, + timestamp: this.get('sent_at'), + attachments, + quote: quoteWithData, + preview: previewWithData, + sticker: stickerWithData, + expireTimer: this.get('expireTimer'), + profileKey, + groupV2, + group: groupV2 + ? undefined + : { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + id: this.getConversation()!.get('groupId')!, + type: window.textsecure.protobuf.GroupContext.Type.DELIVER, + }, + }, + options + ); + } + + return this.send(conversation.wrapSend(promise)); + } + + // eslint-disable-next-line class-methods-use-this + isReplayableError(e: Error): boolean { + return ( + e.name === 'MessageError' || + e.name === 'OutgoingMessageError' || + e.name === 'SendMessageNetworkError' || + e.name === 'SignedPreKeyRotationError' || + e.name === 'OutgoingIdentityKeyError' + ); + } + + canReply(): boolean { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const isAccepted = this.getConversation()!.getAccepted(); + const errors = this.get('errors'); + const isOutgoing = this.get('type') === 'outgoing'; + const numDelivered = this.get('delivered'); + + // Case 1: We cannot reply if we have accepted the message request + if (!isAccepted) { + return false; + } + + // Case 2: We cannot reply if this message is deleted for everyone + if (this.get('deletedForEveryone')) { + return false; + } + + // Case 3: We can reply if this is outgoing and delievered to at least one recipient + if (isOutgoing && numDelivered > 0) { + return true; + } + + // Case 4: We can reply if there are no errors + if (!errors || (errors && errors.length === 0)) { + return true; + } + + // Case 5: default + return false; + } + + // Called when the user ran into an error with a specific user, wants to send to them + // One caller today: ConversationView.forceSend() + async resend(identifier: string): Promise> { + const error = this.removeOutgoingErrors(identifier); + if (!error) { + window.log.warn('resend: requested number was not present in errors'); + return null; + } + + const profileKey = undefined; + const attachmentsWithData = await Promise.all( + (this.get('attachments') || []).map(loadAttachmentData) + ); + const { + body, + attachments, + } = window.Whisper.Message.getLongMessageAttachment({ + body: this.get('body'), + attachments: attachmentsWithData, + now: this.get('sent_at'), + }); + + const quoteWithData = await loadQuoteData(this.get('quote')); + const previewWithData = await loadPreviewData(this.get('preview')); + const stickerWithData = await loadStickerData(this.get('sticker')); + + // Special-case the self-send case - we send only a sync message + if (identifier === this.OUR_NUMBER || identifier === this.OUR_UUID) { + const dataMessage = await window.textsecure.messaging.getMessageProto( + identifier, + body, + attachments, + quoteWithData, + previewWithData, + stickerWithData, + null, + this.get('sent_at'), + this.get('expireTimer'), + profileKey + ); + return this.sendSyncMessageOnly(dataMessage); + } + + const { wrap, sendOptions } = window.ConversationController.prepareForSend( + identifier + ); + const promise = window.textsecure.messaging.sendMessageToIdentifier( + identifier, + body, + attachments, + quoteWithData, + previewWithData, + stickerWithData, + null, + this.get('sent_at'), + this.get('expireTimer'), + profileKey, + sendOptions + ); + + return this.send(wrap(promise)); + } + + removeOutgoingErrors(incomingIdentifier: string): CustomError { + const incomingConversationId = window.ConversationController.getConversationId( + incomingIdentifier + ); + const errors = _.partition( + this.get('errors'), + e => + window.ConversationController.getConversationId( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + e.identifier || e.number! + ) === incomingConversationId && + (e.name === 'MessageError' || + e.name === 'OutgoingMessageError' || + e.name === 'SendMessageNetworkError' || + e.name === 'SignedPreKeyRotationError' || + e.name === 'OutgoingIdentityKeyError') + ); + this.set({ errors: errors[1] }); + return errors[0][0]; + } + + async send( + promise: Promise + ): Promise> { + this.trigger('pending'); + return (promise as Promise) + .then(async result => { + this.trigger('done'); + + // This is used by sendSyncMessage, then set to null + if (result.dataMessage) { + this.set({ dataMessage: result.dataMessage }); + } + + const sentTo = this.get('sent_to') || []; + this.set({ + sent_to: _.union(sentTo, result.successfulIdentifiers), + sent: true, + expirationStartTimestamp: Date.now(), + unidentifiedDeliveries: result.unidentifiedDeliveries, + }); + + await window.Signal.Data.saveMessage(this.attributes, { + Message: window.Whisper.Message, + }); + + this.trigger('sent', this); + this.sendSyncMessage(); + }) + .catch(result => { + this.trigger('done'); + + if (result.dataMessage) { + this.set({ dataMessage: result.dataMessage }); + } + + let promises = []; + + // If we successfully sent to a user, we can remove our unregistered flag. + result.successfulIdentifiers.forEach((identifier: string) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const c = window.ConversationController.get(identifier)!; + if (c && c.isEverUnregistered()) { + c.setRegistered(); + } + }); + + const isError = (e: unknown): e is CustomError => e instanceof Error; + + if (isError(result)) { + this.saveErrors(result); + if (result.name === 'SignedPreKeyRotationError') { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + promises.push(window.getAccountManager()!.rotateSignedPreKey()); + } else if (result.name === 'OutgoingIdentityKeyError') { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const c = window.ConversationController.get(result.number)!; + promises.push(c.getProfiles()); + } + } else { + if (result.successfulIdentifiers.length > 0) { + const sentTo = this.get('sent_to') || []; + + // If we just found out that we couldn't send to a user because they are no + // longer registered, we will update our unregistered flag. In groups we + // will not event try to send to them for 6 hours. And we will never try + // to fetch them on startup again. + // The way to discover registration once more is: + // 1) any attempt to send to them in 1:1 conversation + // 2) the six-hour time period has passed and we send in a group again + const unregisteredUserErrors = _.filter( + result.errors, + error => error.name === 'UnregisteredUserError' + ); + unregisteredUserErrors.forEach(error => { + const c = window.ConversationController.get(error.identifier); + if (c) { + c.setUnregistered(); + } + }); + + // In groups, we don't treat unregistered users as a user-visible + // error. The message will look successful, but the details + // screen will show that we didn't send to these unregistered users. + const filteredErrors = _.reject( + result.errors, + error => error.name === 'UnregisteredUserError' + ); + + // We don't start the expiration timer if there are real errors + // left after filtering out all of the unregistered user errors. + const expirationStartTimestamp = filteredErrors.length + ? null + : Date.now(); + + this.saveErrors(filteredErrors); + + this.set({ + sent_to: _.union(sentTo, result.successfulIdentifiers), + sent: true, + expirationStartTimestamp, + unidentifiedDeliveries: result.unidentifiedDeliveries, + }); + promises.push(this.sendSyncMessage()); + } else { + this.saveErrors(result.errors); + } + promises = promises.concat( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + _.map(result.errors, error => { + if (error.name === 'OutgoingIdentityKeyError') { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const c = window.ConversationController.get( + error.identifier || error.number + )!; + promises.push(c.getProfiles()); + } + }) + ); + } + + this.trigger('send-error', this.get('errors')); + + return Promise.all(promises); + }); + } + + async sendSyncMessageOnly(dataMessage: ArrayBuffer): Promise { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const conv = this.getConversation()!; + this.set({ dataMessage }); + + try { + this.set({ + // These are the same as a normal send() + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + sent_to: [conv.getSendTarget()!], + sent: true, + expirationStartTimestamp: Date.now(), + }); + const result: typeof window.WhatIsThis = await this.sendSyncMessage(); + this.set({ + // We have to do this afterward, since we didn't have a previous send! + unidentifiedDeliveries: result ? result.unidentifiedDeliveries : null, + + // These are unique to a Note to Self message - immediately read/delivered + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + delivered_to: [window.ConversationController.getOurConversationId()!], + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + read_by: [window.ConversationController.getOurConversationId()!], + }); + } catch (result) { + const errors = (result && result.errors) || [new Error('Unknown error')]; + this.set({ errors }); + } finally { + await window.Signal.Data.saveMessage(this.attributes, { + Message: window.Whisper.Message, + }); + this.trigger('done'); + + const errors = this.get('errors'); + if (errors) { + this.trigger('send-error', errors); + } else { + this.trigger('sent'); + } + } + } + + async sendSyncMessage(): Promise { + const ourNumber = window.textsecure.storage.user.getNumber(); + const ourUuid = window.textsecure.storage.user.getUuid(); + const { wrap, sendOptions } = window.ConversationController.prepareForSend( + ourUuid || ourNumber, + { + syncMessage: true, + } + ); + + this.syncPromise = this.syncPromise || Promise.resolve(); + const next = async () => { + const dataMessage = this.get('dataMessage'); + if (!dataMessage) { + return Promise.resolve(); + } + const isUpdate = Boolean(this.get('synced')); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const conv = this.getConversation()!; + + return wrap( + window.textsecure.messaging.sendSyncMessage( + dataMessage, + this.get('sent_at'), + conv.get('e164'), + conv.get('uuid'), + this.get('expirationStartTimestamp'), + this.get('sent_to'), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.get('unidentifiedDeliveries')!, + isUpdate, + sendOptions + ) + ).then(async (result: unknown) => { + this.set({ + synced: true, + dataMessage: null, + }); + return window.Signal.Data.saveMessage(this.attributes, { + Message: window.Whisper.Message, + }).then(() => result); + }); + }; + + this.syncPromise = this.syncPromise.then(next, next); + + return this.syncPromise; + } + + // Receive logic + async queueAttachmentDownloads(): Promise { + const attachmentsToQueue = this.get('attachments') || []; + const messageId = this.id; + let count = 0; + let bodyPending; + + window.log.info( + `Queueing ${ + attachmentsToQueue.length + } attachment downloads for message ${this.idForLogging()}` + ); + + const [longMessageAttachments, normalAttachments] = _.partition( + attachmentsToQueue, + attachment => + attachment.contentType === + window.Whisper.Message.LONG_MESSAGE_CONTENT_TYPE + ); + + if (longMessageAttachments.length > 1) { + window.log.error( + `Received more than one long message attachment in message ${this.idForLogging()}` + ); + } + + window.log.info( + `Queueing ${ + longMessageAttachments.length + } long message attachment downloads for message ${this.idForLogging()}` + ); + + if (longMessageAttachments.length > 0) { + count += 1; + bodyPending = true; + await window.Signal.AttachmentDownloads.addJob( + longMessageAttachments[0], + { + messageId, + type: 'long-message', + index: 0, + } + ); + } + + window.log.info( + `Queueing ${ + normalAttachments.length + } normal attachment downloads for message ${this.idForLogging()}` + ); + const attachments = await Promise.all( + normalAttachments.map((attachment, index) => { + count += 1; + return window.Signal.AttachmentDownloads.addJob< + typeof window.WhatIsThis + >(attachment, { + messageId, + type: 'attachment', + index, + }); + }) + ); + + const previewsToQueue = this.get('preview') || []; + window.log.info( + `Queueing ${ + previewsToQueue.length + } preview attachment downloads for message ${this.idForLogging()}` + ); + const preview = await Promise.all( + previewsToQueue.map(async (item, index) => { + if (!item.image) { + return item; + } + + count += 1; + return { + ...item, + image: await window.Signal.AttachmentDownloads.addJob(item.image, { + messageId, + type: 'preview', + index, + }), + }; + }) + ); + + const contactsToQueue = this.get('contact') || []; + window.log.info( + `Queueing ${ + contactsToQueue.length + } contact attachment downloads for message ${this.idForLogging()}` + ); + const contact = await Promise.all( + contactsToQueue.map(async (item, index) => { + if (!item.avatar || !item.avatar.avatar) { + return item; + } + + count += 1; + return { + ...item, + avatar: { + ...item.avatar, + avatar: await window.Signal.AttachmentDownloads.addJob( + item.avatar.avatar, + { + messageId, + type: 'contact', + index, + } + ), + }, + }; + }) + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + let quote = this.get('quote')!; + const quoteAttachmentsToQueue = + quote && quote.attachments ? quote.attachments : []; + window.log.info( + `Queueing ${ + quoteAttachmentsToQueue.length + } quote attachment downloads for message ${this.idForLogging()}` + ); + if (quoteAttachmentsToQueue.length > 0) { + quote = { + ...quote, + attachments: await Promise.all( + (quote.attachments || []).map(async (item, index) => { + // If we already have a path, then we copied this image from the quoted + // message and we don't need to download the attachment. + if (!item.thumbnail || item.thumbnail.path) { + return item; + } + + count += 1; + return { + ...item, + thumbnail: await window.Signal.AttachmentDownloads.addJob( + item.thumbnail, + { + messageId, + type: 'quote', + index, + } + ), + }; + }) + ), + }; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + let sticker = this.get('sticker')!; + if (sticker) { + window.log.info( + `Queueing sticker download for message ${this.idForLogging()}` + ); + count += 1; + const { packId, stickerId, packKey } = sticker; + + const status = getStickerPackStatus(packId); + let data; + + if (status && (status === 'downloaded' || status === 'installed')) { + try { + const copiedSticker = await copyStickerToAttachments( + packId, + stickerId + ); + data = { + ...copiedSticker, + contentType: 'image/webp', + }; + } catch (error) { + window.log.error( + `Problem copying sticker (${packId}, ${stickerId}) to attachments:`, + error && error.stack ? error.stack : error + ); + } + } + if (!data) { + data = await window.Signal.AttachmentDownloads.addJob(sticker.data, { + messageId, + type: 'sticker', + index: 0, + }); + } + if (!status) { + // Save the packId/packKey for future download/install + savePackMetadata(packId, packKey, { messageId }); + } else { + await addStickerPackReference(messageId, packId); + } + + sticker = { + ...sticker, + packId, + data, + }; + } + + window.log.info( + `Queued ${count} total attachment downloads for message ${this.idForLogging()}` + ); + + if (count > 0) { + this.set({ + bodyPending, + attachments, + preview, + contact, + quote, + sticker, + }); + + return true; + } + + return false; + } + + // eslint-disable-next-line class-methods-use-this + async copyFromQuotedMessage(message: WhatIsThis): Promise { + const { quote } = message; + if (!quote) { + return message; + } + + const { attachments, id, author, authorUuid } = quote; + const firstAttachment = attachments[0]; + const authorConversationId = window.ConversationController.ensureContactIds( + { + e164: author, + uuid: authorUuid, + } + ); + + const collection = await window.Signal.Data.getMessagesBySentAt(id, { + MessageCollection: window.Whisper.MessageCollection, + }); + const found = collection.find(item => { + const messageAuthorId = item.getContactId(); + + return authorConversationId === messageAuthorId; + }); + + if (!found) { + quote.referencedMessageNotFound = true; + return message; + } + if (found.isTapToView()) { + quote.text = null; + quote.attachments = [ + { + contentType: 'image/jpeg', + }, + ]; + + return message; + } + + const queryMessage = window.MessageController.register(found.id, found); + quote.text = queryMessage.get('body'); + if (firstAttachment) { + firstAttachment.thumbnail = null; + } + + if ( + !firstAttachment || + (!GoogleChrome.isImageTypeSupported(firstAttachment.contentType) && + !GoogleChrome.isVideoTypeSupported(firstAttachment.contentType)) + ) { + return message; + } + + try { + if ( + queryMessage.get('schemaVersion') < + TypedMessage.VERSION_NEEDED_FOR_DISPLAY + ) { + const upgradedMessage = await upgradeMessageSchema( + queryMessage.attributes + ); + queryMessage.set(upgradedMessage); + await window.Signal.Data.saveMessage(upgradedMessage, { + Message: window.Whisper.Message, + }); + } + } catch (error) { + window.log.error( + 'Problem upgrading message quoted message from database', + Errors.toLogFormat(error) + ); + return message; + } + + const queryAttachments = queryMessage.get('attachments') || []; + if (queryAttachments.length > 0) { + const queryFirst = queryAttachments[0]; + const { thumbnail } = queryFirst; + + if (thumbnail && thumbnail.path) { + firstAttachment.thumbnail = { + ...thumbnail, + copied: true, + }; + } + } + + const queryPreview = queryMessage.get('preview') || []; + if (queryPreview.length > 0) { + const queryFirst = queryPreview[0]; + const { image } = queryFirst; + + if (image && image.path) { + firstAttachment.thumbnail = { + ...image, + copied: true, + }; + } + } + + const sticker = queryMessage.get('sticker'); + if (sticker && sticker.data && sticker.data.path) { + firstAttachment.thumbnail = { + ...sticker.data, + copied: true, + }; + } + + return message; + } + + handleDataMessage( + initialMessage: typeof window.WhatIsThis, + confirm: () => void, + options: { data?: typeof window.WhatIsThis } = {} + ): WhatIsThis { + const { data } = options; + + // This function is called from the background script in a few scenarios: + // 1. on an incoming message + // 2. on a sent message sync'd from another device + // 3. in rare cases, an incoming message can be retried, though it will + // still go through one of the previous two codepaths + // eslint-disable-next-line @typescript-eslint/no-this-alias + const message = this; + const source = message.get('source'); + const sourceUuid = message.get('sourceUuid'); + const type = message.get('type'); + const conversationId = message.get('conversationId'); + const GROUP_TYPES = window.textsecure.protobuf.GroupContext.Type; + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const conversation = window.ConversationController.get(conversationId)!; + return conversation.queueJob(async () => { + window.log.info( + `Starting handleDataMessage for message ${message.idForLogging()} in conversation ${conversation.idForLogging()}` + ); + + // First, check for duplicates. If we find one, stop processing here. + const existingMessage = await getMessageBySender(this.attributes, { + Message: window.Whisper.Message, + }); + const isUpdate = Boolean(data && data.isRecipientUpdate); + + if (existingMessage && type === 'incoming') { + window.log.warn('Received duplicate message', this.idForLogging()); + confirm(); + return; + } + if (type === 'outgoing') { + if (isUpdate && existingMessage) { + window.log.info( + `handleDataMessage: Updating message ${message.idForLogging()} with received transcript` + ); + + let sentTo = []; + let unidentifiedDeliveries = []; + if (Array.isArray(data.unidentifiedStatus)) { + sentTo = data.unidentifiedStatus.map( + (item: typeof window.WhatIsThis) => item.destination + ); + + const unidentified = _.filter(data.unidentifiedStatus, item => + Boolean(item.unidentified) + ); + unidentifiedDeliveries = unidentified.map(item => item.destination); + } + + const toUpdate = window.MessageController.register( + existingMessage.id, + existingMessage + ); + toUpdate.set({ + sent_to: _.union(toUpdate.get('sent_to'), sentTo), + unidentifiedDeliveries: _.union( + toUpdate.get('unidentifiedDeliveries'), + unidentifiedDeliveries + ), + }); + await window.Signal.Data.saveMessage(toUpdate.attributes, { + Message: window.Whisper.Message, + }); + + confirm(); + return; + } + if (isUpdate) { + window.log.warn( + `handleDataMessage: Received update transcript, but no existing entry for message ${message.idForLogging()}. Dropping.` + ); + + confirm(); + return; + } + if (existingMessage) { + window.log.warn( + `handleDataMessage: Received duplicate transcript for message ${message.idForLogging()}, but it was not an update transcript. Dropping.` + ); + + confirm(); + return; + } + } + + const existingRevision = conversation.get('revision'); + const isGroupV2 = Boolean(initialMessage.groupV2); + const isV2GroupUpdate = + initialMessage.groupV2 && + (!existingRevision || + initialMessage.groupV2.revision > existingRevision); + + // GroupV2 + if (isGroupV2) { + conversation.maybeRepairGroupV2( + _.pick(initialMessage.groupV2, [ + 'masterKey', + 'secretParams', + 'publicParams', + ]) + ); + } + + if (isV2GroupUpdate) { + const { revision, groupChange } = initialMessage.groupV2; + try { + await window.Signal.Groups.maybeUpdateGroup({ + conversation, + groupChangeBase64: groupChange, + newRevision: revision, + receivedAt: message.get('received_at'), + sentAt: message.get('sent_at'), + }); + } catch (error) { + const errorText = error && error.stack ? error.stack : error; + window.log.error( + `handleDataMessage: Failed to process group update for ${conversation.idForLogging()} as part of message ${message.idForLogging()}: ${errorText}` + ); + throw error; + } + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const ourConversationId = window.ConversationController.getOurConversationId()!; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const senderId = window.ConversationController.ensureContactIds({ + e164: source, + uuid: sourceUuid, + })!; + const isV1GroupUpdate = + initialMessage.group && + initialMessage.group.type !== + window.textsecure.protobuf.GroupContext.Type.DELIVER; + + // Drop an incoming GroupV2 message if we or the sender are not part of the group + // after applying the message's associated group chnages. + if ( + type === 'incoming' && + !conversation.isPrivate() && + isGroupV2 && + (conversation.get('left') || + !conversation.hasMember(ourConversationId) || + !conversation.hasMember(senderId)) + ) { + window.log.warn( + `Received message destined for group ${conversation.idForLogging()}, which we or the sender are not a part of. Dropping.` + ); + confirm(); + return; + } + + // We drop incoming messages for v1 groups we already know about, which we're not + // a part of, except for group updates. Because group v1 updates haven't been + // applied by this point. + if ( + type === 'incoming' && + !conversation.isPrivate() && + !isGroupV2 && + !isV1GroupUpdate && + (conversation.get('left') || !conversation.hasMember(ourConversationId)) + ) { + window.log.warn( + `Received message destined for group ${conversation.idForLogging()}, which we're not a part of. Dropping.` + ); + confirm(); + return; + } + + // Send delivery receipts, but only for incoming sealed sender messages + // and not for messages from unaccepted conversations + if ( + type === 'incoming' && + this.get('unidentifiedDeliveryReceived') && + !this.hasErrors() && + conversation.getAccepted() + ) { + // Note: We both queue and batch because we want to wait until we are done + // processing incoming messages to start sending outgoing delivery receipts. + // The queue can be paused easily. + window.Whisper.deliveryReceiptQueue.add(() => { + window.Whisper.deliveryReceiptBatcher.add({ + source, + sourceUuid, + timestamp: this.get('sent_at'), + }); + }); + } + + const withQuoteReference = await this.copyFromQuotedMessage( + initialMessage + ); + const dataMessage = await upgradeMessageSchema(withQuoteReference); + + try { + const now = new Date().getTime(); + + const urls = window.Signal.LinkPreviews.findLinks(dataMessage.body); + const incomingPreview = dataMessage.preview || []; + const preview = incomingPreview.filter( + (item: typeof window.WhatIsThis) => + (item.image || item.title) && + urls.includes(item.url) && + window.Signal.LinkPreviews.isLinkSafeToPreview(item.url) + ); + if (preview.length < incomingPreview.length) { + window.log.info( + `${message.idForLogging()}: Eliminated ${preview.length - + incomingPreview.length} previews with invalid urls'` + ); + } + + message.set({ + id: window.getGuid(), + attachments: dataMessage.attachments, + body: dataMessage.body, + bodyRanges: dataMessage.bodyRanges, + contact: dataMessage.contact, + conversationId: conversation.id, + decrypted_at: now, + errors: [], + flags: dataMessage.flags, + hasAttachments: dataMessage.hasAttachments, + hasFileAttachments: dataMessage.hasFileAttachments, + hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments, + isViewOnce: Boolean(dataMessage.isViewOnce), + preview, + requiredProtocolVersion: + dataMessage.requiredProtocolVersion || + this.INITIAL_PROTOCOL_VERSION, + supportedVersionAtReceive: this.CURRENT_PROTOCOL_VERSION, + quote: dataMessage.quote, + schemaVersion: dataMessage.schemaVersion, + sticker: dataMessage.sticker, + }); + + const isSupported = !message.isUnsupportedMessage(); + if (!isSupported) { + await message.eraseContents(); + } + + if (isSupported) { + let attributes = { + ...conversation.attributes, + }; + + // GroupV1 + if (!isGroupV2 && dataMessage.group) { + const pendingGroupUpdate = []; + const memberConversations: Array = await Promise.all( + dataMessage.group.membersE164.map((e164: string) => + window.ConversationController.getOrCreateAndWait( + e164, + 'private' + ) + ) + ); + const members = memberConversations.map(c => c.get('id')); + attributes = { + ...attributes, + type: 'group', + groupId: dataMessage.group.id, + }; + if (dataMessage.group.type === GROUP_TYPES.UPDATE) { + attributes = { + ...attributes, + name: dataMessage.group.name, + members: _.union(members, conversation.get('members')), + }; + + if (dataMessage.group.name !== conversation.get('name')) { + pendingGroupUpdate.push(['name', dataMessage.group.name]); + } + + const avatarAttachment = dataMessage.group.avatar; + + let downloadedAvatar; + let hash; + if (avatarAttachment) { + try { + downloadedAvatar = await window.Signal.Util.downloadAttachment( + avatarAttachment + ); + + if (downloadedAvatar) { + const loadedAttachment = await window.Signal.Migrations.loadAttachmentData( + downloadedAvatar + ); + + hash = await window.Signal.Types.Conversation.computeHash( + loadedAttachment.data + ); + } + } catch (err) { + window.log.info( + 'handleDataMessage: group avatar download failed' + ); + } + } + + const existingAvatar = conversation.get('avatar'); + + if ( + // Avatar added + (!existingAvatar && avatarAttachment) || + // Avatar changed + (existingAvatar && existingAvatar.hash !== hash) || + // Avatar removed + (existingAvatar && !avatarAttachment) + ) { + // Removes existing avatar from disk + if (existingAvatar && existingAvatar.path) { + await window.Signal.Migrations.deleteAttachmentData( + existingAvatar.path + ); + } + + let avatar = null; + if (downloadedAvatar && avatarAttachment !== null) { + const onDiskAttachment = await window.Signal.Types.Attachment.migrateDataToFileSystem( + downloadedAvatar, + { + writeNewAttachmentData: + window.Signal.Migrations.writeNewAttachmentData, + } + ); + avatar = { + ...onDiskAttachment, + hash, + }; + } + + attributes.avatar = avatar; + + pendingGroupUpdate.push(['avatarUpdated', true]); + } else { + window.log.info( + 'handleDataMessage: Group avatar hash matched; not replacing group avatar' + ); + } + + const difference = _.difference( + members, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + conversation.get('members')! + ); + if (difference.length > 0) { + // Because GroupV1 groups are based on e164 only + const e164s = difference.map(id => { + const c = window.ConversationController.get(id); + return c ? c.get('e164') : null; + }); + pendingGroupUpdate.push(['joined', e164s]); + } + if (conversation.get('left')) { + window.log.warn('re-added to a left group'); + attributes.left = false; + conversation.set({ addedBy: message.getContactId() }); + } + } else if (dataMessage.group.type === GROUP_TYPES.QUIT) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const sender = window.ConversationController.get(senderId)!; + const inGroup = Boolean( + sender && + (conversation.get('members') || []).includes(sender.id) + ); + if (!inGroup) { + const senderString = sender ? sender.idForLogging() : null; + window.log.info( + `Got 'left' message from someone not in group: ${senderString}. Dropping.` + ); + return; + } + + if (sender.isMe()) { + attributes.left = true; + pendingGroupUpdate.push(['left', 'You']); + } else { + pendingGroupUpdate.push(['left', sender.get('id')]); + } + attributes.members = _.without( + conversation.get('members'), + sender.get('id') + ); + } + + if (pendingGroupUpdate.length) { + const groupUpdate = pendingGroupUpdate.reduce( + (acc, [key, value]) => { + acc[key] = value; + return acc; + }, + {} as typeof window.WhatIsThis + ); + message.set({ group_update: groupUpdate }); + } + } + + // Drop empty messages after. This needs to happen after the initial + // message.set call and after GroupV1 processing to make sure all possible + // properties are set before we determine that a message is empty. + if (message.isEmpty()) { + window.log.info( + `handleDataMessage: Dropping empty message ${message.idForLogging()} in conversation ${conversation.idForLogging()}` + ); + confirm(); + return; + } + + if (type === 'outgoing') { + const receipts = window.Whisper.DeliveryReceipts.forMessage( + conversation, + message + ); + receipts.forEach(receipt => + message.set({ + delivered: (message.get('delivered') || 0) + 1, + delivered_to: _.union(message.get('delivered_to') || [], [ + receipt.get('deliveredTo'), + ]), + }) + ); + } + + attributes.active_at = now; + conversation.set(attributes); + + if (dataMessage.expireTimer) { + message.set({ expireTimer: dataMessage.expireTimer }); + } + + if (!isGroupV2) { + if (message.isExpirationTimerUpdate()) { + message.set({ + expirationTimerUpdate: { + source, + sourceUuid, + expireTimer: dataMessage.expireTimer, + }, + }); + conversation.set({ expireTimer: dataMessage.expireTimer }); + } + + // NOTE: Remove once the above calls this.model.updateExpirationTimer() + const { expireTimer } = dataMessage; + const shouldLogExpireTimerChange = + message.isExpirationTimerUpdate() || expireTimer; + if (shouldLogExpireTimerChange) { + window.log.info("Update conversation 'expireTimer'", { + id: conversation.idForLogging(), + expireTimer, + source: 'handleDataMessage', + }); + } + + if (!message.isEndSession()) { + if (dataMessage.expireTimer) { + if ( + dataMessage.expireTimer !== conversation.get('expireTimer') + ) { + conversation.updateExpirationTimer( + dataMessage.expireTimer, + source, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + message.get('received_at')!, + { + fromGroupUpdate: message.isGroupUpdate(), + } + ); + } + } else if ( + conversation.get('expireTimer') && + // We only turn off timers if it's not a group update + !message.isGroupUpdate() + ) { + conversation.updateExpirationTimer( + undefined, + source, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + message.get('received_at')! + ); + } + } + } + + if (type === 'incoming') { + const readSync = window.Whisper.ReadSyncs.forMessage(message); + if (readSync) { + if ( + message.get('expireTimer') && + !message.get('expirationStartTimestamp') + ) { + message.set( + 'expirationStartTimestamp', + Math.min(readSync.get('read_at'), Date.now()) + ); + } + } + if (readSync || message.isExpirationTimerUpdate()) { + message.unset('unread'); + // This is primarily to allow the conversation to mark all older + // messages as read, as is done when we receive a read sync for + // a message we already know about. + const c = message.getConversation(); + if (c) { + c.onReadMessage(message); + } + } else { + conversation.set({ + unreadCount: (conversation.get('unreadCount') || 0) + 1, + isArchived: false, + }); + } + } + + if (type === 'outgoing') { + const reads = window.Whisper.ReadReceipts.forMessage( + conversation, + message + ); + if (reads.length) { + const readBy = reads.map(receipt => receipt.get('reader')); + message.set({ + read_by: _.union(message.get('read_by'), readBy), + }); + } + + // A sync'd message to ourself is automatically considered read/delivered + if (conversation.isMe()) { + message.set({ + read_by: conversation.getRecipients(), + delivered_to: conversation.getRecipients(), + }); + } + + message.set({ recipients: conversation.getRecipients() }); + } + + if (dataMessage.profileKey) { + const profileKey = dataMessage.profileKey.toString('base64'); + if ( + source === window.textsecure.storage.user.getNumber() || + sourceUuid === window.textsecure.storage.user.getUuid() + ) { + conversation.set({ profileSharing: true }); + } else if (conversation.isPrivate()) { + conversation.setProfileKey(profileKey); + } else { + const localId = window.ConversationController.ensureContactIds({ + e164: source, + uuid: sourceUuid, + }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + window.ConversationController.get(localId)!.setProfileKey( + profileKey + ); + } + } + + if (message.isTapToView() && type === 'outgoing') { + await message.eraseContents(); + } + + if ( + type === 'incoming' && + message.isTapToView() && + !message.isValidTapToView() + ) { + window.log.warn( + `Received tap to view message ${message.idForLogging()} with invalid data. Erasing contents.` + ); + message.set({ + isTapToViewInvalid: true, + }); + await message.eraseContents(); + } + // Check for out-of-order view syncs + if (type === 'incoming' && message.isTapToView()) { + const viewSync = window.Whisper.ViewSyncs.forMessage(message); + if (viewSync) { + await message.markViewed({ fromSync: true }); + } + } + } + + const conversationTimestamp = conversation.get('timestamp'); + if ( + !conversationTimestamp || + message.get('sent_at') > conversationTimestamp + ) { + conversation.set({ + lastMessage: message.getNotificationText(), + timestamp: message.get('sent_at'), + }); + } + + window.MessageController.register( + message.id, + message as typeof window.WhatIsThis + ); + conversation.incrementMessageCount(); + window.Signal.Data.updateConversation(conversation.attributes); + + // Only queue attachments for downloads if this is an outgoing message + // or we've accepted the conversation + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (this.getConversation()!.getAccepted() || message.isOutgoing()) { + await message.queueAttachmentDownloads(); + } + + // Does this message have any pending, previously-received associated reactions? + const reactions = window.Whisper.Reactions.forMessage(message); + reactions.forEach(reaction => { + message.handleReaction(reaction, false); + }); + + // Does this message have any pending, previously-received associated + // delete for everyone messages? + const deletes = window.Whisper.Deletes.forMessage(message); + deletes.forEach(del => { + window.Signal.Util.deleteForEveryone(message, del, false); + }); + + await window.Signal.Data.saveMessage(message.attributes, { + Message: window.Whisper.Message, + forceSave: true, + }); + + conversation.trigger('newmessage', message); + + if (message.get('unread')) { + await conversation.notify(message); + } + + // Increment the sent message count if this is an outgoing message + if (type === 'outgoing') { + conversation.incrementSentMessageCount(); + } + + window.Whisper.events.trigger('incrementProgress'); + confirm(); + } catch (error) { + const errorForLog = error && error.stack ? error.stack : error; + window.log.error( + 'handleDataMessage', + message.idForLogging(), + 'error:', + errorForLog + ); + throw error; + } + }); + } + + async handleReaction( + reaction: typeof window.WhatIsThis, + shouldPersist = true + ): Promise { + if (this.get('deletedForEveryone')) { + return; + } + + const reactions = this.get('reactions') || []; + const messageId = this.idForLogging(); + const count = reactions.length; + + if (reaction.get('remove')) { + window.log.info('Removing reaction for message', messageId); + const newReactions = reactions.filter( + re => + re.emoji !== reaction.get('emoji') || + re.fromId !== reaction.get('fromId') + ); + this.set({ reactions: newReactions }); + } else { + window.log.info('Adding reaction for message', messageId); + const newReactions = reactions.filter( + re => re.fromId !== reaction.get('fromId') + ); + newReactions.push(reaction.toJSON()); + this.set({ reactions: newReactions }); + + const conversation = window.ConversationController.get( + this.get('conversationId') + ); + + // Only notify for reactions to our own messages + if (conversation && this.isOutgoing() && !reaction.get('fromSync')) { + conversation.notify(this, reaction); + } + } + + const newCount = this.get('reactions').length; + window.log.info( + `Done processing reaction for message ${messageId}. Went from ${count} to ${newCount} reactions.` + ); + + if (shouldPersist) { + await window.Signal.Data.saveMessage(this.attributes, { + Message: window.Whisper.Message, + }); + } + } + + async handleDeleteForEveryone( + del: typeof window.WhatIsThis, + shouldPersist = true + ): Promise { + window.log.info('Handling DOE.', { + fromId: del.get('fromId'), + targetSentTimestamp: del.get('targetSentTimestamp'), + messageServerTimestamp: this.get('serverTimestamp'), + deleteServerTimestamp: del.get('serverTimestamp'), + }); + + // Remove any notifications for this message + window.Whisper.Notifications.removeBy({ messageId: this.get('id') }); + + // Erase the contents of this message + await this.eraseContents( + { deletedForEveryone: true, reactions: [] }, + shouldPersist + ); + + // Update the conversation's last message in case this was the last message + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.getConversation()!.updateLastMessage(); + } +} + +window.Whisper.Message = MessageModel as typeof window.WhatIsThis; + +// Receive will be enabled before we enable send +window.Whisper.Message.LONG_MESSAGE_CONTENT_TYPE = 'text/x-signal-plain'; + +window.Whisper.Message.getLongMessageAttachment = ({ + body, + attachments, + now, +}) => { + if (!body || body.length <= 2048) { + return { + body, + attachments, + }; + } + + const data = bytesFromString(body); + const attachment = { + contentType: window.Whisper.Message.LONG_MESSAGE_CONTENT_TYPE, + fileName: `long-message-${now}.txt`, + data, + size: data.byteLength, + }; + + return { + body: body.slice(0, 2048), + attachments: [attachment, ...attachments], + }; +}; + +window.Whisper.Message.updateTimers = () => { + window.Whisper.ExpiringMessagesListener.update(); + window.Whisper.TapToViewMessagesListener.update(); +}; + +window.Whisper.MessageCollection = window.Backbone.Collection.extend({ + model: window.Whisper.Message, + comparator(left: typeof window.WhatIsThis, right: typeof window.WhatIsThis) { + if (left.get('received_at') === right.get('received_at')) { + return (left.get('sent_at') || 0) - (right.get('sent_at') || 0); + } + + return (left.get('received_at') || 0) - (right.get('received_at') || 0); + }, +}); diff --git a/ts/services/calling.ts b/ts/services/calling.ts index ec16d6a23..66a78de2d 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -22,12 +22,12 @@ import { CallDetailsType, } from '../state/ducks/calling'; import { CallingMessageClass, EnvelopeClass } from '../textsecure.d'; -import { ConversationModelType } from '../model-types.d'; import { AudioDevice, CallHistoryDetailsType, MediaDeviceSettings, } from '../types/Calling'; +import { ConversationModel } from '../models/conversations'; export { CallState, @@ -87,7 +87,7 @@ export class CallingClass { } async startOutgoingCall( - conversation: ConversationModelType, + conversation: ConversationModel, isVideoCall: boolean ): Promise { window.log.info('CallingClass.startOutgoingCall()'); @@ -626,7 +626,7 @@ export class CallingClass { this.addCallHistoryForAutoEndedIncomingCall(conversation, reason); } - private attachToCall(conversation: ConversationModelType, call: Call): void { + private attachToCall(conversation: ConversationModel, call: Call): void { const { uxActions } = this; if (!uxActions) { return; @@ -679,8 +679,8 @@ export class CallingClass { } private getRemoteUserIdFromConversation( - conversation: ConversationModelType - ): UserId | undefined { + conversation: ConversationModel + ): UserId | undefined | null { const recipients = conversation.getRecipients(); if (recipients.length !== 1) { return undefined; @@ -705,7 +705,7 @@ export class CallingClass { } private async getCallSettings( - conversation: ConversationModelType + conversation: ConversationModel ): Promise { if (!window.textsecure.messaging) { throw new Error('getCallSettings: offline!'); @@ -725,9 +725,12 @@ export class CallingClass { } private getUxCallDetails( - conversation: ConversationModelType, + conversation: ConversationModel, call: Call ): CallDetailsType { + // Does not meet CallDetailsType interface requirements + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore return { ...conversation.cachedProps, @@ -738,7 +741,7 @@ export class CallingClass { } private addCallHistoryForEndedCall( - conversation: ConversationModelType, + conversation: ConversationModel, call: Call, acceptedTimeParam: number | undefined ) { @@ -770,7 +773,7 @@ export class CallingClass { } private addCallHistoryForFailedIncomingCall( - conversation: ConversationModelType, + conversation: ConversationModel, call: Call ) { const callHistoryDetails: CallHistoryDetailsType = { @@ -785,7 +788,7 @@ export class CallingClass { } private addCallHistoryForAutoEndedIncomingCall( - conversation: ConversationModelType, + conversation: ConversationModel, _reason: CallEndedReason ) { const callHistoryDetails: CallHistoryDetailsType = { diff --git a/ts/services/storage.ts b/ts/services/storage.ts index 66768ee74..f9768e5b9 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -17,7 +17,6 @@ import { StorageManifestClass, StorageRecordClass, } from '../textsecure.d'; -import { ConversationModelType } from '../model-types.d'; import { isEnabled } from '../RemoteConfig'; import { mergeAccountRecord, @@ -29,6 +28,7 @@ import { toGroupV1Record, toGroupV2Record, } from './storageRecordOps'; +import { ConversationModel } from '../models/conversations'; const { eraseStorageServiceStateFromConversations, @@ -98,7 +98,7 @@ function generateStorageID(): ArrayBuffer { return Crypto.getRandomBytes(16); } -function isGroupV1(conversation: ConversationModelType): boolean { +function isGroupV1(conversation: ConversationModel): boolean { const groupID = conversation.get('groupId'); if (!groupID) { return false; @@ -109,7 +109,7 @@ function isGroupV1(conversation: ConversationModelType): boolean { type GeneratedManifestType = { conversationsToUpdate: Array<{ - conversation: ConversationModelType; + conversation: ConversationModel; storageID: string | undefined; }>; deleteKeys: Array; @@ -602,7 +602,7 @@ async function processManifest( const localKeys = window .getConversations() - .map((conversation: ConversationModelType) => conversation.get('storageID')) + .map((conversation: ConversationModel) => conversation.get('storageID')) .filter(Boolean); const unknownRecordsArray = diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index f44485706..4fd643ca8 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -14,7 +14,7 @@ import { GroupV2RecordClass, } from '../textsecure.d'; import { deriveGroupFields, waitThenMaybeUpdateGroup } from '../groups'; -import { ConversationModelType } from '../model-types.d'; +import { ConversationModel } from '../models/conversations'; const { updateConversation } = dataInterface; @@ -40,7 +40,7 @@ function toRecordVerified(verified: number): number { function addUnknownFields( record: RecordClass, - conversation: ConversationModelType + conversation: ConversationModel ): void { if (record.__unknownFields) { window.log.info( @@ -56,7 +56,7 @@ function addUnknownFields( function applyUnknownFields( record: RecordClass, - conversation: ConversationModelType + conversation: ConversationModel ): void { if (conversation.get('storageUnknownFields')) { window.log.info( @@ -72,7 +72,7 @@ function applyUnknownFields( } export async function toContactRecord( - conversation: ConversationModelType + conversation: ConversationModel ): Promise { const contactRecord = new window.textsecure.protobuf.ContactRecord(); if (conversation.get('uuid')) { @@ -113,7 +113,7 @@ export async function toContactRecord( } export async function toAccountRecord( - conversation: ConversationModelType + conversation: ConversationModel ): Promise { const accountRecord = new window.textsecure.protobuf.AccountRecord(); @@ -147,7 +147,7 @@ export async function toAccountRecord( } export async function toGroupV1Record( - conversation: ConversationModelType + conversation: ConversationModel ): Promise { const groupV1Record = new window.textsecure.protobuf.GroupV1Record(); @@ -164,7 +164,7 @@ export async function toGroupV1Record( } export async function toGroupV2Record( - conversation: ConversationModelType + conversation: ConversationModel ): Promise { const groupV2Record = new window.textsecure.protobuf.GroupV2Record(); @@ -185,7 +185,7 @@ type MessageRequestCapableRecord = ContactRecordClass | GroupV1RecordClass; function applyMessageRequestState( record: MessageRequestCapableRecord, - conversation: ConversationModelType + conversation: ConversationModel ): void { if (record.blocked) { conversation.applyMessageRequestResponse( @@ -218,7 +218,7 @@ type RecordClassObject = { function doRecordsConflict( localRecord: RecordClassObject, remoteRecord: RecordClassObject, - conversation: ConversationModelType + conversation: ConversationModel ): boolean { const debugID = conversation.debugID(); @@ -277,7 +277,7 @@ function doRecordsConflict( function doesRecordHavePendingChanges( mergedRecord: RecordClass, serviceRecord: RecordClass, - conversation: ConversationModelType + conversation: ConversationModel ): boolean { const shouldSync = Boolean(conversation.get('needsStorageServiceSync')); diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index c85036d6e..57e7d0799 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -21,9 +21,7 @@ import { createBatcher } from '../util/batcher'; import { ConversationModelCollectionType, - ConversationModelType, MessageModelCollectionType, - MessageModelType, } from '../model-types.d'; import { @@ -45,6 +43,8 @@ import { StickerType, UnprocessedType, } from './Interface'; +import { MessageModel } from '../models/messages'; +import { ConversationModel } from '../models/conversations'; // We listen to a lot of events on ipcRenderer, often on the same channel. This prevents // any warnings that might be sent to the console in that case. @@ -726,7 +726,7 @@ async function saveConversations(array: Array) { async function getConversationById( id: string, - { Conversation }: { Conversation: typeof ConversationModelType } + { Conversation }: { Conversation: typeof ConversationModel } ) { const data = await channels.getConversationById(id); @@ -756,7 +756,7 @@ async function updateConversations(array: Array) { async function removeConversation( id: string, - { Conversation }: { Conversation: typeof ConversationModelType } + { Conversation }: { Conversation: typeof ConversationModel } ) { const existing = await getConversationById(id, { Conversation }); @@ -869,10 +869,7 @@ async function getMessageCount(conversationId?: string) { async function saveMessage( data: MessageType, - { - forceSave, - Message, - }: { forceSave?: boolean; Message: typeof MessageModelType } + { forceSave, Message }: { forceSave?: boolean; Message: typeof MessageModel } ) { const id = await channels.saveMessage(_cleanData(data), { forceSave }); Message.updateTimers(); @@ -889,7 +886,7 @@ async function saveMessages( async function removeMessage( id: string, - { Message }: { Message: typeof MessageModelType } + { Message }: { Message: typeof MessageModel } ) { const message = await getMessageById(id, { Message }); @@ -908,7 +905,7 @@ async function _removeMessages(ids: Array) { async function getMessageById( id: string, - { Message }: { Message: typeof MessageModelType } + { Message }: { Message: typeof MessageModel } ) { const message = await channels.getMessageById(id); if (!message) { @@ -947,7 +944,7 @@ async function getMessageBySender( sourceDevice: string; sent_at: number; }, - { Message }: { Message: typeof MessageModelType } + { Message }: { Message: typeof MessageModel } ) { const messages = await channels.getMessageBySender({ source, @@ -1027,9 +1024,9 @@ async function getNewerMessagesByConversation( async function getLastConversationActivity( conversationId: string, options: { - Message: typeof MessageModelType; + Message: typeof MessageModel; } -): Promise { +): Promise { const { Message } = options; const result = await channels.getLastConversationActivity(conversationId); if (result) { @@ -1040,9 +1037,9 @@ async function getLastConversationActivity( async function getLastConversationPreview( conversationId: string, options: { - Message: typeof MessageModelType; + Message: typeof MessageModel; } -): Promise { +): Promise { const { Message } = options; const result = await channels.getLastConversationPreview(conversationId); if (result) { @@ -1083,12 +1080,12 @@ async function removeAllMessagesInConversation( return; } - const ids = messages.map((message: MessageModelType) => message.id); + const ids = messages.map((message: MessageModel) => 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(async (message: MessageModelType) => message.cleanup()) + messages.map(async (message: MessageModel) => message.cleanup()) ); await channels.removeMessage(ids); @@ -1129,7 +1126,7 @@ async function getOutgoingWithoutExpiresAt({ async function getNextExpiringMessage({ Message, }: { - Message: typeof MessageModelType; + Message: typeof MessageModel; }) { const message = await channels.getNextExpiringMessage(); @@ -1143,7 +1140,7 @@ async function getNextExpiringMessage({ async function getNextTapToViewMessageToAgeOut({ Message, }: { - Message: typeof MessageModelType; + Message: typeof MessageModel; }) { const message = await channels.getNextTapToViewMessageToAgeOut(); if (!message) { diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index dce3a81ce..70084df24 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -19,10 +19,10 @@ export type UnprocessedType = any; import { ConversationModelCollectionType, - ConversationModelType, MessageModelCollectionType, - MessageModelType, } from '../model-types.d'; +import { MessageModel } from '../models/messages'; +import { ConversationModel } from '../models/conversations'; export interface DataInterface { close: () => Promise; @@ -204,7 +204,11 @@ export type ServerInterface = DataInterface & { getMessagesBySentAt: (sentAt: number) => Promise>; getOlderMessagesByConversation: ( conversationId: string, - options?: { limit?: number; receivedAt?: number; messageId?: string } + options?: { + limit?: number; + receivedAt?: number; + messageId?: string; + } ) => Promise>; getNewerMessagesByConversation: ( conversationId: string, @@ -228,7 +232,7 @@ export type ServerInterface = DataInterface & { saveMessage: ( data: MessageType, options: { forceSave?: boolean } - ) => Promise; + ) => Promise; updateConversation: (data: ConversationType) => Promise; // For testing only @@ -272,8 +276,8 @@ export type ClientInterface = DataInterface & { }) => Promise; getConversationById: ( id: string, - { Conversation }: { Conversation: typeof ConversationModelType } - ) => Promise; + { Conversation }: { Conversation: typeof ConversationModel } + ) => Promise; getExpiredMessages: ({ MessageCollection, }: { @@ -281,7 +285,7 @@ export type ClientInterface = DataInterface & { }) => Promise; getMessageById: ( id: string, - { Message }: { Message: typeof MessageModelType } + { Message }: { Message: typeof MessageModel } ) => Promise; getMessageBySender: ( options: { @@ -290,8 +294,8 @@ export type ClientInterface = DataInterface & { sourceDevice: string; sent_at: number; }, - { Message }: { Message: typeof MessageModelType } - ) => Promise; + { Message }: { Message: typeof MessageModel } + ) => Promise; getMessagesBySentAt: ( sentAt: number, { @@ -302,6 +306,7 @@ export type ClientInterface = DataInterface & { conversationId: string, options: { limit?: number; + messageId?: string; receivedAt?: number; MessageCollection: typeof MessageModelCollectionType; } @@ -317,25 +322,25 @@ export type ClientInterface = DataInterface & { getLastConversationActivity: ( conversationId: string, options: { - Message: typeof MessageModelType; + Message: typeof MessageModel; } - ) => Promise; + ) => Promise; getLastConversationPreview: ( conversationId: string, options: { - Message: typeof MessageModelType; + Message: typeof MessageModel; } - ) => Promise; + ) => Promise; getNextExpiringMessage: ({ Message, }: { - Message: typeof MessageModelType; - }) => Promise; + Message: typeof MessageModel; + }) => Promise; getNextTapToViewMessageToAgeOut: ({ Message, }: { - Message: typeof MessageModelType; - }) => Promise; + Message: typeof MessageModel; + }) => Promise; getOutgoingWithoutExpiresAt: ({ MessageCollection, }: { @@ -354,17 +359,17 @@ export type ClientInterface = DataInterface & { ) => Promise; removeConversation: ( id: string, - { Conversation }: { Conversation: typeof ConversationModelType } + { Conversation }: { Conversation: typeof ConversationModel } ) => Promise; removeMessage: ( id: string, - { Message }: { Message: typeof MessageModelType } + { Message }: { Message: typeof MessageModel } ) => Promise; saveMessage: ( data: MessageType, - options: { forceSave?: boolean; Message: typeof MessageModelType } - ) => Promise; - updateConversation: (data: ConversationType) => void; + options: { forceSave?: boolean; Message: typeof MessageModel } + ) => Promise; + updateConversation: (data: ConversationType, extra?: unknown) => void; // Test-only diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 75a53d476..69af76a10 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -2674,7 +2674,11 @@ async function getOlderMessagesByConversation( limit = 100, receivedAt = Number.MAX_VALUE, messageId, - }: { limit?: number; receivedAt?: number; messageId?: string } = {} + }: { + limit?: number; + receivedAt?: number; + messageId?: string; + } = {} ) { const db = getInstance(); let rows; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 052757ad5..5ae471d13 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -23,6 +23,17 @@ export type DBConversationType = { lastMessage: string; type: string; }; + +export type LastMessageStatus = + | 'error' + | 'partial-sent' + | 'sending' + | 'sent' + | 'delivered' + | 'read'; + +export type ConversationTypeType = 'direct' | 'group'; + export type ConversationType = { id: string; uuid?: string; @@ -39,19 +50,13 @@ export type ConversationType = { timestamp?: number; inboxPosition?: number; lastMessage?: { - status: - | 'error' - | 'partial-sent' - | 'sending' - | 'sent' - | 'delivered' - | 'read'; + status: LastMessageStatus; text: string; }; phoneNumber?: string; membersCount?: number; muteExpiresAt?: number; - type: 'direct' | 'group'; + type: ConversationTypeType; isMe?: boolean; lastUpdated: number; title: string; @@ -59,14 +64,14 @@ export type ConversationType = { isSelected?: boolean; typingContact?: { avatarPath?: string; - color: string; + color?: ColorType; name?: string; - phoneNumber: string; + phoneNumber?: string; profileName?: string; - }; + } | null; shouldShowDraft?: boolean; - draftText?: string; + draftText?: string | null; draftPreview?: string; messageRequestsEnabled?: boolean; diff --git a/ts/textsecure.d.ts b/ts/textsecure.d.ts index a494741b5..6e211f770 100644 --- a/ts/textsecure.d.ts +++ b/ts/textsecure.d.ts @@ -39,7 +39,7 @@ export type StorageServiceCredentials = { export type TextSecureType = { createTaskWithTimeout: ( - task: () => Promise, + task: () => Promise | any, id?: string, options?: { timeout?: number } ) => () => Promise; @@ -77,12 +77,16 @@ export type TextSecureType = { protocol: StorageProtocolType; }; messageReceiver: MessageReceiver; - messaging?: SendMessage; + messageSender: MessageSender; + messaging: SendMessage; protobuf: ProtobufCollectionType; utils: typeof utils; EventTarget: typeof EventTarget; MessageReceiver: typeof MessageReceiver; + AccountManager: WhatIsThis; + MessageSender: WhatIsThis; + SyncRequest: WhatIsThis; }; type StoredSignedPreKeyType = SignedPreKeyType & { @@ -108,11 +112,13 @@ export type StorageProtocolType = StorageType & { removeSession: (identifier: string) => Promise; getDeviceIds: (identifier: string) => Promise>; getIdentityRecord: (identifier: string) => IdentityKeyRecord | undefined; + getVerified: (id: string) => Promise; hydrateCaches: () => Promise; clearPreKeyStore: () => Promise; clearSignedPreKeysStore: () => Promise; clearSessionStore: () => Promise; isTrustedIdentity: () => void; + isUntrusted: (id: string) => Promise; storePreKey: (keyId: number, keyPair: KeyPairType) => Promise; storeSignedPreKey: ( keyId: number, @@ -131,6 +137,7 @@ export type StorageProtocolType = StorageType & { number: string, options: IdentityKeyRecord ) => Promise; + setApproval: (id: string, something: boolean) => void; setVerified: ( encodedAddress: string, verifiedStatus: number, @@ -138,6 +145,8 @@ export type StorageProtocolType = StorageType & { ) => Promise; removeSignedPreKey: (keyId: number) => Promise; removeAllData: () => Promise; + on: (key: string, callback: () => void) => WhatIsThis; + removeAllConfiguration: () => Promise; }; // Protobufs @@ -279,6 +288,7 @@ export declare class AccessControlClass { // Note: we need to use namespaces to express nested classes in Typescript export declare namespace AccessControlClass { class AccessRequired { + static ANY: number; static UNKNOWN: number; static MEMBER: number; static ADMINISTRATOR: number; @@ -445,6 +455,10 @@ export declare class AttachmentPointerClass { encoding?: string ) => AttachmentPointerClass; + static Flags: { + VOICE_MESSAGE: number; + }; + cdnId?: ProtoBigNumberType; cdnKey?: string; contentType?: string; @@ -1144,6 +1158,7 @@ export declare class VerifiedClass { data: ArrayBuffer | ByteBufferClass, encoding?: string ) => VerifiedClass; + static State: WhatIsThis; destination?: string; destinationUuid?: string; diff --git a/ts/textsecure/SendMessage.ts b/ts/textsecure/SendMessage.ts index 07c6c4444..c1a5593af 100644 --- a/ts/textsecure/SendMessage.ts +++ b/ts/textsecure/SendMessage.ts @@ -586,7 +586,7 @@ export default class MessageSender { messageProto: DataMessageClass, silent?: boolean, options?: SendOptionsType - ) { + ): Promise { return new Promise((resolve, reject) => { const callback = (result: CallbackResultType) => { if (result && result.errors && result.errors.length > 0) { @@ -615,7 +615,7 @@ export default class MessageSender { timestamp: number, silent?: boolean, options?: SendOptionsType - ) { + ): Promise { return new Promise((resolve, reject) => { const callback = (res: CallbackResultType) => { if (res && res.errors && res.errors.length > 0) { @@ -651,8 +651,8 @@ export default class MessageSender { async sendSyncMessage( encodedDataMessage: ArrayBuffer, timestamp: number, - destination: string, - destinationUuid: string | null, + destination: string | undefined, + destinationUuid: string | null | undefined, expirationStartTimestamp: number | null, sentTo: Array = [], unidentifiedDeliveries: Array = [], @@ -927,11 +927,11 @@ export default class MessageSender { async sendTypingMessage( options: { - recipientId: string; - groupId: string; + recipientId?: string; + groupId?: string; groupMembers: Array; isTyping: boolean; - timestamp: number; + timestamp?: number; }, sendOptions: SendOptionsType = {} ) { @@ -950,9 +950,9 @@ export default class MessageSender { throw new Error('Need to provide either recipientId or groupId!'); } - const recipients = groupId - ? (without(groupMembers, myNumber, myUuid) as Array) - : [recipientId]; + const recipients = (groupId + ? without(groupMembers, myNumber, myUuid) + : [recipientId]) as Array; const groupIdBuffer = groupId ? fromEncodedBinaryToArrayBuffer(groupId) : null; @@ -1035,7 +1035,7 @@ export default class MessageSender { recipientUuid: string, timestamps: Array, options?: SendOptionsType - ) { + ): Promise { const myNumber = window.textsecure.storage.user.getNumber(); const myUuid = window.textsecure.storage.user.getUuid(); const myDevice = window.textsecure.storage.user.getDeviceId(); @@ -1129,7 +1129,7 @@ export default class MessageSender { senderUuid: string, timestamp: number, options?: SendOptionsType - ) { + ): Promise { const myNumber = window.textsecure.storage.user.getNumber(); const myUuid = window.textsecure.storage.user.getUuid(); const myDevice = window.textsecure.storage.user.getDeviceId(); @@ -1207,7 +1207,7 @@ export default class MessageSender { installed: boolean; }>, options?: SendOptionsType - ) { + ): Promise { const myDevice = window.textsecure.storage.user.getDeviceId(); if (myDevice === 1 || myDevice === '1') { return null; @@ -1359,10 +1359,10 @@ export default class MessageSender { async getMessageProto( destination: string, - body: string, - attachments: Array | null, + body: string | undefined, + attachments: Array, quote: any, - preview: Array | null, + preview: Array, sticker: any, reaction: any, timestamp: number, @@ -1402,10 +1402,10 @@ export default class MessageSender { async sendMessageToIdentifier( identifier: string, - messageText: string, - attachments: Array | null, + messageText: string | undefined, + attachments: Array | undefined, quote: any, - preview: Array | null, + preview: Array | undefined, sticker: any, reaction: any, timestamp: number, diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index f7202a1cb..863a9a123 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -419,7 +419,7 @@ async function _promiseAjax( // Build expired! if (response.status === 499) { window.log.error('Error: build expired'); - window.storage.put('remoteBuildExpiration', Date.now()); + await window.storage.put('remoteBuildExpiration', Date.now()); window.reduxActions.expiration.hydrateExpirationStatus(true); } diff --git a/ts/types/Util.ts b/ts/types/Util.ts index 15787a167..3016d9fcd 100644 --- a/ts/types/Util.ts +++ b/ts/types/Util.ts @@ -12,10 +12,10 @@ export type RenderTextCallbackType = (options: { }) => JSX.Element | string; export type ReplacementValuesType = { - [key: string]: string; + [key: string]: string | undefined; }; export type LocalizerType = ( key: string, - values?: Array | ReplacementValuesType + values?: Array | ReplacementValuesType ) => string; diff --git a/ts/util/deleteForEveryone.ts b/ts/util/deleteForEveryone.ts index 4455ba82f..4f2cacf25 100644 --- a/ts/util/deleteForEveryone.ts +++ b/ts/util/deleteForEveryone.ts @@ -1,9 +1,10 @@ -import { DeletesModelType, MessageModelType } from '../model-types.d'; +import { DeletesModelType } from '../model-types.d'; +import { MessageModel } from '../models/messages'; const ONE_DAY = 24 * 60 * 60 * 1000; export async function deleteForEveryone( - message: MessageModelType, + message: MessageModel, doe: DeletesModelType, shouldPersist = true ): Promise { diff --git a/ts/util/lint/linter.ts b/ts/util/lint/linter.ts index 772738174..61967ca11 100644 --- a/ts/util/lint/linter.ts +++ b/ts/util/lint/linter.ts @@ -46,10 +46,14 @@ const excludedFilesRegexps = [ '\\.d\\.ts$', // High-traffic files in our project - '^js/models/messages.js', - '^js/models/conversations.js', - '^js/views/conversation_view.js', - '^js/background.js', + '^ts/models/messages.js', + '^ts/models/messages.ts', + '^ts/models/conversations.js', + '^ts/models/conversations.ts', + '^ts/views/conversation_view.js', + '^ts/views/conversation_view.ts', + '^ts/background.js', + '^ts/background.ts', '^ts/Crypto.js', '^ts/Crypto.ts', '^ts/textsecure/MessageReceiver.js', diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts new file mode 100644 index 000000000..8c3486a1b --- /dev/null +++ b/ts/views/conversation_view.ts @@ -0,0 +1,3297 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +const FIVE_MINUTES = 1000 * 60 * 5; + +window.Whisper = window.Whisper || {}; + +const { Whisper } = window; +const { Message, MIME, VisualAttachment } = window.Signal.Types; + +const { + copyIntoTempDirectory, + deleteDraftFile, + deleteTempFile, + getAbsoluteAttachmentPath, + getAbsoluteDraftPath, + getAbsoluteTempPath, + openFileInFolder, + readAttachmentData, + readDraftData, + saveAttachmentToDisk, + upgradeMessageSchema, + writeNewDraftData, +} = window.Signal.Migrations; + +const { + getOlderMessagesByConversation, + getMessageMetricsForConversation, + getMessageById, + getMessagesBySentAt, + getNewerMessagesByConversation, +} = window.Signal.Data; + +Whisper.ExpiredToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: window.i18n('expiredWarning') }; + }, +}); + +Whisper.BlockedToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: window.i18n('unblockToSend') }; + }, +}); + +Whisper.BlockedGroupToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: window.i18n('unblockGroupToSend') }; + }, +}); + +Whisper.LeftGroupToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: window.i18n('youLeftTheGroup') }; + }, +}); + +Whisper.OriginalNotFoundToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: window.i18n('originalMessageNotFound') }; + }, +}); + +Whisper.OriginalNoLongerAvailableToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: window.i18n('originalMessageNotAvailable') }; + }, +}); + +Whisper.FoundButNotLoadedToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: window.i18n('messageFoundButNotLoaded') }; + }, +}); + +Whisper.VoiceNoteLimit = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: window.i18n('voiceNoteLimit') }; + }, +}); + +Whisper.VoiceNoteMustBeOnlyAttachmentToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: window.i18n('voiceNoteMustBeOnlyAttachment') }; + }, +}); + +Whisper.ConversationArchivedToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: window.i18n('conversationArchived') }; + }, +}); + +Whisper.ConversationUnarchivedToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: window.i18n('conversationReturnedToInbox') }; + }, +}); + +Whisper.TapToViewExpiredIncomingToast = Whisper.ToastView.extend({ + render_attributes() { + return { + toastMessage: window.i18n( + 'Message--tap-to-view--incoming--expired-toast' + ), + }; + }, +}); + +Whisper.TapToViewExpiredOutgoingToast = Whisper.ToastView.extend({ + render_attributes() { + return { + toastMessage: window.i18n( + 'Message--tap-to-view--outgoing--expired-toast' + ), + }; + }, +}); + +Whisper.FileSavedToast = Whisper.ToastView.extend({ + className: 'toast toast-clickable', + initialize(options: any) { + if (!options.fullPath) { + throw new Error('FileSavedToast: name option was not provided!'); + } + this.fullPath = options.fullPath; + this.timeout = 10000; + + if (window.getInteractionMode() === 'keyboard') { + setTimeout(() => { + this.$el.focus(); + }, 1); + } + }, + events: { + click: 'onClick', + keydown: 'onKeydown', + }, + onClick() { + openFileInFolder(this.fullPath); + this.close(); + }, + onKeydown(event: any) { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + openFileInFolder(this.fullPath); + this.close(); + }, + render_attributes() { + return { toastMessage: window.i18n('attachmentSaved') }; + }, +}); + +Whisper.ReactionFailedToast = Whisper.ToastView.extend({ + className: 'toast toast-clickable', + initialize() { + this.timeout = 4000; + + if (window.getInteractionMode() === 'keyboard') { + setTimeout(() => { + this.$el.focus(); + }, 1); + } + }, + events: { + click: 'onClick', + keydown: 'onKeydown', + }, + onClick() { + this.close(); + }, + onKeydown(event: any) { + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + this.close(); + }, + render_attributes() { + return { toastMessage: window.i18n('Reactions--error') }; + }, +}); + +const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; + +Whisper.MessageBodyTooLongToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: window.i18n('messageBodyTooLong') }; + }, +}); + +Whisper.FileSizeToast = Whisper.ToastView.extend({ + templateName: 'file-size-modal', + render_attributes() { + return { + 'file-size-warning': window.i18n('fileSizeWarning'), + limit: this.model.limit, + units: this.model.units, + }; + }, +}); + +Whisper.UnableToLoadToast = Whisper.ToastView.extend({ + render_attributes() { + return { toastMessage: window.i18n('unableToLoadAttachment') }; + }, +}); + +Whisper.DangerousFileTypeToast = Whisper.ToastView.extend({ + template: window.i18n('dangerousFileType'), +}); + +Whisper.OneNonImageAtATimeToast = Whisper.ToastView.extend({ + template: window.i18n('oneNonImageAtATimeToast'), +}); + +Whisper.CannotMixImageAndNonImageAttachmentsToast = Whisper.ToastView.extend({ + template: window.i18n('cannotMixImageAndNonImageAttachments'), +}); + +Whisper.MaxAttachmentsToast = Whisper.ToastView.extend({ + template: window.i18n('maximumAttachments'), +}); + +Whisper.TimerConflictToast = Whisper.ToastView.extend({ + template: window.i18n('GroupV2--timerConflict'), +}); + +Whisper.ConversationLoadingScreen = Whisper.View.extend({ + templateName: 'conversation-loading-screen', + className: 'conversation-loading-screen', +}); + +Whisper.ConversationView = Whisper.View.extend({ + className() { + return ['conversation', this.model.get('type')].join(' '); + }, + id() { + return `conversation-${this.model.cid}`; + }, + template: $('#conversation').html(), + render_attributes() { + return { + 'send-message': window.i18n('sendMessage'), + }; + }, + initialize(options: any) { + // Events on Conversation model + this.listenTo(this.model, 'destroy', this.stopListening); + this.listenTo(this.model, 'change:verified', this.onVerifiedChange); + this.listenTo(this.model, 'newmessage', this.addMessage); + this.listenTo(this.model, 'opened', this.onOpened); + this.listenTo(this.model, 'backgrounded', this.resetEmojiResults); + this.listenTo(this.model, 'scroll-to-message', this.scrollToMessage); + this.listenTo(this.model, 'unload', (reason: any) => + this.unload(`model trigger - ${reason}`) + ); + this.listenTo(this.model, 'focus-composer', this.focusMessageField); + this.listenTo(this.model, 'open-all-media', this.showAllMedia); + this.listenTo(this.model, 'begin-recording', this.captureAudio); + this.listenTo(this.model, 'attach-file', this.onChooseAttachment); + this.listenTo(this.model, 'escape-pressed', this.resetPanel); + this.listenTo(this.model, 'show-message-details', this.showMessageDetail); + this.listenTo(this.model, 'toggle-reply', (messageId: any) => { + const target = this.quote || !messageId ? null : messageId; + this.setQuoteMessage(target); + }); + this.listenTo( + this.model, + 'save-attachment', + this.downloadAttachmentWrapper + ); + this.listenTo(this.model, 'delete-message', this.deleteMessage); + this.listenTo(this.model, 'remove-link-review', this.removeLinkPreview); + this.listenTo( + this.model, + 'remove-all-draft-attachments', + this.clearAttachments + ); + + // Events on Message models - we still listen to these here because they + // can be emitted by the non-reduxified MessageDetail pane + this.listenTo( + this.model.messageCollection, + 'show-identity', + this.showSafetyNumber + ); + this.listenTo(this.model.messageCollection, 'force-send', this.forceSend); + this.listenTo(this.model.messageCollection, 'delete', this.deleteMessage); + this.listenTo( + this.model.messageCollection, + 'show-visual-attachment', + this.showLightbox + ); + this.listenTo( + this.model.messageCollection, + 'display-tap-to-view-message', + this.displayTapToViewMessage + ); + this.listenTo(this.model.messageCollection, 'navigate-to', this.navigateTo); + this.listenTo( + this.model.messageCollection, + 'download-new-version', + this.downloadNewVersion + ); + + this.lazyUpdateVerified = _.debounce( + this.model.updateVerified.bind(this.model), + 1000 // one second + ); + this.model.throttledGetProfiles = + this.model.throttledGetProfiles || + _.throttle(this.model.getProfiles.bind(this.model), FIVE_MINUTES); + this.model.throttledUpdateSharedGroups = + this.model.throttledUpdateSharedGroups || + _.throttle(this.model.updateSharedGroups.bind(this.model), FIVE_MINUTES); + this.model.throttledFetchLatestGroupV2Data = + this.model.throttledFetchLatestGroupV2Data || + _.throttle( + this.model.fetchLatestGroupV2Data.bind(this.model), + FIVE_MINUTES + ); + + this.debouncedMaybeGrabLinkPreview = _.debounce( + this.maybeGrabLinkPreview.bind(this), + 200 + ); + this.debouncedSaveDraft = _.debounce(this.saveDraft.bind(this), 200); + + this.render(); + + this.loadingScreen = new Whisper.ConversationLoadingScreen(); + this.loadingScreen.render(); + this.loadingScreen.$el.prependTo(this.$('.discussion-container')); + + this.window = options.window; + const attachmentListEl = $( + '
' + ); + + this.attachmentListView = new Whisper.ReactWrapperView({ + el: attachmentListEl, + Component: window.Signal.Components.AttachmentList, + props: this.getPropsForAttachmentList(), + }); + + window.extension.windows.onClosed(() => { + this.unload('windows closed'); + }); + + this.setupHeader(); + this.setupTimeline(); + this.setupCompositionArea({ attachmentListEl: attachmentListEl[0] }); + }, + + events: { + 'click .composition-area-placeholder': 'onClickPlaceholder', + 'click .bottom-bar': 'focusMessageField', + 'click .capture-audio .microphone': 'captureAudio', + 'change input.file-input': 'onChoseAttachment', + + dragover: 'onDragOver', + dragleave: 'onDragLeave', + drop: 'onDrop', + paste: 'onPaste', + }, + + getMuteExpirationLabel() { + const muteExpiresAt = this.model.get('muteExpiresAt'); + if (!muteExpiresAt) { + return; + } + + const today = window.moment(Date.now()); + const expires = window.moment(muteExpiresAt); + + if (today.isSame(expires, 'day')) { + // eslint-disable-next-line consistent-return + return expires.format('hh:mm A'); + } + + // eslint-disable-next-line consistent-return + return expires.format('M/D/YY, hh:mm A'); + }, + + setupHeader() { + const getHeaderProps = (_unknown?: unknown) => { + const expireTimer = this.model.get('expireTimer'); + const expirationSettingName = expireTimer + ? Whisper.ExpirationTimerOptions.getName(expireTimer || 0) + : null; + + return { + ...this.model.cachedProps, + + leftGroup: this.model.get('left'), + + disableTimerChanges: + this.model.get('left') || + !this.model.getAccepted() || + !this.model.canChangeTimer(), + showBackButton: Boolean(this.panels && this.panels.length), + + expirationSettingName, + timerOptions: Whisper.ExpirationTimerOptions.map((item: any) => ({ + name: item.getName(), + value: item.get('seconds'), + })), + + muteExpirationLabel: this.getMuteExpirationLabel(), + + onSetDisappearingMessages: (seconds: number) => + this.setDisappearingMessages(seconds), + onDeleteMessages: () => this.destroyMessages(), + onResetSession: () => this.endSession(), + onSearchInConversation: () => { + const { searchInConversation } = window.reduxActions.search; + const name = this.model.isMe() + ? window.i18n('noteToSelf') + : this.model.getTitle(); + searchInConversation(this.model.id, name); + }, + onSetMuteNotifications: (ms: number) => this.setMuteNotifications(ms), + + // These are view only and don't update the Conversation model, so they + // need a manual update call. + onOutgoingAudioCallInConversation: async () => { + window.log.info( + 'onOutgoingAudioCallInConversation: about to start an audio call' + ); + + const conversation = this.model; + const isVideoCall = false; + + if (await this.isCallSafe()) { + window.log.info( + 'onOutgoingAudioCallInConversation: call is deemed "safe". Making call' + ); + await window.Signal.Services.calling.startOutgoingCall( + conversation, + isVideoCall + ); + window.log.info( + 'onOutgoingAudioCallInConversation: started the call' + ); + } else { + window.log.info( + 'onOutgoingAudioCallInConversation: call is deemed "unsafe". Stopping' + ); + } + }, + + onOutgoingVideoCallInConversation: async () => { + window.log.info( + 'onOutgoingVideoCallInConversation: about to start a video call' + ); + const conversation = this.model; + const isVideoCall = true; + + if (await this.isCallSafe()) { + window.log.info( + 'onOutgoingVideoCallInConversation: call is deemed "safe". Making call' + ); + await window.Signal.Services.calling.startOutgoingCall( + conversation, + isVideoCall + ); + window.log.info( + 'onOutgoingVideoCallInConversation: started the call' + ); + } else { + window.log.info( + 'onOutgoingVideoCallInConversation: call is deemed "unsafe". Stopping' + ); + } + }, + + onShowSafetyNumber: () => { + this.showSafetyNumber(); + }, + onShowAllMedia: () => { + this.showAllMedia(); + }, + onShowGroupMembers: async () => { + await this.showMembers(); + this.updateHeader(); + }, + onGoBack: () => { + this.resetPanel(); + }, + + onArchive: () => { + this.model.setArchived(true); + this.model.trigger('unload', 'archive'); + + Whisper.ToastView.show( + Whisper.ConversationArchivedToast, + document.body + ); + }, + onMoveToInbox: () => { + this.model.setArchived(false); + + Whisper.ToastView.show( + Whisper.ConversationUnarchivedToast, + document.body + ); + }, + }; + }; + this.titleView = new Whisper.ReactWrapperView({ + className: 'title-wrapper', + Component: window.Signal.Components.ConversationHeader, + props: getHeaderProps(this.model), + }); + this.updateHeader = () => this.titleView.update(getHeaderProps()); + this.listenTo(this.model, 'change', this.updateHeader); + this.$('.conversation-header').append(this.titleView.el); + }, + + setupCompositionArea({ attachmentListEl }: any) { + const compositionApi = { current: null }; + this.compositionApi = compositionApi; + + const micCellEl = $(` +
+ +
+ `)[0]; + + const messageRequestEnum = + window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; + + const props = { + id: this.model.id, + compositionApi, + onClickAddPack: () => this.showStickerManager(), + onPickSticker: (packId: string, stickerId: number) => + this.sendStickerMessage({ packId, stickerId }), + onSubmit: (message: any) => this.sendMessage(message), + onEditorStateChange: (msg: any, caretLocation: any) => + this.onEditorStateChange(msg, caretLocation), + onTextTooLong: () => this.showToast(Whisper.MessageBodyTooLongToast), + onChooseAttachment: this.onChooseAttachment.bind(this), + getQuotedMessage: () => this.model.get('quotedMessageId'), + clearQuotedMessage: () => this.setQuoteMessage(null), + micCellEl, + attachmentListEl, + onAccept: this.model.syncMessageRequestResponse.bind( + this.model, + messageRequestEnum.ACCEPT + ), + onBlock: this.model.syncMessageRequestResponse.bind( + this.model, + messageRequestEnum.BLOCK + ), + onUnblock: this.model.syncMessageRequestResponse.bind( + this.model, + messageRequestEnum.ACCEPT + ), + onDelete: this.model.syncMessageRequestResponse.bind( + this.model, + messageRequestEnum.DELETE + ), + onBlockAndDelete: this.model.syncMessageRequestResponse.bind( + this.model, + messageRequestEnum.BLOCK_AND_DELETE + ), + }; + + this.compositionAreaView = new Whisper.ReactWrapperView({ + className: 'composition-area-wrapper', + JSX: window.Signal.State.Roots.createCompositionArea( + window.reduxStore, + props + ), + }); + + // Finally, add it to the DOM + this.$('.composition-area-placeholder').append(this.compositionAreaView.el); + }, + + setupTimeline() { + const { id } = this.model; + + const reactToMessage = (messageId: any, reaction: any) => { + this.sendReactionMessage(messageId, reaction); + }; + const replyToMessage = (messageId: any) => { + this.setQuoteMessage(messageId); + }; + const retrySend = (messageId: any) => { + this.retrySend(messageId); + }; + const deleteMessage = (messageId: any) => { + this.deleteMessage(messageId); + }; + const showMessageDetail = (messageId: any) => { + this.showMessageDetail(messageId); + }; + const openConversation = (conversationId: any, messageId: any) => { + this.openConversation(conversationId, messageId); + }; + const showContactDetail = (options: any) => { + this.showContactDetail(options); + }; + const showVisualAttachment = (options: any) => { + this.showLightbox(options); + }; + const downloadAttachment = (options: any) => { + this.downloadAttachment(options); + }; + const displayTapToViewMessage = (messageId: any) => + this.displayTapToViewMessage(messageId); + const showIdentity = (conversationId: any) => { + this.showSafetyNumber(conversationId); + }; + const openLink = (url: any) => { + this.navigateTo(url); + }; + const downloadNewVersion = () => { + this.downloadNewVersion(); + }; + const showExpiredIncomingTapToViewToast = () => { + this.showToast(Whisper.TapToViewExpiredIncomingToast); + }; + const showExpiredOutgoingTapToViewToast = () => { + this.showToast(Whisper.TapToViewExpiredOutgoingToast); + }; + + const scrollToQuotedMessage = async (options: any) => { + const { author, sentAt } = options; + + const conversationId = this.model.id; + const messages = await getMessagesBySentAt(sentAt, { + MessageCollection: Whisper.MessageCollection, + }); + const message = messages.find( + item => + item.get('conversationId') === conversationId && + item.getSource() === author + ); + + if (!message) { + this.showToast(Whisper.OriginalNotFoundToast); + return; + } + + this.scrollToMessage(message.id); + }; + + const loadOlderMessages = async (oldestMessageId: any) => { + const { + messagesAdded, + setMessagesLoading, + } = window.reduxActions.conversations; + const conversationId = this.model.id; + + setMessagesLoading(conversationId, true); + const finish = this.setInProgressFetch(); + + try { + const message = await getMessageById(oldestMessageId, { + Message: Whisper.Message, + }); + if (!message) { + throw new Error( + `loadOlderMessages: failed to load message ${oldestMessageId}` + ); + } + + const receivedAt = message.get('received_at'); + const models = await getOlderMessagesByConversation(conversationId, { + receivedAt, + messageId: oldestMessageId, + limit: 500, + MessageCollection: Whisper.MessageCollection, + }); + + if (models.length < 1) { + window.log.warn( + 'loadOlderMessages: requested, but loaded no messages' + ); + return; + } + + const cleaned = await this.cleanModels(models); + this.model.messageCollection.add(cleaned); + + const isNewMessage = false; + messagesAdded( + id, + models.map(model => model.getReduxData()), + isNewMessage, + window.isActive() + ); + } catch (error) { + setMessagesLoading(conversationId, true); + throw error; + } finally { + finish(); + } + }; + const loadNewerMessages = async (newestMessageId: any) => { + const { + messagesAdded, + setMessagesLoading, + } = window.reduxActions.conversations; + const conversationId = this.model.id; + + setMessagesLoading(conversationId, true); + const finish = this.setInProgressFetch(); + + try { + const message = await getMessageById(newestMessageId, { + Message: Whisper.Message, + }); + if (!message) { + throw new Error( + `loadNewerMessages: failed to load message ${newestMessageId}` + ); + } + + const receivedAt = message.get('received_at'); + const models = await getNewerMessagesByConversation(this.model.id, { + receivedAt, + limit: 500, + MessageCollection: Whisper.MessageCollection, + }); + + if (models.length < 1) { + window.log.warn( + 'loadNewerMessages: requested, but loaded no messages' + ); + return; + } + + const cleaned = await this.cleanModels(models); + this.model.messageCollection.add(cleaned); + + const isNewMessage = false; + messagesAdded( + id, + models.map(model => model.getReduxData()), + isNewMessage, + window.isActive() + ); + } catch (error) { + setMessagesLoading(conversationId, false); + throw error; + } finally { + finish(); + } + }; + const markMessageRead = async (messageId: any) => { + if (!window.isActive()) { + return; + } + + const message = await getMessageById(messageId, { + Message: Whisper.Message, + }); + if (!message) { + throw new Error(`markMessageRead: failed to load message ${messageId}`); + } + + await this.model.markRead(message.get('received_at')); + }; + + this.timelineView = new Whisper.ReactWrapperView({ + className: 'timeline-wrapper', + JSX: window.Signal.State.Roots.createTimeline(window.reduxStore, { + id, + + deleteMessage, + displayTapToViewMessage, + downloadAttachment, + downloadNewVersion, + loadNewerMessages, + loadNewestMessages: this.loadNewestMessages.bind(this), + loadAndScroll: this.loadAndScroll.bind(this), + loadOlderMessages, + markMessageRead, + openConversation, + openLink, + reactToMessage, + replyToMessage, + retrySend, + scrollToQuotedMessage, + showContactDetail, + showIdentity, + showMessageDetail, + showVisualAttachment, + showExpiredIncomingTapToViewToast, + showExpiredOutgoingTapToViewToast, + updateSharedGroups: this.model.throttledUpdateSharedGroups, + }), + }); + + this.$('.timeline-placeholder').append(this.timelineView.el); + }, + + showToast(ToastView: any, options: any) { + const toast = new ToastView(options); + + const lightboxEl = $('.module-lightbox'); + if (lightboxEl.length > 0) { + toast.$el.appendTo(lightboxEl); + } else { + toast.$el.appendTo(this.$el); + } + + toast.render(); + }, + + async cleanModels(collection: any) { + const result = collection + .filter((message: any) => Boolean(message.id)) + .map((message: any) => + window.MessageController.register(message.id, message) + ); + + const eliminated = collection.length - result.length; + if (eliminated > 0) { + window.log.warn( + `cleanModels: Eliminated ${eliminated} messages without an id` + ); + } + + for (let max = result.length, i = 0; i < max; i += 1) { + const message = result[i]; + const { attributes } = message; + const { schemaVersion } = attributes; + + if (schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY) { + // Yep, we really do want to wait for each of these + // eslint-disable-next-line no-await-in-loop + const upgradedMessage = await upgradeMessageSchema(attributes); + message.set(upgradedMessage); + // eslint-disable-next-line no-await-in-loop + await window.Signal.Data.saveMessage(upgradedMessage, { + Message: Whisper.Message, + }); + } + } + + return result; + }, + + async scrollToMessage(messageId: any) { + const message = await getMessageById(messageId, { + Message: Whisper.Message, + }); + if (!message) { + throw new Error(`scrollToMessage: failed to load message ${messageId}`); + } + + if (this.model.messageCollection.get(messageId)) { + const { scrollToMessage } = window.reduxActions.conversations; + scrollToMessage(this.model.id, messageId); + return; + } + + this.loadAndScroll(messageId); + }, + + setInProgressFetch() { + let resolvePromise: any; + this.model.inProgressFetch = new Promise(resolve => { + resolvePromise = resolve; + }); + + const finish = () => { + resolvePromise(); + this.model.inProgressFinish = null; + }; + + return finish; + }, + + async loadAndScroll(messageId: any, options: any) { + const { disableScroll } = options || {}; + const { + messagesReset, + setMessagesLoading, + } = window.reduxActions.conversations; + const conversationId = this.model.id; + + setMessagesLoading(conversationId, true); + const finish = this.setInProgressFetch(); + + try { + const message = await getMessageById(messageId, { + Message: Whisper.Message, + }); + if (!message) { + throw new Error( + `loadMoreAndScroll: failed to load message ${messageId}` + ); + } + + const receivedAt = message.get('received_at'); + const older = await getOlderMessagesByConversation(conversationId, { + limit: 250, + receivedAt, + messageId, + MessageCollection: Whisper.MessageCollection, + }); + const newer = await getNewerMessagesByConversation(conversationId, { + limit: 250, + receivedAt, + MessageCollection: Whisper.MessageCollection, + }); + const metrics = await getMessageMetricsForConversation(conversationId); + + const all = [...older.models, message, ...newer.models]; + + const cleaned = await this.cleanModels(all); + this.model.messageCollection.reset(cleaned); + const scrollToMessageId = disableScroll ? undefined : messageId; + + messagesReset( + conversationId, + cleaned.map((model: any) => model.getReduxData()), + metrics, + scrollToMessageId + ); + } catch (error) { + setMessagesLoading(conversationId, false); + throw error; + } finally { + finish(); + } + }, + + async loadNewestMessages(newestMessageId: any, setFocus: any) { + const { + messagesReset, + setMessagesLoading, + } = window.reduxActions.conversations; + const conversationId = this.model.id; + + setMessagesLoading(conversationId, true); + const finish = this.setInProgressFetch(); + + try { + let scrollToLatestUnread = true; + + if (newestMessageId) { + const newestInMemoryMessage = await getMessageById(newestMessageId, { + Message: Whisper.Message, + }); + if (!newestInMemoryMessage) { + window.log.warn( + `loadNewestMessages: did not find message ${newestMessageId}` + ); + } + + // If newest in-memory message is unread, scrolling down would mean going to + // the very bottom, not the oldest unread. + if (newestInMemoryMessage.isUnread()) { + scrollToLatestUnread = false; + } + } + + const metrics = await getMessageMetricsForConversation(conversationId); + + if (scrollToLatestUnread && metrics.oldestUnread) { + this.loadAndScroll(metrics.oldestUnread.id, { + disableScroll: !setFocus, + }); + return; + } + + const messages = await getOlderMessagesByConversation(conversationId, { + limit: 500, + MessageCollection: Whisper.MessageCollection, + }); + + const cleaned = await this.cleanModels(messages); + this.model.messageCollection.reset(cleaned); + const scrollToMessageId = + setFocus && metrics.newest ? metrics.newest.id : undefined; + + // Because our `getOlderMessages` fetch above didn't specify a receivedAt, we got + // the most recent 500 messages in the conversation. If it has a conflict with + // metrics, fetched a bit before, that's likely a race condition. So we tell our + // reducer to trust the message set we just fetched for determining if we have + // the newest message loaded. + const unboundedFetch = true; + messagesReset( + conversationId, + cleaned.map((model: any) => model.getReduxData()), + metrics, + scrollToMessageId, + unboundedFetch + ); + } catch (error) { + setMessagesLoading(conversationId, false); + throw error; + } finally { + finish(); + } + }, + + // We need this, or clicking the reactified buttons will submit the form and send any + // mid-composition message content. + onClickPlaceholder(e: any) { + e.preventDefault(); + }, + + onChooseAttachment() { + this.$('input.file-input').click(); + }, + async onChoseAttachment() { + const fileField = this.$('input.file-input'); + const files = fileField.prop('files'); + + for (let i = 0, max = files.length; i < max; i += 1) { + const file = files[i]; + // eslint-disable-next-line no-await-in-loop + await this.maybeAddAttachment(file); + this.toggleMicrophone(); + } + + fileField.val(null); + }, + + unload(reason: any) { + window.log.info( + 'unloading conversation', + this.model.idForLogging(), + 'due to:', + reason + ); + + const { conversationUnloaded } = window.reduxActions.conversations; + if (conversationUnloaded) { + conversationUnloaded(this.model.id); + } + + if (this.model.get('draftChanged')) { + if (this.model.hasDraft()) { + this.model.set({ + draftChanged: false, + draftTimestamp: Date.now(), + timestamp: Date.now(), + }); + } else { + this.model.set({ + draftChanged: false, + draftTimestamp: null, + }); + } + + // We don't wait here; we need to take down the view + this.saveModel(); + + this.model.updateLastMessage(); + } + + this.titleView.remove(); + this.timelineView.remove(); + this.compositionAreaView.remove(); + + if (this.attachmentListView) { + this.attachmentListView.remove(); + } + if (this.captionEditorView) { + this.captionEditorView.remove(); + } + if (this.stickerButtonView) { + this.stickerButtonView.remove(); + } + if (this.stickerPreviewModalView) { + this.stickerPreviewModalView.remove(); + } + if (this.captureAudioView) { + this.captureAudioView.remove(); + } + if (this.banner) { + this.banner.remove(); + } + if (this.lastSeenIndicator) { + this.lastSeenIndicator.remove(); + } + if (this.scrollDownButton) { + this.scrollDownButton.remove(); + } + if (this.quoteView) { + this.quoteView.remove(); + } + if (this.lightboxView) { + this.lightboxView.remove(); + } + if (this.lightboxGalleryView) { + this.lightboxGalleryView.remove(); + } + if (this.panels && this.panels.length) { + for (let i = 0, max = this.panels.length; i < max; i += 1) { + const panel = this.panels[i]; + panel.remove(); + } + } + + this.remove(); + + this.model.messageCollection.reset([]); + }, + + navigateTo(url: any) { + window.location = url; + }, + + downloadNewVersion() { + (window as any).location = 'https://signal.org/download'; + }, + + onDragOver(e: any) { + if (e.originalEvent.dataTransfer.types[0] !== 'Files') { + return; + } + + e.stopPropagation(); + e.preventDefault(); + this.$el.addClass('dropoff'); + }, + + onDragLeave(e: any) { + if (e.originalEvent.dataTransfer.types[0] !== 'Files') { + return; + } + + e.stopPropagation(); + e.preventDefault(); + }, + + async onDrop(e: any) { + if (e.originalEvent.dataTransfer.types[0] !== 'Files') { + return; + } + + e.stopPropagation(); + e.preventDefault(); + + const { files } = e.originalEvent.dataTransfer; + for (let i = 0, max = files.length; i < max; i += 1) { + const file = files[i]; + // eslint-disable-next-line no-await-in-loop + await this.maybeAddAttachment(file); + } + }, + + onPaste(e: any) { + const { items } = e.originalEvent.clipboardData; + let imgBlob = null; + for (let i = 0; i < items.length; i += 1) { + if (items[i].type.split('/')[0] === 'image') { + imgBlob = items[i].getAsFile(); + } + } + if (imgBlob !== null) { + const file = imgBlob; + this.maybeAddAttachment(file); + + e.stopPropagation(); + e.preventDefault(); + } + }, + + getPropsForAttachmentList() { + const draftAttachments = this.model.get('draftAttachments') || []; + + return { + // In conversation model/redux + attachments: draftAttachments.map((attachment: any) => ({ + ...attachment, + url: attachment.screenshotPath + ? getAbsoluteDraftPath(attachment.screenshotPath) + : getAbsoluteDraftPath(attachment.path), + })), + // Passed in from ConversationView + onAddAttachment: this.onChooseAttachment.bind(this), + onClickAttachment: this.onClickAttachment.bind(this), + onCloseAttachment: this.onCloseAttachment.bind(this), + onClose: this.clearAttachments.bind(this), + }; + }, + + onClickAttachment(attachment: any) { + const getProps = () => ({ + url: attachment.url, + caption: attachment.caption, + attachment, + onSave, + }); + + const onSave = (caption: any) => { + this.model.set({ + draftAttachments: this.model + .get('draftAttachments') + .map((item: any) => { + if ( + (item.path && item.path === attachment.path) || + (item.screenshotPath && + item.screenshotPath === attachment.screenshotPath) + ) { + return { + ...attachment, + caption, + }; + } + + return item; + }), + draftChanged: true, + }); + + this.captionEditorView.remove(); + window.Signal.Backbone.Views.Lightbox.hide(); + + this.updateAttachmentsView(); + this.saveModel(); + }; + + this.captionEditorView = new Whisper.ReactWrapperView({ + className: 'attachment-list-wrapper', + Component: window.Signal.Components.CaptionEditor, + props: getProps(), + onClose: () => window.Signal.Backbone.Views.Lightbox.hide(), + }); + window.Signal.Backbone.Views.Lightbox.show(this.captionEditorView.el); + }, + + async deleteDraftAttachment(attachment: any) { + if (attachment.screenshotPath) { + await deleteDraftFile(attachment.screenshotPath); + } + if (attachment.path) { + await deleteDraftFile(attachment.path); + } + }, + + async saveModel() { + window.Signal.Data.updateConversation(this.model.attributes); + }, + + async addAttachment(attachment: any) { + const onDisk = await this.writeDraftAttachment(attachment); + + const draftAttachments = this.model.get('draftAttachments') || []; + this.model.set({ + draftAttachments: [...draftAttachments, onDisk], + draftChanged: true, + }); + await this.saveModel(); + + this.updateAttachmentsView(); + }, + + async onCloseAttachment(attachment: any) { + const draftAttachments = this.model.get('draftAttachments') || []; + + this.model.set({ + draftAttachments: _.reject( + draftAttachments, + item => item.path === attachment.path + ), + draftChanged: true, + }); + + this.updateAttachmentsView(); + + await this.saveModel(); + await this.deleteDraftAttachment(attachment); + }, + + async clearAttachments() { + this.voiceNoteAttachment = null; + + const draftAttachments = this.model.get('draftAttachments') || []; + this.model.set({ + draftAttachments: [], + draftChanged: true, + }); + + this.updateAttachmentsView(); + + // We're fine doing this all at once; at most it should be 32 attachments + await Promise.all([ + this.saveModel(), + Promise.all( + draftAttachments.map((attachment: any) => + this.deleteDraftAttachment(attachment) + ) + ), + ]); + }, + + hasFiles() { + const draftAttachments = this.model.get('draftAttachments') || []; + return draftAttachments.length > 0; + }, + + async getFiles() { + if (this.voiceNoteAttachment) { + // We don't need to pull these off disk; we return them as-is + return [this.voiceNoteAttachment]; + } + + const draftAttachments = this.model.get('draftAttachments') || []; + const files = _.compact( + await Promise.all( + draftAttachments.map((attachment: any) => this.getFile(attachment)) + ) + ); + return files; + }, + + async getFile(attachment: any) { + if (!attachment) { + return Promise.resolve(); + } + + const data = await readDraftData(attachment.path); + if (data.byteLength !== attachment.size) { + window.log.error( + `Attachment size from disk ${data.byteLength} did not match attachment size ${attachment.size}` + ); + return null; + } + + return { + ..._.pick(attachment, [ + 'contentType', + 'fileName', + 'size', + 'caption', + 'blurHash', + ]), + data, + }; + }, + + arrayBufferFromFile(file: any) { + return new Promise((resolve, reject) => { + const FR = new FileReader(); + FR.onload = (e: any) => { + resolve(e.target.result); + }; + FR.onerror = reject; + FR.onabort = reject; + FR.readAsArrayBuffer(file); + }); + }, + + showFileSizeError({ limit, units, u }: any) { + const toast = new Whisper.FileSizeToast({ + model: { limit, units: units[u] }, + }); + toast.$el.insertAfter(this.$el); + toast.render(); + }, + + updateAttachmentsView() { + this.attachmentListView.update(this.getPropsForAttachmentList()); + this.toggleMicrophone(); + if (this.hasFiles()) { + this.removeLinkPreview(); + } + }, + + async writeDraftAttachment(attachment: any) { + let toWrite = attachment; + + if (toWrite.data) { + const path = await writeNewDraftData(toWrite.data); + toWrite = { + ..._.omit(toWrite, ['data']), + path, + }; + } + if (toWrite.screenshotData) { + const screenshotPath = await writeNewDraftData(toWrite.screenshotData); + toWrite = { + ..._.omit(toWrite, ['screenshotData']), + screenshotPath, + }; + } + + return toWrite; + }, + + async maybeAddAttachment(file: any) { + if (!file) { + return; + } + + const MB = 1000 * 1024; + if (file.size > 100 * MB) { + this.showFileSizeError({ limit: 100, units: ['MB'], u: 0 }); + return; + } + + if (window.Signal.Util.isFileDangerous(file.name)) { + this.showToast(Whisper.DangerousFileTypeToast); + return; + } + + const draftAttachments = this.model.get('draftAttachments') || []; + if (draftAttachments.length >= 32) { + this.showToast(Whisper.MaxAttachmentsToast); + return; + } + + const haveNonImage = window._.any( + draftAttachments, + (attachment: any) => !MIME.isImage(attachment.contentType) + ); + // You can't add another attachment if you already have a non-image staged + if (haveNonImage) { + this.showToast(Whisper.OneNonImageAtATimeToast); + return; + } + + // You can't add a non-image attachment if you already have attachments staged + if (!MIME.isImage(file.type) && draftAttachments.length > 0) { + this.showToast(Whisper.CannotMixImageAndNonImageAttachmentsToast); + return; + } + + let attachment; + + try { + if (window.Signal.Util.GoogleChrome.isImageTypeSupported(file.type)) { + attachment = await this.handleImageAttachment(file); + } else if ( + window.Signal.Util.GoogleChrome.isVideoTypeSupported(file.type) + ) { + attachment = await this.handleVideoAttachment(file); + } else { + const data = await this.arrayBufferFromFile(file); + attachment = { + data, + size: data.byteLength, + contentType: file.type, + fileName: file.name, + }; + } + } catch (e) { + window.log.error( + `Was unable to generate thumbnail for file type ${file.type}`, + e && e.stack ? e.stack : e + ); + const data = await this.arrayBufferFromFile(file); + attachment = { + data, + size: data.byteLength, + contentType: file.type, + fileName: file.name, + }; + } + + try { + if (!this.isSizeOkay(attachment)) { + return; + } + } catch (error) { + window.log.error( + 'Error ensuring that image is properly sized:', + error && error.stack ? error.stack : error + ); + + this.showToast(Whisper.UnableToLoadToast); + return; + } + + await this.addAttachment(attachment); + }, + + isSizeOkay(attachment: any) { + let limitKb = 1000000; + const type = + attachment.contentType === 'image/gif' + ? 'gif' + : attachment.contentType.split('/')[0]; + + switch (type) { + case 'image': + limitKb = 6000; + break; + case 'gif': + limitKb = 25000; + break; + case 'audio': + limitKb = 100000; + break; + case 'video': + limitKb = 100000; + break; + default: + limitKb = 100000; + break; + } + // this needs to be cast properly + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + if ((attachment.data.byteLength / 1024).toFixed(4) >= limitKb) { + const units = ['kB', 'MB', 'GB']; + let u = -1; + let limit = limitKb * 1000; + do { + limit /= 1000; + u += 1; + } while (limit >= 1000 && u < units.length - 1); + this.showFileSizeError({ limit, units, u }); + return false; + } + + return true; + }, + + async handleVideoAttachment(file: any) { + const objectUrl = URL.createObjectURL(file); + if (!objectUrl) { + throw new Error('Failed to create object url for video!'); + } + try { + const screenshotContentType = 'image/png'; + const screenshotBlob = await VisualAttachment.makeVideoScreenshot({ + objectUrl, + contentType: screenshotContentType, + logger: window.log, + }); + const screenshotData = await VisualAttachment.blobToArrayBuffer( + screenshotBlob + ); + const data = await this.arrayBufferFromFile(file); + + return { + fileName: file.name, + screenshotContentType, + screenshotData, + screenshotSize: screenshotData.byteLength, + contentType: file.type, + data, + size: data.byteLength, + }; + } finally { + URL.revokeObjectURL(objectUrl); + } + }, + + async handleImageAttachment(file: any) { + const blurHash = await window.imageToBlurHash(file); + if (MIME.isJPEG(file.type)) { + const rotatedDataUrl = await window.autoOrientImage(file); + const rotatedBlob = window.dataURLToBlobSync(rotatedDataUrl); + const { contentType, file: resizedBlob, fileName } = await this.autoScale( + { + contentType: file.type, + fileName: file.name, + file: rotatedBlob, + } + ); + const data = await await VisualAttachment.blobToArrayBuffer(resizedBlob); + + return { + fileName: fileName || file.name, + contentType, + data, + size: data.byteLength, + blurHash, + }; + } + + const { contentType, file: resizedBlob, fileName } = await this.autoScale({ + contentType: file.type, + fileName: file.name, + file, + }); + const data = await await VisualAttachment.blobToArrayBuffer(resizedBlob); + return { + fileName: fileName || file.name, + contentType, + data, + size: data.byteLength, + blurHash, + }; + }, + + autoScale(attachment: any) { + const { contentType, file, fileName } = attachment; + if (contentType.split('/')[0] !== 'image' || contentType === 'image/tiff') { + // nothing to do + return Promise.resolve(attachment); + } + + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file); + const img = document.createElement('img'); + img.onerror = reject; + img.onload = () => { + URL.revokeObjectURL(url); + + const maxSize = 6000 * 1024; + const maxHeight = 4096; + const maxWidth = 4096; + if ( + img.naturalWidth <= maxWidth && + img.naturalHeight <= maxHeight && + file.size <= maxSize + ) { + resolve(attachment); + return; + } + + const gifMaxSize = 25000 * 1024; + if (file.type === 'image/gif' && file.size <= gifMaxSize) { + resolve(attachment); + return; + } + + if (file.type === 'image/gif') { + reject(new Error('GIF is too large')); + return; + } + + const targetContentType = 'image/jpeg'; + const canvas = window.loadImage.scale(img, { + canvas: true, + maxWidth, + maxHeight, + }); + + let quality = 0.95; + let i = 4; + let blob; + do { + i -= 1; + blob = window.dataURLToBlobSync( + canvas.toDataURL(targetContentType, quality) + ); + quality = (quality * maxSize) / blob.size; + // NOTE: During testing with a large image, we observed the + // `quality` value being > 1. Should we clamp it to [0.5, 1.0]? + // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLCanvasElement/toBlob#Syntax + if (quality < 0.5) { + quality = 0.5; + } + } while (i > 0 && blob.size > maxSize); + + resolve({ + ...attachment, + fileName: this.fixExtension(fileName, targetContentType), + contentType: targetContentType, + file: blob, + }); + }; + img.src = url; + }); + }, + + getFileName(fileName: any) { + if (!fileName) { + return ''; + } + + if (!fileName.includes('.')) { + return fileName; + } + + return fileName + .split('.') + .slice(0, -1) + .join('.'); + }, + + getType(contentType: any) { + if (!contentType) { + return ''; + } + + if (!contentType.includes('/')) { + return contentType; + } + + return contentType.split('/')[1]; + }, + + fixExtension(fileName: any, contentType: any) { + const extension = this.getType(contentType); + const name = this.getFileName(fileName); + return `${name}.${extension}`; + }, + + markAllAsVerifiedDefault(unverified: any) { + return Promise.all( + unverified.map((contact: any) => { + if (contact.isUnverified()) { + return contact.setVerifiedDefault(); + } + + return null; + }) + ); + }, + + markAllAsApproved(untrusted: any) { + return Promise.all(untrusted.map((contact: any) => contact.setApproved())); + }, + + openSafetyNumberScreens(unverified: any) { + if (unverified.length === 1) { + this.showSafetyNumber(unverified.at(0).id); + return; + } + + this.showMembers(null, unverified, { needVerify: true }); + }, + + onVerifiedChange() { + if (this.model.isUnverified()) { + const unverified = this.model.getUnverified(); + let message; + if (!unverified.length) { + return; + } + if (unverified.length > 1) { + message = window.i18n('multipleNoLongerVerified'); + } else { + message = window.i18n('noLongerVerified', [ + unverified.at(0).getTitle(), + ]); + } + + // Need to re-add, since unverified set may have changed + if (this.banner) { + this.banner.remove(); + this.banner = null; + } + + this.banner = new Whisper.BannerView({ + message, + onDismiss: () => { + this.markAllAsVerifiedDefault(unverified); + }, + onClick: () => { + this.openSafetyNumberScreens(unverified); + }, + }); + + const container = this.$('.discussion-container'); + container.append(this.banner.el); + } else if (this.banner) { + this.banner.remove(); + this.banner = null; + } + }, + + toggleMicrophone() { + this.compositionApi.current.setShowMic(!this.hasFiles()); + }, + + captureAudio(e: any) { + if (e) { + e.preventDefault(); + } + + if (this.compositionApi.current.isDirty()) { + return; + } + + if (this.hasFiles()) { + this.showToast(Whisper.VoiceNoteMustBeOnlyAttachmentToast); + return; + } + + this.showToast(Whisper.VoiceNoteLimit); + + // Note - clicking anywhere will close the audio capture panel, due to + // the onClick handler in InboxView, which calls its closeRecording method. + + if (this.captureAudioView) { + this.captureAudioView.remove(); + this.captureAudioView = null; + } + + this.captureAudioView = new Whisper.RecorderView(); + + const view = this.captureAudioView; + view.render(); + view.on('send', this.handleAudioCapture.bind(this)); + view.on('confirm', this.handleAudioConfirm.bind(this)); + view.on('closed', this.endCaptureAudio.bind(this)); + view.$el.appendTo(this.$('.capture-audio')); + view.$('.finish').focus(); + this.compositionApi.current.setMicActive(true); + + this.disableMessageField(); + this.$('.microphone').hide(); + }, + handleAudioConfirm(blob: any, lostFocus: any) { + const dialog = new Whisper.ConfirmationDialogView({ + cancelText: window.i18n('discard'), + message: lostFocus + ? window.i18n('voiceRecordingInterruptedBlur') + : window.i18n('voiceRecordingInterruptedMax'), + okText: window.i18n('sendAnyway'), + resolve: async () => { + await this.handleAudioCapture(blob); + }, + }); + + this.$el.prepend(dialog.el); + dialog.focusCancel(); + }, + async handleAudioCapture(blob: any) { + if (this.hasFiles()) { + throw new Error('A voice note cannot be sent with other attachments'); + } + + const data = await this.arrayBufferFromFile(blob); + + // These aren't persisted to disk; they are meant to be sent immediately + this.voiceNoteAttachment = { + contentType: blob.type, + data, + size: data.byteLength, + flags: window.textsecure.protobuf.AttachmentPointer.Flags.VOICE_MESSAGE, + }; + + // Note: The RecorderView removes itself on send + this.captureAudioView = null; + + this.sendMessage(); + }, + endCaptureAudio() { + this.enableMessageField(); + this.$('.microphone').show(); + + // Note: The RecorderView removes itself on close + this.captureAudioView = null; + + this.compositionApi.current.setMicActive(false); + }, + + async onOpened(messageId: any) { + if (messageId) { + const message = await getMessageById(messageId, { + Message: Whisper.Message, + }); + + if (message) { + this.loadAndScroll(messageId); + return; + } + + window.log.warn(`onOpened: Did not find message ${messageId}`); + } + + this.loadNewestMessages(); + this.model.updateLastMessage(); + + this.focusMessageField(); + + const quotedMessageId = this.model.get('quotedMessageId'); + if (quotedMessageId) { + this.setQuoteMessage(quotedMessageId); + } + + this.model.throttledFetchLatestGroupV2Data(); + + const statusPromise = this.model.throttledGetProfiles(); + // eslint-disable-next-line more/no-then + this.statusFetch = statusPromise.then(() => + // eslint-disable-next-line more/no-then + this.model.updateVerified().then(() => { + this.onVerifiedChange(); + this.statusFetch = null; + }) + ); + }, + + async retrySend(messageId: any) { + const message = this.model.messageCollection.get(messageId); + if (!message) { + throw new Error(`retrySend: Did not find message for id ${messageId}`); + } + await message.retrySend(); + }, + + async showAllMedia() { + if (this.panels && this.panels.length > 0) { + return; + } + + // We fetch more documents than media as they don’t require to be loaded + // into memory right away. Revisit this once we have infinite scrolling: + const DEFAULT_MEDIA_FETCH_COUNT = 50; + const DEFAULT_DOCUMENTS_FETCH_COUNT = 150; + + const conversationId = this.model.get('id'); + + const getProps = async () => { + const rawMedia = await window.Signal.Data.getMessagesWithVisualMediaAttachments( + conversationId, + { + limit: DEFAULT_MEDIA_FETCH_COUNT, + } + ); + const rawDocuments = await window.Signal.Data.getMessagesWithFileAttachments( + conversationId, + { + limit: DEFAULT_DOCUMENTS_FETCH_COUNT, + } + ); + + // First we upgrade these messages to ensure that they have thumbnails + for (let max = rawMedia.length, i = 0; i < max; i += 1) { + const message = rawMedia[i]; + const { schemaVersion } = message; + + if (schemaVersion < Message.VERSION_NEEDED_FOR_DISPLAY) { + // Yep, we really do want to wait for each of these + // eslint-disable-next-line no-await-in-loop + rawMedia[i] = await upgradeMessageSchema(message); + // eslint-disable-next-line no-await-in-loop + await window.Signal.Data.saveMessage(rawMedia[i], { + Message: Whisper.Message, + }); + } + } + + const media = window._.flatten( + rawMedia.map(message => { + const { attachments } = message; + return (attachments || []) + .filter( + (attachment: any) => + attachment.thumbnail && !attachment.pending && !attachment.error + ) + .map((attachment: any, index: any) => { + const { thumbnail } = attachment; + + return { + objectURL: getAbsoluteAttachmentPath(attachment.path), + thumbnailObjectUrl: thumbnail + ? getAbsoluteAttachmentPath(thumbnail.path) + : null, + contentType: attachment.contentType, + index, + attachment, + message, + }; + }); + }) + ); + + // Unlike visual media, only one non-image attachment is supported + const documents = rawDocuments + .filter(message => + Boolean(message.attachments && message.attachments.length) + ) + .map(message => { + const attachments = message.attachments || []; + const attachment = attachments[0]; + return { + contentType: attachment.contentType, + index: 0, + attachment, + message, + }; + }); + + const saveAttachment = async ({ attachment, message }: any = {}) => { + const timestamp = message.sent_at; + const fullPath = await window.Signal.Types.Attachment.save({ + attachment, + readAttachmentData, + saveAttachmentToDisk, + timestamp, + }); + + if (fullPath) { + this.showToast(Whisper.FileSavedToast, { fullPath }); + } + }; + + const onItemClick = async ({ message, attachment, type }: any) => { + switch (type) { + case 'documents': { + saveAttachment({ message, attachment }); + break; + } + + case 'media': { + const selectedIndex = media.findIndex( + mediaMessage => mediaMessage.attachment.path === attachment.path + ); + this.lightboxGalleryView = new Whisper.ReactWrapperView({ + className: 'lightbox-wrapper', + Component: window.Signal.Components.LightboxGallery, + props: { + media, + onSave: saveAttachment, + selectedIndex, + }, + onClose: () => window.Signal.Backbone.Views.Lightbox.hide(), + }); + window.Signal.Backbone.Views.Lightbox.show( + this.lightboxGalleryView.el + ); + break; + } + + default: + throw new TypeError(`Unknown attachment type: '${type}'`); + } + }; + + return { + documents, + media, + onItemClick, + }; + }; + + const view = new Whisper.ReactWrapperView({ + className: 'panel', + Component: window.Signal.Components.MediaGallery, + props: await getProps(), + onClose: () => { + this.stopListening(this.model.messageCollection, 'remove', update); + }, + }); + + const update = async () => { + view.update(await getProps()); + }; + + this.listenTo(this.model.messageCollection, 'remove', update); + + this.listenBack(view); + this.updateHeader(); + }, + + focusMessageField() { + if (this.panels && this.panels.length) { + return; + } + + const { compositionApi } = this; + + if (compositionApi && compositionApi.current) { + compositionApi.current.focusInput(); + } + }, + + focusMessageFieldAndClearDisabled() { + this.compositionApi.current.setDisabled(false); + this.focusMessageField(); + }, + + disableMessageField() { + this.compositionApi.current.setDisabled(true); + }, + + enableMessageField() { + this.compositionApi.current.setDisabled(false); + }, + + resetEmojiResults() { + this.compositionApi.current.resetEmojiResults(false); + }, + + async addMessage(message: any) { + // This is debounced, so it won't hit the database too often. + this.lazyUpdateVerified(); + + // We do this here because we don't want convo.messageCollection to have + // anything in it unless it has an associated view. This is so, when we + // fetch on open, it's clean. + this.model.addIncomingMessage(message); + }, + + async showMembers(_e: any, providedMembers: any, options: any = {}) { + window._.defaults(options, { needVerify: false }); + + let model = providedMembers || this.model.contactCollection; + + if (!providedMembers && this.model.get('groupVersion') === 2) { + model = new Whisper.GroupConversationCollection( + this.model.get('membersV2').map(({ conversationId, role }: any) => ({ + conversation: window.ConversationController.get(conversationId), + isAdmin: + role === window.textsecure.protobuf.Member.Role.ADMINISTRATOR, + })) + ); + } + + const view = new Whisper.GroupMemberList({ + model, + // we pass this in to allow nested panels + listenBack: this.listenBack.bind(this), + needVerify: options.needVerify, + }); + + this.listenBack(view); + }, + + forceSend({ contactId, messageId }: any) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const contact = window.ConversationController.get(contactId)!; + const message = this.model.messageCollection.get(messageId); + if (!message) { + throw new Error(`forceSend: Did not find message for id ${messageId}`); + } + + const dialog = new Whisper.ConfirmationDialogView({ + message: window.i18n('identityKeyErrorOnSend', { + name1: contact.getTitle(), + name2: contact.getTitle(), + }), + okText: window.i18n('sendAnyway'), + resolve: async () => { + await contact.updateVerified(); + + if (contact.isUnverified()) { + await contact.setVerifiedDefault(); + } + + const untrusted = await contact.isUntrusted(); + if (untrusted) { + contact.setApproved(); + } + + message.resend(contact.getSendTarget()); + }, + }); + + this.$el.prepend(dialog.el); + dialog.focusCancel(); + }, + + showSafetyNumber(id: any) { + let conversation; + + if (!id && this.model.isPrivate()) { + // eslint-disable-next-line prefer-destructuring + conversation = this.model; + } else { + conversation = window.ConversationController.get(id); + } + if (conversation) { + const view = new Whisper.KeyVerificationPanelView({ + model: conversation, + }); + this.listenBack(view); + this.updateHeader(); + } + }, + + downloadAttachmentWrapper(messageId: any) { + const message = this.model.messageCollection.get(messageId); + if (!message) { + throw new Error( + `downloadAttachmentWrapper: Did not find message for id ${messageId}` + ); + } + + const { attachments, sent_at: timestamp } = message.attributes; + if (!attachments || attachments.length < 1) { + return; + } + + const attachment = attachments[0]; + const { fileName } = attachment; + + const isDangerous = window.Signal.Util.isFileDangerous(fileName || ''); + + this.downloadAttachment({ attachment, timestamp, isDangerous }); + }, + + async downloadAttachment({ attachment, timestamp, isDangerous }: any) { + if (isDangerous) { + this.showToast(Whisper.DangerousFileTypeToast); + return; + } + + const fullPath = await window.Signal.Types.Attachment.save({ + attachment, + readAttachmentData, + saveAttachmentToDisk, + timestamp, + }); + + if (fullPath) { + this.showToast(Whisper.FileSavedToast, { fullPath }); + } + }, + + async displayTapToViewMessage(messageId: any) { + const message = this.model.messageCollection.get(messageId); + if (!message) { + throw new Error( + `displayTapToViewMessage: Did not find message for id ${messageId}` + ); + } + + if (!message.isTapToView()) { + throw new Error( + `displayTapToViewMessage: Message ${message.idForLogging()} is not a tap to view message` + ); + } + + if (message.isErased()) { + throw new Error( + `displayTapToViewMessage: Message ${message.idForLogging()} is already erased` + ); + } + + const firstAttachment = message.get('attachments')[0]; + if (!firstAttachment || !firstAttachment.path) { + throw new Error( + `displayTapToViewMessage: Message ${message.idForLogging()} had no first attachment with path` + ); + } + + const absolutePath = getAbsoluteAttachmentPath(firstAttachment.path); + const tempPath = await copyIntoTempDirectory(absolutePath); + const tempAttachment = { + ...firstAttachment, + path: tempPath, + }; + + await message.markViewed(); + + const closeLightbox = async () => { + if (!this.lightboxView) { + return; + } + + const { lightboxView } = this; + this.lightboxView = null; + + this.stopListening(message); + window.Signal.Backbone.Views.Lightbox.hide(); + lightboxView.remove(); + + await deleteTempFile(tempPath); + }; + this.listenTo(message, 'expired', closeLightbox); + this.listenTo(message, 'change', () => { + if (this.lightBoxView) { + this.lightBoxView.update(getProps()); + } + }); + + const getProps = () => { + const { path, contentType } = tempAttachment; + + return { + objectURL: getAbsoluteTempPath(path), + contentType, + onSave: null, // important so download button is omitted + isViewOnce: true, + }; + }; + this.lightboxView = new Whisper.ReactWrapperView({ + className: 'lightbox-wrapper', + Component: window.Signal.Components.Lightbox, + props: getProps(), + onClose: closeLightbox, + }); + + window.Signal.Backbone.Views.Lightbox.show(this.lightboxView.el); + }, + + deleteMessage(messageId: any) { + const message = this.model.messageCollection.get(messageId); + if (!message) { + throw new Error( + `deleteMessage: Did not find message for id ${messageId}` + ); + } + + const dialog = new Whisper.ConfirmationDialogView({ + message: window.i18n('deleteWarning'), + okText: window.i18n('delete'), + resolve: () => { + window.Signal.Data.removeMessage(message.id, { + Message: Whisper.Message, + }); + message.trigger('unload'); + this.model.messageCollection.remove(message.id); + if (message.isOutgoing()) { + this.model.decrementSentMessageCount(); + } else { + this.model.decrementMessageCount(); + } + this.resetPanel(); + }, + }); + + this.$el.prepend(dialog.el); + dialog.focusCancel(); + }, + + showStickerPackPreview(packId: any, packKey: any) { + window.Signal.Stickers.downloadEphemeralPack(packId, packKey); + + const props = { + packId, + onClose: async () => { + this.stickerPreviewModalView.remove(); + this.stickerPreviewModalView = null; + await window.Signal.Stickers.removeEphemeralPack(packId); + }, + }; + + this.stickerPreviewModalView = new Whisper.ReactWrapperView({ + className: 'sticker-preview-modal-wrapper', + JSX: window.Signal.State.Roots.createStickerPreviewModal( + window.reduxStore, + props + ), + }); + }, + + showLightbox({ attachment, messageId }: any) { + const message = this.model.messageCollection.get(messageId); + if (!message) { + throw new Error(`showLightbox: did not find message for id ${messageId}`); + } + const sticker = message.get('sticker'); + if (sticker) { + const { packId, packKey } = sticker; + this.showStickerPackPreview(packId, packKey); + return; + } + + const { contentType, path } = attachment; + + if ( + !window.Signal.Util.GoogleChrome.isImageTypeSupported(contentType) && + !window.Signal.Util.GoogleChrome.isVideoTypeSupported(contentType) + ) { + this.downloadAttachment({ attachment, message }); + return; + } + + const attachments = message.get('attachments') || []; + + const media = attachments + .filter((item: any) => item.thumbnail && !item.pending && !item.error) + .map((item: any, index: any) => ({ + objectURL: getAbsoluteAttachmentPath(item.path), + path: item.path, + contentType: item.contentType, + index, + message, + attachment: item, + })); + + if (media.length === 1) { + const props = { + objectURL: getAbsoluteAttachmentPath(path), + contentType, + caption: attachment.caption, + onSave: () => { + const timestamp = message.get('sent_at'); + this.downloadAttachment({ attachment, timestamp, message }); + }, + }; + this.lightboxView = new Whisper.ReactWrapperView({ + className: 'lightbox-wrapper', + Component: window.Signal.Components.Lightbox, + props, + onClose: () => { + window.Signal.Backbone.Views.Lightbox.hide(); + this.stopListening(message); + }, + }); + this.listenTo(message, 'expired', () => this.lightboxView.remove()); + window.Signal.Backbone.Views.Lightbox.show(this.lightboxView.el); + return; + } + + const selectedIndex = window._.findIndex( + media, + (item: any) => attachment.path === item.path + ); + + const onSave = async (options: any = {}) => { + const fullPath = await window.Signal.Types.Attachment.save({ + attachment: options.attachment, + index: options.index + 1, + readAttachmentData, + saveAttachmentToDisk, + timestamp: options.message.get('sent_at'), + }); + + if (fullPath) { + this.showToast(Whisper.FileSavedToast, { fullPath }); + } + }; + + const props = { + media, + selectedIndex: selectedIndex >= 0 ? selectedIndex : 0, + onSave, + }; + this.lightboxGalleryView = new Whisper.ReactWrapperView({ + className: 'lightbox-wrapper', + Component: window.Signal.Components.LightboxGallery, + props, + onClose: () => { + window.Signal.Backbone.Views.Lightbox.hide(); + this.stopListening(message); + }, + }); + this.listenTo(message, 'expired', () => this.lightboxGalleryView.remove()); + window.Signal.Backbone.Views.Lightbox.show(this.lightboxGalleryView.el); + }, + + showMessageDetail(messageId: any) { + const message = this.model.messageCollection.get(messageId); + if (!message) { + throw new Error( + `showMessageDetail: Did not find message for id ${messageId}` + ); + } + + if (!message.isNormalBubble()) { + return; + } + + const onClose = () => { + this.stopListening(message, 'change', update); + this.resetPanel(); + }; + + const props = message.getPropsForMessageDetail(); + const view = new Whisper.ReactWrapperView({ + className: 'panel message-detail-wrapper', + Component: window.Signal.Components.MessageDetail, + props, + onClose, + }); + + const update = () => view.update(message.getPropsForMessageDetail()); + this.listenTo(message, 'change', update); + this.listenTo(message, 'expired', onClose); + // We could listen to all involved contacts, but we'll call that overkill + + this.listenBack(view); + this.updateHeader(); + view.render(); + }, + + showStickerManager() { + const view = new Whisper.ReactWrapperView({ + className: ['sticker-manager-wrapper', 'panel'].join(' '), + JSX: window.Signal.State.Roots.createStickerManager(window.reduxStore), + onClose: () => { + this.resetPanel(); + }, + }); + + this.listenBack(view); + this.updateHeader(); + view.render(); + }, + + showContactDetail({ contact, signalAccount }: any) { + const view = new Whisper.ReactWrapperView({ + Component: window.Signal.Components.ContactDetail, + className: 'contact-detail-pane panel', + props: { + contact, + hasSignalAccount: Boolean(signalAccount), + onSendMessage: () => { + if (signalAccount) { + this.openConversation(signalAccount); + } + }, + }, + onClose: () => { + this.resetPanel(); + }, + }); + + this.listenBack(view); + this.updateHeader(); + }, + + async openConversation(number: any) { + window.Whisper.events.trigger('showConversation', number); + }, + + listenBack(view: any) { + this.panels = this.panels || []; + + if (this.panels.length === 0) { + this.previousFocus = document.activeElement; + } + + this.panels.unshift(view); + view.$el.insertAfter(this.$('.panel').last()); + view.$el.one('animationend', () => { + view.$el.addClass('panel--static'); + }); + }, + resetPanel() { + if (!this.panels || !this.panels.length) { + return; + } + + const view = this.panels.shift(); + + if ( + this.panels.length === 0 && + this.previousFocus && + this.previousFocus.focus + ) { + this.previousFocus.focus(); + this.previousFocus = null; + } + + if (this.panels.length > 0) { + this.panels[0].$el.fadeIn(250); + } + this.updateHeader(); + + view.$el.addClass('panel--remove').one('transitionend', () => { + view.remove(); + + if (this.panels.length === 0) { + // Make sure poppers are positioned properly + window.dispatchEvent(new Event('resize')); + } + }); + }, + + endSession() { + this.model.endSession(); + }, + + async setDisappearingMessages(seconds: any) { + try { + if (seconds > 0) { + await this.model.updateExpirationTimer(seconds); + } else { + await this.model.updateExpirationTimer(null); + } + } catch (error) { + if (error.code === 409) { + this.showToast(Whisper.TimerConflictToast); + } + } + }, + + setMuteNotifications(ms: any) { + this.model.set({ + muteExpiresAt: ms > 0 ? Date.now() + ms : undefined, + }); + }, + + async destroyMessages() { + try { + await this.confirm(window.i18n('deleteConversationConfirmation')); + try { + this.model.trigger('unload', 'delete messages'); + await this.model.destroyMessages(); + this.model.updateLastMessage(); + } catch (error) { + window.log.error( + 'destroyMessages: Failed to successfully delete conversation', + error && error.stack ? error.stack : error + ); + } + } catch (error) { + // nothing to see here, user canceled out of dialog + } + }, + + async isCallSafe() { + const contacts = await this.getUntrustedContacts(); + if (contacts && contacts.length) { + const callAnyway = await this.showSendAnywayDialog( + contacts, + window.i18n('callAnyway') + ); + if (!callAnyway) { + window.log.info( + 'Safety number change dialog not accepted, new call not allowed.' + ); + return false; + } + } + + return true; + }, + + showSendAnywayDialog(contacts: any, confirmText: any) { + return new Promise(resolve => { + const dialog = new Whisper.SafetyNumberChangeDialogView({ + confirmText, + contacts, + reject: () => { + resolve(false); + }, + resolve: () => { + resolve(true); + }, + }); + + this.$el.prepend(dialog.el); + }); + }, + + async sendReactionMessage(messageId: any, reaction: any) { + const messageModel = messageId + ? await getMessageById(messageId, { + Message: Whisper.Message, + }) + : null; + + try { + await this.model.sendReactionMessage(reaction, { + targetAuthorE164: messageModel.getSource(), + targetAuthorUuid: messageModel.getSourceUuid(), + targetTimestamp: messageModel.get('sent_at'), + }); + } catch (error) { + window.log.error('Error sending reaction', error, messageId, reaction); + this.showToast(Whisper.ReactionFailedToast); + } + }, + + async sendStickerMessage(options: any = {}) { + try { + const contacts = await this.getUntrustedContacts(options); + + if (contacts && contacts.length) { + const sendAnyway = await this.showSendAnywayDialog(contacts); + if (sendAnyway) { + this.sendStickerMessage({ ...options, force: true }); + } + + return; + } + + const { packId, stickerId } = options; + this.model.sendStickerMessage(packId, stickerId); + } catch (error) { + window.log.error( + 'clickSend error:', + error && error.stack ? error.stack : error + ); + } + }, + + async getUntrustedContacts(options: any = {}) { + // This will go to the trust store for the latest identity key information, + // and may result in the display of a new banner for this conversation. + await this.model.updateVerified(); + const unverifiedContacts = this.model.getUnverified(); + + if (options.force) { + if (unverifiedContacts.length) { + await this.markAllAsVerifiedDefault(unverifiedContacts); + // We only want force to break us through one layer of checks + // eslint-disable-next-line no-param-reassign + options.force = false; + } + } else if (unverifiedContacts.length) { + return unverifiedContacts; + } + + const untrustedContacts = await this.model.getUntrusted(); + + if (options.force) { + if (untrustedContacts.length) { + await this.markAllAsApproved(untrustedContacts); + } + } else if (untrustedContacts.length) { + return untrustedContacts; + } + + return null; + }, + + async setQuoteMessage(messageId: any) { + const model = messageId + ? await getMessageById(messageId, { + Message: Whisper.Message, + }) + : null; + + if (model && !model.canReply()) { + return; + } + + if (model && !model.isNormalBubble()) { + return; + } + + this.quote = null; + this.quotedMessage = null; + this.quoteHolder = null; + + const existing = this.model.get('quotedMessageId'); + if (existing !== messageId) { + this.model.set({ + quotedMessageId: messageId, + draftChanged: true, + }); + + await this.saveModel(); + } + + if (this.quoteView) { + this.quoteView.remove(); + this.quoteView = null; + } + + if (model) { + const message = window.MessageController.register(model.id, model); + this.quotedMessage = message; + + if (message) { + this.quote = await this.model.makeQuote(this.quotedMessage); + + this.focusMessageFieldAndClearDisabled(); + } + } + + this.renderQuotedMessage(); + }, + + renderQuotedMessage() { + if (this.quoteView) { + this.quoteView.remove(); + this.quoteView = null; + } + if (!this.quotedMessage) { + return; + } + + const message = new Whisper.Message({ + conversationId: this.model.id, + quote: this.quote, + } as any); + message.quotedMessage = this.quotedMessage; + this.quoteHolder = message; + + const props = message.getPropsForQuote(); + + this.listenTo(message, 'scroll-to-message', () => { + this.scrollToMessage(message.quotedMessage.id); + }); + + const contact = this.quotedMessage.getContact(); + if (contact) { + this.listenTo(contact, 'change', this.renderQuotedMesage); + } + + this.quoteView = new Whisper.ReactWrapperView({ + className: 'quote-wrapper', + Component: window.Signal.Components.Quote, + elCallback: (el: any) => + this.$(this.compositionApi.current.attSlotRef.current).prepend(el), + props: { + ...props, + withContentAbove: true, + onClose: () => { + // This can't be the normal 'onClose' because that is always run when this + // view is removed from the DOM, and would clear the draft quote. + this.setQuoteMessage(null); + }, + }, + }); + }, + + async sendMessage(message = '', options = {}) { + this.sendStart = Date.now(); + + try { + const contacts = await this.getUntrustedContacts(options); + this.disableMessageField(); + + if (contacts && contacts.length) { + const sendAnyway = await this.showSendAnywayDialog(contacts); + if (sendAnyway) { + this.sendMessage(message, { force: true }); + return; + } + + this.focusMessageFieldAndClearDisabled(); + return; + } + } catch (error) { + this.focusMessageFieldAndClearDisabled(); + window.log.error( + 'sendMessage error:', + error && error.stack ? error.stack : error + ); + return; + } + + this.model.clearTypingTimers(); + + let ToastView; + if (window.reduxStore.getState().expiration.hasExpired) { + ToastView = Whisper.ExpiredToast; + } + if ( + this.model.isPrivate() && + (window.storage.isBlocked(this.model.get('e164')) || + window.storage.isUuidBlocked(this.model.get('uuid'))) + ) { + ToastView = Whisper.BlockedToast; + } + if ( + !this.model.isPrivate() && + window.storage.isGroupBlocked(this.model.get('groupId')) + ) { + ToastView = Whisper.BlockedGroupToast; + } + if (!this.model.isPrivate() && this.model.get('left')) { + ToastView = Whisper.LeftGroupToast; + } + if (message.length > MAX_MESSAGE_BODY_LENGTH) { + ToastView = Whisper.MessageBodyTooLongToast; + } + + if (ToastView) { + this.showToast(ToastView); + this.focusMessageFieldAndClearDisabled(); + return; + } + + try { + if (!message.length && !this.hasFiles() && !this.voiceNoteAttachment) { + return; + } + + const attachments = await this.getFiles(); + const sendDelta = Date.now() - this.sendStart; + window.log.info('Send pre-checks took', sendDelta, 'milliseconds'); + + this.model.sendMessage( + message, + attachments, + this.quote, + this.getLinkPreview() + ); + + this.compositionApi.current.reset(); + this.setQuoteMessage(null); + this.resetLinkPreview(); + this.clearAttachments(); + } catch (error) { + window.log.error( + 'Error pulling attached files before send', + error && error.stack ? error.stack : error + ); + } finally { + this.focusMessageFieldAndClearDisabled(); + } + }, + + onEditorStateChange(messageText: any, caretLocation: any) { + this.maybeBumpTyping(messageText); + this.debouncedSaveDraft(messageText); + this.debouncedMaybeGrabLinkPreview(messageText, caretLocation); + }, + + async saveDraft(messageText: any) { + const trimmed = + messageText && messageText.length > 0 ? messageText.trim() : ''; + + if (this.model.get('draft') && (!messageText || trimmed.length === 0)) { + this.model.set({ + draft: null, + draftChanged: true, + }); + await this.saveModel(); + + return; + } + + if (messageText !== this.model.get('draft')) { + this.model.set({ + draft: messageText, + draftChanged: true, + }); + await this.saveModel(); + } + }, + + maybeGrabLinkPreview(message: any, caretLocation: any) { + // Don't generate link previews if user has turned them off + if (!window.storage.get('linkPreviews', false)) { + return; + } + // Do nothing if we're offline + if (!window.textsecure.messaging) { + return; + } + // If we have attachments, don't add link preview + if (this.hasFiles()) { + return; + } + // If we're behind a user-configured proxy, we don't support link previews + if (window.isBehindProxy()) { + return; + } + + if (!message) { + this.resetLinkPreview(); + return; + } + if (this.disableLinkPreviews) { + return; + } + + const links = window.Signal.LinkPreviews.findLinks(message, caretLocation); + const { currentlyMatchedLink } = this; + if (links.includes(currentlyMatchedLink)) { + return; + } + + this.currentlyMatchedLink = null; + this.excludedPreviewUrls = this.excludedPreviewUrls || []; + + const link = links.find( + item => + window.Signal.LinkPreviews.isLinkInWhitelist(item) && + !this.excludedPreviewUrls.includes(item) + ); + if (!link) { + this.removeLinkPreview(); + return; + } + + this.currentlyMatchedLink = link; + this.addLinkPreview(link); + }, + + resetLinkPreview() { + this.disableLinkPreviews = false; + this.excludedPreviewUrls = []; + this.removeLinkPreview(); + }, + + removeLinkPreview() { + (this.preview || []).forEach((item: any) => { + if (item.url) { + URL.revokeObjectURL(item.url); + } + }); + this.preview = null; + this.previewLoading = null; + this.currentlyMatchedLink = false; + this.renderLinkPreview(); + }, + + async makeChunkedRequest(url: any) { + const PARALLELISM = 3; + const first = await window.textsecure.messaging.makeProxiedRequest(url, { + start: 0, + end: window.Signal.Crypto.getRandomValue(1023, 2047), + returnArrayBuffer: true, + }); + const { totalSize, result } = first; + const initialOffset = result.data.byteLength; + const firstChunk = { + start: 0, + end: initialOffset, + ...result, + }; + + const chunks = await window.Signal.LinkPreviews.getChunkPattern( + totalSize, + initialOffset + ); + + let results: Array = []; + const jobs = chunks.map((chunk: any) => async () => { + const { start, end } = chunk; + + const jobResult = await window.textsecure.messaging.makeProxiedRequest( + url, + { + start, + end, + returnArrayBuffer: true, + } + ); + + return { + ...chunk, + ...jobResult.result, + }; + }); + + while (jobs.length > 0) { + const activeJobs = []; + for (let i = 0, max = PARALLELISM; i < max; i += 1) { + if (!jobs.length) { + break; + } + + const job = jobs.shift(); + activeJobs.push(job()); + } + + // eslint-disable-next-line no-await-in-loop + results = results.concat(await Promise.all(activeJobs)); + } + + if (!results.length) { + throw new Error('No responses received'); + } + + const { contentType } = results[0]; + const data = window.Signal.LinkPreviews.assembleChunks( + [firstChunk].concat(results) + ); + + return { + contentType, + data, + }; + }, + + async getStickerPackPreview(url: any) { + const isPackDownloaded = (pack: any) => + pack && (pack.status === 'downloaded' || pack.status === 'installed'); + const isPackValid = (pack: any) => + pack && + (pack.status === 'ephemeral' || + pack.status === 'downloaded' || + pack.status === 'installed'); + + const dataFromLink = window.Signal.Stickers.getDataFromLink(url); + if (!dataFromLink) { + return null; + } + const { id, key } = dataFromLink; + + try { + const keyBytes = window.Signal.Crypto.bytesFromHexString(key); + const keyBase64 = window.Signal.Crypto.arrayBufferToBase64(keyBytes); + + const existing = window.Signal.Stickers.getStickerPack(id); + if (!isPackDownloaded(existing)) { + await window.Signal.Stickers.downloadEphemeralPack(id, keyBase64); + } + + const pack = window.Signal.Stickers.getStickerPack(id); + if (!isPackValid(pack)) { + return null; + } + if (pack.key !== keyBase64) { + return null; + } + + const { title, coverStickerId } = pack; + const sticker = pack.stickers[coverStickerId]; + const data = + pack.status === 'ephemeral' + ? await window.Signal.Migrations.readTempData(sticker.path) + : await window.Signal.Migrations.readStickerData(sticker.path); + + return { + title, + url, + image: { + ...sticker, + data, + size: data.byteLength, + contentType: 'image/webp', + }, + }; + } catch (error) { + window.log.error( + 'getStickerPackPreview error:', + error && error.stack ? error.stack : error + ); + return null; + } finally { + if (id) { + await window.Signal.Stickers.removeEphemeralPack(id); + } + } + }, + + async getPreview(url: any) { + if (window.Signal.LinkPreviews.isStickerPack(url)) { + return this.getStickerPackPreview(url); + } + + let html; + try { + html = await window.textsecure.messaging.makeProxiedRequest(url); + } catch (error) { + if (error.code >= 300) { + return null; + } + } + + const title = window.Signal.LinkPreviews.getTitleMetaTag(html); + const imageUrl = window.Signal.LinkPreviews.getImageMetaTag(html); + + let image; + let objectUrl; + try { + if (imageUrl) { + if (!window.Signal.LinkPreviews.isMediaLinkInWhitelist(imageUrl)) { + const primaryDomain = window.Signal.LinkPreviews.getDomain(url); + const imageDomain = window.Signal.LinkPreviews.getDomain(imageUrl); + throw new Error( + `imageUrl for domain ${primaryDomain} did not match media whitelist. Domain: ${imageDomain}` + ); + } + + const chunked = await this.makeChunkedRequest(imageUrl); + + // Ensure that this file is either small enough or is resized to meet our + // requirements for attachments + const withBlob = await this.autoScale({ + contentType: chunked.contentType, + file: new Blob([chunked.data], { + type: chunked.contentType, + }), + }); + + const data = await this.arrayBufferFromFile(withBlob.file); + objectUrl = URL.createObjectURL(withBlob.file); + + const dimensions = await window.Signal.Types.VisualAttachment.getImageDimensions( + { + objectUrl, + logger: window.log, + } + ); + + image = { + data, + size: data.byteLength, + ...dimensions, + contentType: withBlob.file.type, + }; + } + } catch (error) { + // We still want to show the preview if we failed to get an image + window.log.error( + 'getPreview failed to get image for link preview:', + error.message + ); + } finally { + if (objectUrl) { + URL.revokeObjectURL(objectUrl); + } + } + + return { + title, + url, + image, + }; + }, + + async addLinkPreview(url: any) { + (this.preview || []).forEach((item: any) => { + if (item.url) { + URL.revokeObjectURL(item.url); + } + }); + this.preview = null; + + this.currentlyMatchedLink = url; + this.previewLoading = this.getPreview(url); + const promise = this.previewLoading; + this.renderLinkPreview(); + + try { + const result = await promise; + + if ( + url !== this.currentlyMatchedLink || + promise !== this.previewLoading + ) { + // another request was started, or this was canceled + return; + } + + // If we couldn't pull down the initial URL + if (!result) { + this.excludedPreviewUrls.push(url); + this.removeLinkPreview(); + return; + } + + if (result.image) { + const blob = new Blob([result.image.data], { + type: result.image.contentType, + }); + result.image.url = URL.createObjectURL(blob); + } else if (!result.title) { + // A link preview isn't worth showing unless we have either a title or an image + this.removeLinkPreview(); + return; + } + + this.preview = [result]; + this.renderLinkPreview(); + } catch (error) { + window.log.error( + 'Problem loading link preview, disabling.', + error && error.stack ? error.stack : error + ); + this.disableLinkPreviews = true; + this.removeLinkPreview(); + } + }, + + renderLinkPreview() { + if (this.previewView) { + this.previewView.remove(); + this.previewView = null; + } + if (!this.currentlyMatchedLink) { + return; + } + + const first = (this.preview && this.preview[0]) || null; + const props = { + ...first, + domain: first && window.Signal.LinkPreviews.getDomain(first.url), + isLoaded: Boolean(first), + onClose: () => { + this.disableLinkPreviews = true; + this.removeLinkPreview(); + }, + }; + + this.previewView = new Whisper.ReactWrapperView({ + className: 'preview-wrapper', + Component: window.Signal.Components.StagedLinkPreview, + elCallback: (el: any) => + this.$(this.compositionApi.current.attSlotRef.current).prepend(el), + props, + }); + }, + + getLinkPreview() { + // Don't generate link previews if user has turned them off + if (!window.storage.get('linkPreviews', false)) { + return []; + } + + if (!this.preview) { + return []; + } + + return this.preview.map((item: any) => { + if (item.image) { + // We eliminate the ObjectURL here, unneeded for send or save + return { + ...item, + image: _.omit(item.image, 'url'), + }; + } + + return item; + }); + }, + + // Called whenever the user changes the message composition field. But only + // fires if there's content in the message field after the change. + maybeBumpTyping(messageText: any) { + if (messageText.length) { + this.model.throttledBumpTyping(); + } + }, +}); diff --git a/ts/window.d.ts b/ts/window.d.ts index 35582db95..96674edaa 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -5,9 +5,8 @@ import * as Underscore from 'underscore'; import { Ref } from 'react'; import { ConversationModelCollectionType, - ConversationModelType, MessageModelCollectionType, - MessageModelType, + MessageAttributesType, } from './model-types.d'; import { LibSignalType, @@ -19,7 +18,7 @@ import { WebAPIConnectType } from './textsecure/WebAPI'; import { CallingClass } from './services/calling'; import * as Crypto from './Crypto'; import * as RemoteConfig from './RemoteConfig'; -import { LocalizerType } from './types/Util'; +import { LocalizerType, BodyRangesType } from './types/Util'; import { CallHistoryDetailsType } from './types/Calling'; import { ColorType } from './types/Colors'; import { ConversationController } from './ConversationController'; @@ -27,16 +26,47 @@ import { ReduxActions } from './state/types'; import { SendOptionsType } from './textsecure/SendMessage'; import AccountManager from './textsecure/AccountManager'; import Data from './sql/Client'; +import { UserMessage } from './types/Message'; +import PQueue from 'p-queue/dist'; +import { PhoneNumberFormat } from 'google-libphonenumber'; +import { MessageModel } from './models/messages'; +import { ConversationModel } from './models/conversations'; +import { combineNames } from './util'; +import { BatcherType } from './util/batcher'; export { Long } from 'long'; type TaskResultType = any; +type WhatIsThis = any; + declare global { interface Window { + _: typeof Underscore; + $: typeof jQuery; + + extension: any; + moment: any; + imageToBlurHash: any; + autoOrientImage: any; + dataURLToBlobSync: any; + loadImage: any; + isBehindProxy: any; + + PQueue: typeof PQueue; + PQueueType: PQueue; + + WhatIsThis: WhatIsThis; + + baseAttachmentsPath: string; + baseStickersPath: string; + baseTempPath: string; dcodeIO: DCodeIOType; + enterKeyboardMode: () => void; + enterMouseMode: () => void; getAccountManager: () => AccountManager | undefined; getAlwaysRelayCalls: () => Promise; + getBuiltInImages: () => Promise>; getCallRingtoneNotification: () => Promise; getCallSystemNotification: () => Promise; getConversations: () => ConversationModelCollectionType; @@ -46,20 +76,33 @@ declare global { getGuid: () => string; getInboxCollection: () => ConversationModelCollectionType; getIncomingCallNotification: () => Promise; + getInteractionMode: () => string; getMediaCameraPermissions: () => Promise; getMediaPermissions: () => Promise; getServerPublicParams: () => string; getSocketStatus: () => number; + getSyncRequest: () => WhatIsThis; getTitle: () => string; waitForEmptyEventQueue: () => Promise; getVersion: () => string; showCallingPermissionsPopup: (forCamera: boolean) => Promise; i18n: LocalizerType; - isValidGuid: (maybeGuid: string) => boolean; + isActive: () => boolean; + isAfterVersion: (version: WhatIsThis, anotherVersion: string) => boolean; + isBeforeVersion: (version: WhatIsThis, anotherVersion: string) => boolean; + isValidGuid: (maybeGuid: string | null) => boolean; + isValidE164: (maybeE164: unknown) => boolean; libphonenumber: { util: { getRegionCodeForNumber: (number: string) => string; + parseNumber: ( + e164: string, + regionCode: string + ) => typeof window.Signal.Types.PhoneNumber; }; + parse: (number: string) => string; + getRegionCodeForNumber: (number: string) => string; + format: (number: string, format: PhoneNumberFormat) => string; }; libsignal: LibSignalType; log: { @@ -67,28 +110,69 @@ declare global { warn: LoggerType; error: LoggerType; }; + nodeSetImmediate: typeof setImmediate; normalizeUuids: (obj: any, paths: Array, context: string) => any; + owsDesktopApp: WhatIsThis; platform: string; + preloadedImages: Array; reduxActions: ReduxActions; + reduxStore: WhatIsThis; + registerForActive: (handler: WhatIsThis) => void; + resetActiveTimer: () => void; restart: () => void; + setImmediate: typeof setImmediate; showWindow: () => void; showSettings: () => void; + shutdown: () => void; + setAutoHideMenuBar: (value: WhatIsThis) => void; setBadgeCount: (count: number) => void; + setMenuBarVisibility: (value: WhatIsThis) => void; + showKeyboardShortcuts: () => void; storage: { - put: (key: string, value: any) => void; - remove: (key: string) => Promise; - get: (key: string) => T | undefined; + addBlockedGroup: (group: string) => void; addBlockedNumber: (number: string) => void; + addBlockedUuid: (uuid: string) => void; + fetch: () => void; + get: (key: string, defaultValue?: T) => T | undefined; + getItemsState: () => WhatIsThis; isBlocked: (number: string) => boolean; + isGroupBlocked: (group: unknown) => boolean; + isUuidBlocked: (uuid: string) => boolean; + onready: WhatIsThis; + put: (key: string, value: any) => Promise; + remove: (key: string) => Promise; + removeBlockedGroup: (group: string) => void; removeBlockedNumber: (number: string) => void; + removeBlockedUuid: (uuid: string) => void; }; + systemTheme: WhatIsThis; textsecure: TextSecureType; + unregisterForActive: (handler: WhatIsThis) => void; updateTrayIcon: (count: number) => void; Backbone: typeof Backbone; Signal: { + Backbone: any; + AttachmentDownloads: { + addJob: ( + attachment: unknown, + options: unknown + ) => Promise; + start: (options: WhatIsThis) => void; + stop: () => void; + }; Crypto: typeof Crypto; Data: typeof Data; + Groups: { + maybeUpdateGroup: (options: unknown) => Promise; + waitThenMaybeUpdateGroup: (options: unknown) => Promise; + uploadGroupChange: ( + options: unknown + ) => Promise<{ toArrayBuffer: () => ArrayBuffer }>; + buildDisappearingMessagesTimerChange: ( + options: unknown + ) => { version: number }; + }; Metadata: { SecretSessionCipher: typeof SecretSessionCipherClass; createCertificateValidator: ( @@ -98,22 +182,301 @@ declare global { RemoteConfig: typeof RemoteConfig; Services: { calling: CallingClass; + eraseAllStorageServiceState: () => Promise; + handleUnknownRecords: (param: WhatIsThis) => void; + initializeGroupCredentialFetcher: () => void; + initializeNetworkObserver: (network: WhatIsThis) => void; + initializeUpdateListener: ( + updates: WhatIsThis, + events: WhatIsThis + ) => void; + runStorageServiceSyncJob: () => Promise; + storageServiceUploadJob: () => void; }; Migrations: { + readTempData: any; deleteAttachmentData: (path: string) => Promise; + doesAttachmentExist: () => unknown; writeNewAttachmentData: (data: ArrayBuffer) => Promise; + deleteExternalMessageFiles: (attributes: unknown) => Promise; + getAbsoluteAttachmentPath: (path: string) => string; + loadAttachmentData: (attachment: WhatIsThis) => WhatIsThis; + loadQuoteData: (quote: unknown) => WhatIsThis; + loadPreviewData: (preview: unknown) => WhatIsThis; + loadStickerData: (sticker: unknown) => WhatIsThis; + readStickerData: (path: string) => Promise; + upgradeMessageSchema: (attributes: unknown) => WhatIsThis; + + copyIntoTempDirectory: any; + deleteDraftFile: any; + deleteTempFile: any; + getAbsoluteDraftPath: any; + getAbsoluteTempPath: any; + openFileInFolder: any; + readAttachmentData: any; + readDraftData: any; + saveAttachmentToDisk: any; + writeNewDraftData: any; + }; + Stickers: { + getDataFromLink: any; + copyStickerToAttachments: ( + packId: string, + stickerId: number + ) => Promise; + deletePackReference: (id: string, packId: string) => Promise; + downloadEphemeralPack: ( + packId: string, + key: WhatIsThis + ) => Promise; + downloadQueuedPacks: () => void; + downloadStickerPack: ( + id: string, + key: string, + options: WhatIsThis + ) => void; + getInitialState: () => WhatIsThis; + load: () => void; + removeEphemeralPack: (packId: string) => Promise; + savePackMetadata: ( + packId: string, + packKey: string, + metadata: unknown + ) => void; + getStickerPackStatus: (packId: string) => 'downloaded' | 'installed'; + getSticker: ( + packId: string, + stickerId: number + ) => typeof window.Signal.Types.Sticker; + getStickerPack: (packId: string) => WhatIsThis; + getInstalledStickerPacks: () => WhatIsThis; }; Types: { + Attachment: { + save: any; + path: string; + pending: boolean; + flags: number; + size: number; + screenshot: { + path: string; + }; + thumbnail: { + path: string; + objectUrl: string; + }; + contentType: string; + error: unknown; + + migrateDataToFileSystem: ( + attachment: WhatIsThis, + options: unknown + ) => WhatIsThis; + + isVoiceMessage: (attachments: unknown) => boolean; + isImage: (attachments: unknown) => boolean; + isVideo: (attachments: unknown) => boolean; + isAudio: (attachments: unknown) => boolean; + }; + MIME: { + IMAGE_GIF: unknown; + isImage: any; + isJPEG: any; + }; + Contact: { + avatar?: { avatar?: unknown }; + number: Array<{ value: string }>; + signalAccount: unknown; + + contactSelector: ( + contact: typeof window.Signal.Types.Contact, + options: unknown + ) => typeof window.Signal.Types.Contact; + getName: (contact: typeof window.Signal.Types.Contact) => string; + }; + Conversation: { + computeHash: (data: string) => Promise; + deleteExternalFiles: ( + attributes: unknown, + options: unknown + ) => Promise; + maybeUpdateProfileAvatar: ( + attributes: unknown, + decrypted: unknown, + options: unknown + ) => Promise>; + maybeUpdateAvatar: ( + attributes: unknown, + data: unknown, + options: unknown + ) => Promise; + }; + PhoneNumber: { + format: ( + identifier: string, + options: Record + ) => string; + isValidNumber( + phoneNumber: string, + options?: { + regionCode?: string; + } + ): boolean; + e164: string; + error: string; + }; + Errors: { + toLogFormat(error: Error): void; + }; Message: { CURRENT_SCHEMA_VERSION: number; + VERSION_NEEDED_FOR_DISPLAY: number; + GROUP: 'group'; + PRIVATE: 'private'; + + initializeSchemaVersion: (version: { + message: unknown; + logger: unknown; + }) => unknown & { + schemaVersion: number; + }; + hasExpiration: (json: string) => boolean; }; + Sticker: { + emoji: string; + packId: string; + packKey: string; + stickerId: number; + data: { + pending: boolean; + path: string; + }; + width: number; + height: number; + path: string; + }; + VisualAttachment: any; }; + Util: { + isFileDangerous: any; + GoogleChrome: { + isImageTypeSupported: (contentType: string) => unknown; + isVideoTypeSupported: (contentType: string) => unknown; + }; + downloadAttachment: (attachment: WhatIsThis) => WhatIsThis; + getStringForProfileChange: ( + change: unknown, + changedContact: unknown, + i18n: unknown + ) => string; + getTextWithMentions: ( + bodyRanges: BodyRangesType, + text: string + ) => string; + deleteForEveryone: ( + message: unknown, + del: unknown, + bool: boolean + ) => void; + zkgroup: { + generateProfileKeyCredentialRequest: ( + clientZkProfileCipher: unknown, + uuid: string, + profileKey: unknown + ) => { requestHex: string; context: unknown }; + getClientZkProfileOperations: (params: unknown) => unknown; + handleProfileKeyCredential: ( + clientZkProfileCipher: unknown, + profileCredentialRequestContext: unknown, + credential: unknown + ) => unknown; + deriveProfileKeyVersion: ( + profileKey: unknown, + uuid: string + ) => string; + }; + combineNames: typeof combineNames; + migrateColor: (color: string) => ColorType; + createBatcher: (options: WhatIsThis) => WhatIsThis; + Registration: { + everDone: () => boolean; + markDone: () => void; + markEverDone: () => void; + remove: () => void; + }; + hasExpired: () => boolean; + makeLookup: (conversations: WhatIsThis, key: string) => void; + parseRemoteClientExpiration: (value: WhatIsThis) => WhatIsThis; + }; + LinkPreviews: { + isMediaLinkInWhitelist: any; + getTitleMetaTag: any; + getImageMetaTag: any; + assembleChunks: any; + getChunkPattern: any; + isLinkInWhitelist: any; + isStickerPack: (url: string) => boolean; + isLinkSafeToPreview: (url: string) => boolean; + findLinks: (body: string, unknown?: any) => Array; + getDomain: (url: string) => string; + }; + GroupChange: { + renderChange: (change: unknown, things: unknown) => Array; + }; + Components: { + StagedLinkPreview: any; + Quote: any; + ContactDetail: any; + MessageDetail: any; + Lightbox: any; + MediaGallery: any; + CaptionEditor: any; + ConversationHeader: any; + AttachmentList: any; + getCallingNotificationText: ( + callHistoryDetails: unknown, + i18n: unknown + ) => string; + LightboxGallery: any; + }; + OS: { + isLinux: () => boolean; + }; + Workflow: { + IdleDetector: WhatIsThis; + MessageDataMigrator: WhatIsThis; + }; + IndexedDB: { + removeDatabase: WhatIsThis; + doesDatabaseExist: WhatIsThis; + }; + Views: WhatIsThis; + State: WhatIsThis; + Logs: WhatIsThis; + conversationControllerStart: WhatIsThis; + Emojis: { + getInitialState: () => WhatIsThis; + load: () => void; + }; + RefreshSenderCertificate: WhatIsThis; }; + ConversationController: ConversationController; + Events: WhatIsThis; MessageController: MessageControllerType; WebAPI: WebAPIConnectType; Whisper: WhisperType; + AccountCache: Record; + AccountJobs: Record>; + + doesAccountCheckJobExist: (number: string) => boolean; + checkForSignalAccount: (number: string) => Promise; + isSignalAccountCheckComplete: (number: string) => boolean; + hasSignalAccount: (number: string) => boolean; + getServerTrustRoot: () => WhatIsThis; + readyForUpdates: () => void; + // Flags CALLING: boolean; GV2: boolean; @@ -132,12 +495,13 @@ export type DCodeIOType = { }; Long: Long & { fromBits: (low: number, high: number, unsigned: boolean) => number; - fromString: (str: string) => Long; + fromString: (str: string | null) => Long; }; }; type MessageControllerType = { - register: (id: string, model: MessageModelType) => MessageModelType; + register: (id: string, model: MessageModel) => MessageModel; + unregister: (id: string) => void; }; export class CertificateValidatorType { @@ -211,7 +575,7 @@ export type LoggerType = (...args: Array) => void; export type WhisperType = { events: { on: (name: string, callback: (param1: any, param2?: any) => void) => void; - trigger: (name: string, param1: any, param2?: any) => void; + trigger: (name: string, param1?: any, param2?: any) => void; }; Database: { open: () => Promise; @@ -221,8 +585,120 @@ export type WhisperType = { reject: Function ) => void; }; + GroupConversationCollection: typeof ConversationModelCollectionType; ConversationCollection: typeof ConversationModelCollectionType; - Conversation: typeof ConversationModelType; + ConversationCollectionType: ConversationModelCollectionType; + Conversation: typeof ConversationModel; + ConversationType: ConversationModel; MessageCollection: typeof MessageModelCollectionType; - Message: typeof MessageModelType; + MessageCollectionType: MessageModelCollectionType; + MessageAttributesType: MessageAttributesType; + Message: typeof MessageModel; + MessageType: MessageModel; + GroupMemberConversation: WhatIsThis; + KeyChangeListener: WhatIsThis; + ConfirmationDialogView: WhatIsThis; + ClearDataView: WhatIsThis; + ReactWrapperView: WhatIsThis; + activeConfirmationView: WhatIsThis; + ToastView: WhatIsThis; + ConversationArchivedToast: WhatIsThis; + ConversationUnarchivedToast: WhatIsThis; + AppView: WhatIsThis; + WallClockListener: WhatIsThis; + MessageRequests: WhatIsThis; + BannerView: any; + RecorderView: any; + GroupMemberList: any; + KeyVerificationPanelView: any; + SafetyNumberChangeDialogView: any; + + ExpirationTimerOptions: { + map: any; + getName: (number: number) => string; + getAbbreviated: (number: number) => string; + }; + + Notifications: { + removeBy: (filter: Partial) => void; + add: (notification: unknown) => void; + clear: () => void; + disable: () => void; + enable: () => void; + fastClear: () => void; + on: ( + event: string, + callback: (id: string, messageId: string) => void + ) => void; + }; + + DeliveryReceipts: { + add: (reciept: WhatIsThis) => void; + forMessage: (conversation: unknown, message: unknown) => Array; + onReceipt: (receipt: WhatIsThis) => void; + }; + + ReadReceipts: { + add: (receipt: WhatIsThis) => WhatIsThis; + forMessage: (conversation: unknown, message: unknown) => Array; + onReceipt: (receipt: WhatIsThis) => void; + }; + + ReadSyncs: { + add: (sync: WhatIsThis) => WhatIsThis; + forMessage: (message: unknown) => WhatIsThis; + onReceipt: (receipt: WhatIsThis) => WhatIsThis; + }; + + ViewSyncs: { + add: (sync: WhatIsThis) => WhatIsThis; + forMessage: (message: unknown) => Array; + onSync: (sync: WhatIsThis) => WhatIsThis; + }; + + Reactions: { + forMessage: (message: unknown) => Array; + add: (reaction: unknown) => WhatIsThis; + onReaction: (reactionModel: unknown) => unknown; + }; + + Deletes: { + add: (model: WhatIsThis) => WhatIsThis; + forMessage: (message: unknown) => Array; + onDelete: (model: WhatIsThis) => void; + }; + + IdenticonSVGView: WhatIsThis; + + ExpiringMessagesListener: WhatIsThis; + TapToViewMessagesListener: WhatIsThis; + + deliveryReceiptQueue: PQueue; + deliveryReceiptBatcher: BatcherType; + RotateSignedPreKeyListener: WhatIsThis; + + ExpiredToast: any; + BlockedToast: any; + BlockedGroupToast: any; + LeftGroupToast: any; + OriginalNotFoundToast: any; + OriginalNoLongerAvailableToast: any; + FoundButNotLoadedToast: any; + VoiceNoteLimit: any; + VoiceNoteMustBeOnlyAttachmentToast: any; + TapToViewExpiredIncomingToast: any; + TapToViewExpiredOutgoingToast: any; + FileSavedToast: any; + ReactionFailedToast: any; + MessageBodyTooLongToast: any; + FileSizeToast: any; + UnableToLoadToast: any; + DangerousFileTypeToast: any; + OneNonImageAtATimeToast: any; + CannotMixImageAndNonImageAttachmentsToast: any; + MaxAttachmentsToast: any; + TimerConflictToast: any; + ConversationLoadingScreen: any; + ConversationView: any; + View: any; }; diff --git a/tslint.json b/tslint.json index d62064e1d..4f0e4ecd2 100644 --- a/tslint.json +++ b/tslint.json @@ -186,6 +186,7 @@ "ts/components/conversation/**", "ts/components/emoji/**", "ts/components/stickers/**", + "ts/models/**", "ts/notifications/**", "ts/protobuf/**", "ts/scripts/**", @@ -196,7 +197,8 @@ "ts/test/**", "ts/types/**", "ts/updater/**", - "ts/util/**" + "ts/util/**", + "ts/views/**" ] } }