// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only /* eslint-disable class-methods-use-this */ /* eslint-disable camelcase */ import { MessageModelCollectionType, WhatIsThis, MessageAttributesType, ConversationAttributesType, VerificationOptions, } from '../model-types.d'; import { GroupV2PendingMembership, GroupV2RequestingMembership, } from '../components/conversation/conversation-details/PendingInvites'; import { GroupV2Membership } from '../components/conversation/conversation-details/ConversationDetailsMembershipList'; import { CallMode, CallHistoryDetailsType } from '../types/Calling'; import { CallbackResultType, GroupV2InfoType } from '../textsecure/SendMessage'; import { ConversationType, ConversationTypeType, } from '../state/ducks/conversations'; import { ColorType } from '../types/Colors'; import { MessageModel } from './messages'; import { isMuted } from '../util/isMuted'; import { missingCaseError } from '../util/missingCaseError'; import { sniffImageMimeType } from '../util/sniffImageMimeType'; import { MIMEType, IMAGE_WEBP } from '../types/MIME'; import { arrayBufferToBase64, base64ToArrayBuffer, deriveAccessKey, fromEncodedBinaryToArrayBuffer, getRandomBytes, stringFromBytes, trimForDisplay, verifyAccessKey, } from '../Crypto'; import { GroupChangeClass } from '../textsecure.d'; import { BodyRangesType } from '../types/Util'; import { getTextWithMentions } from '../util'; import { migrateColor } from '../util/migrateColor'; /* 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 COLORS = [ 'red', 'deep_orange', 'brown', 'pink', 'purple', 'indigo', 'blue', 'teal', 'green', 'light_green', 'blue_grey', 'ultramarine', ]; const THREE_HOURS = 3 * 60 * 60 * 1000; type CustomError = Error & { identifier?: string; number?: string; }; export class ConversationModel extends window.Backbone.Model< ConversationAttributesType > { static COLORS: string; cachedProps?: ConversationType | null; oldCachedProps?: ConversationType | null; contactTypingTimers?: Record< string, { senderId: string; timer: NodeJS.Timer } >; contactCollection?: Backbone.Collection; debouncedUpdateLastMessage?: () => void; // backbone ensures this exists // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore id: string; initialPromise?: Promise; inProgressFetch?: Promise; incomingMessageQueue?: typeof window.PQueueType; jobQueue?: typeof window.PQueueType; messageCollection?: MessageModelCollectionType; ourNumber?: string; ourUuid?: string; storeName?: string | null; throttledBumpTyping: unknown; typingRefreshTimer?: NodeJS.Timer | null; typingPauseTimer?: NodeJS.Timer | null; verifiedEnum?: typeof window.textsecure.storage.protocol.VerifiedStatus; intlCollator = new Intl.Collator(); private cachedLatestGroupCallEraId?: string; // 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})`; } if (this.isGroupV2()) { 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 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; // We clear our cached props whenever we change so that the next call to format() will // result in refresh via a getProps() call. See format() below. this.on('change', () => { if (this.cachedProps) { this.oldCachedProps = this.cachedProps; } this.cachedProps = null; }); } isMe(): boolean { const e164 = this.get('e164'); const uuid = this.get('uuid'); return Boolean( (e164 && e164 === this.ourNumber) || (uuid && uuid === this.ourUuid) ); } isGroupV1(): boolean { const groupId = this.get('groupId'); if (!groupId) { return false; } const buffer = fromEncodedBinaryToArrayBuffer(groupId); return buffer.byteLength === window.Signal.Groups.ID_V1_LENGTH; } isGroupV2(): boolean { const groupId = this.get('groupId'); if (!groupId) { return false; } const groupVersion = this.get('groupVersion') || 0; try { return ( groupVersion === 2 && base64ToArrayBuffer(groupId).byteLength === window.Signal.Groups.ID_LENGTH ); } catch (error) { window.log.error('isGroupV2: Failed to process groupId in base64!'); return false; } } isMemberRequestingToJoin(conversationId: string): boolean { if (!this.isGroupV2()) { return false; } const pendingAdminApprovalV2 = this.get('pendingAdminApprovalV2'); if (!pendingAdminApprovalV2 || !pendingAdminApprovalV2.length) { return false; } return pendingAdminApprovalV2.some( item => item.conversationId === conversationId ); } isMemberPending(conversationId: string): boolean { if (!this.isGroupV2()) { return false; } const pendingMembersV2 = this.get('pendingMembersV2'); if (!pendingMembersV2 || !pendingMembersV2.length) { return false; } return window._.any( pendingMembersV2, item => item.conversationId === conversationId ); } isMember(conversationId: string): boolean { if (!this.isGroupV2()) { throw new Error( `isMember: Called for non-GroupV2 conversation ${this.idForLogging()}` ); } const membersV2 = this.get('membersV2'); if (!membersV2 || !membersV2.length) { return false; } return window._.any( membersV2, item => item.conversationId === conversationId ); } async updateExpirationTimerInGroupV2( seconds?: number ): Promise { const idLog = this.idForLogging(); const current = this.get('expireTimer'); const bothFalsey = Boolean(current) === false && Boolean(seconds) === false; if (current === seconds || bothFalsey) { window.log.warn( `updateExpirationTimerInGroupV2/${idLog}: Requested timer ${seconds} is unchanged from existing ${current}.` ); return undefined; } return window.Signal.Groups.buildDisappearingMessagesTimerChange({ expireTimer: seconds || 0, group: this.attributes, }); } async promotePendingMember( conversationId: string ): Promise { const idLog = this.idForLogging(); // This user's pending state may have changed in the time between the user's // button press and when we get here. It's especially important to check here // in conflict/retry cases. if (!this.isMemberPending(conversationId)) { window.log.warn( `promotePendingMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.` ); return undefined; } const pendingMember = window.ConversationController.get(conversationId); if (!pendingMember) { throw new Error( `promotePendingMember/${idLog}: No conversation found for conversation ${conversationId}` ); } // We need the user's profileKeyCredential, which requires a roundtrip with the // server, and most definitely their profileKey. A getProfiles() call will // ensure that we have as much as we can get with the data we have. let profileKeyCredentialBase64 = pendingMember.get('profileKeyCredential'); if (!profileKeyCredentialBase64) { await pendingMember.getProfiles(); profileKeyCredentialBase64 = pendingMember.get('profileKeyCredential'); if (!profileKeyCredentialBase64) { throw new Error( `promotePendingMember/${idLog}: No profileKeyCredential for conversation ${pendingMember.idForLogging()}` ); } } return window.Signal.Groups.buildPromoteMemberChange({ group: this.attributes, profileKeyCredentialBase64, serverPublicParamsBase64: window.getServerPublicParams(), }); } async approvePendingApprovalRequest( conversationId: string ): Promise { const idLog = this.idForLogging(); // This user's pending state may have changed in the time between the user's // button press and when we get here. It's especially important to check here // in conflict/retry cases. if (!this.isMemberRequestingToJoin(conversationId)) { window.log.warn( `approvePendingApprovalRequest/${idLog}: ${conversationId} is not requesting to join the group. Returning early.` ); return undefined; } const pendingMember = window.ConversationController.get(conversationId); if (!pendingMember) { throw new Error( `approvePendingApprovalRequest/${idLog}: No conversation found for conversation ${conversationId}` ); } const uuid = pendingMember.get('uuid'); if (!uuid) { throw new Error( `approvePendingApprovalRequest/${idLog}: Missing uuid for conversation ${conversationId}` ); } return window.Signal.Groups.buildPromotePendingAdminApprovalMemberChange({ group: this.attributes, uuid, }); } async denyPendingApprovalRequest( conversationId: string ): Promise { const idLog = this.idForLogging(); // This user's pending state may have changed in the time between the user's // button press and when we get here. It's especially important to check here // in conflict/retry cases. if (!this.isMemberRequestingToJoin(conversationId)) { window.log.warn( `denyPendingApprovalRequest/${idLog}: ${conversationId} is not requesting to join the group. Returning early.` ); return undefined; } const pendingMember = window.ConversationController.get(conversationId); if (!pendingMember) { throw new Error( `denyPendingApprovalRequest/${idLog}: No conversation found for conversation ${conversationId}` ); } const uuid = pendingMember.get('uuid'); if (!uuid) { throw new Error( `denyPendingApprovalRequest/${idLog}: Missing uuid for conversation ${pendingMember.idForLogging()}` ); } return window.Signal.Groups.buildDeletePendingAdminApprovalMemberChange({ group: this.attributes, uuid, }); } async removePendingMember( conversationIds: Array ): Promise { const idLog = this.idForLogging(); const uuids = conversationIds .map(conversationId => { // This user's pending state may have changed in the time between the user's // button press and when we get here. It's especially important to check here // in conflict/retry cases. if (!this.isMemberPending(conversationId)) { window.log.warn( `removePendingMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.` ); return undefined; } const pendingMember = window.ConversationController.get(conversationId); if (!pendingMember) { window.log.warn( `removePendingMember/${idLog}: No conversation found for conversation ${conversationId}` ); return undefined; } const uuid = pendingMember.get('uuid'); if (!uuid) { window.log.warn( `removePendingMember/${idLog}: Missing uuid for conversation ${pendingMember.idForLogging()}` ); return undefined; } return uuid; }) .filter((uuid): uuid is string => Boolean(uuid)); if (!uuids.length) { return undefined; } return window.Signal.Groups.buildDeletePendingMemberChange({ group: this.attributes, uuids, }); } async removeMember( conversationId: string ): Promise { const idLog = this.idForLogging(); // This user's pending state may have changed in the time between the user's // button press and when we get here. It's especially important to check here // in conflict/retry cases. if (!this.isMember(conversationId)) { window.log.warn( `removeMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.` ); return undefined; } const member = window.ConversationController.get(conversationId); if (!member) { throw new Error( `removeMember/${idLog}: No conversation found for conversation ${conversationId}` ); } const uuid = member.get('uuid'); if (!uuid) { throw new Error( `removeMember/${idLog}: Missing uuid for conversation ${member.idForLogging()}` ); } return window.Signal.Groups.buildDeleteMemberChange({ group: this.attributes, uuid, }); } async toggleAdminChange( conversationId: string ): Promise { if (!this.isGroupV2()) { return undefined; } const idLog = this.idForLogging(); if (!this.isMember(conversationId)) { window.log.warn( `toggleAdminChange/${idLog}: ${conversationId} is not a pending member of group. Returning early.` ); return undefined; } const conversation = window.ConversationController.get(conversationId); if (!conversation) { throw new Error( `toggleAdminChange/${idLog}: No conversation found for conversation ${conversationId}` ); } const uuid = conversation.get('uuid'); if (!uuid) { throw new Error( `toggleAdminChange/${idLog}: Missing uuid for conversation ${conversationId}` ); } const MEMBER_ROLES = window.textsecure.protobuf.Member.Role; const role = this.isAdmin(conversationId) ? MEMBER_ROLES.DEFAULT : MEMBER_ROLES.ADMINISTRATOR; return window.Signal.Groups.buildModifyMemberRoleChange({ group: this.attributes, uuid, role, }); } async modifyGroupV2({ name, createGroupChange, }: { name: string; createGroupChange: () => Promise; }): Promise { const idLog = `${name}/${this.idForLogging()}`; if (!this.isGroupV2()) { throw new Error( `modifyGroupV2/${idLog}: Called for non-GroupV2 conversation` ); } const ONE_MINUTE = 1000 * 60; const startTime = Date.now(); const timeoutTime = startTime + ONE_MINUTE; const MAX_ATTEMPTS = 5; for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) { window.log.info(`modifyGroupV2/${idLog}: Starting attempt ${attempt}`); try { // eslint-disable-next-line no-await-in-loop await window.waitForEmptyEventQueue(); window.log.info(`modifyGroupV2/${idLog}: Queuing attempt ${attempt}`); // eslint-disable-next-line no-await-in-loop await this.queueJob(async () => { window.log.info(`modifyGroupV2/${idLog}: Running attempt ${attempt}`); const actions = await createGroupChange(); if (!actions) { window.log.warn( `modifyGroupV2/${idLog}: No change actions. Returning early.` ); return; } // The new revision has to be exactly one more than the current revision // or it won't upload properly, and it won't apply in maybeUpdateGroup const currentRevision = this.get('revision'); const newRevision = actions.version; if ((currentRevision || 0) + 1 !== newRevision) { throw new Error( `modifyGroupV2/${idLog}: Revision mismatch - ${currentRevision} to ${newRevision}.` ); } // Upload. If we don't have permission, the server will return an error here. const groupChange = await window.Signal.Groups.uploadGroupChange({ actions, group: this.attributes, }); const groupChangeBuffer = groupChange.toArrayBuffer(); const groupChangeBase64 = arrayBufferToBase64(groupChangeBuffer); // Apply change locally, just like we would with an incoming change. This will // change conversation state and add change notifications to the timeline. await window.Signal.Groups.maybeUpdateGroup({ conversation: this, groupChangeBase64, newRevision, }); // Send message to notify group members (including pending members) of change const profileKey = this.get('profileSharing') ? window.storage.get('profileKey') : undefined; const sendOptions = this.getSendOptions(); const timestamp = Date.now(); const promise = this.wrapSend( window.textsecure.messaging.sendMessageToGroup( { groupV2: this.getGroupV2Info({ groupChange: groupChangeBuffer, includePendingMembers: true, }), timestamp, profileKey, }, sendOptions ) ); // We don't save this message; we just use it to ensure that a sync message is // sent to our linked devices. const m = new window.Whisper.Message(({ conversationId: this.id, type: 'not-to-save', sent_at: timestamp, received_at: timestamp, // TODO: DESKTOP-722 // this type does not fully implement the interface it is expected to } as unknown) as MessageAttributesType); // This is to ensure that the functions in send() and sendSyncMessage() // don't save anything to the database. m.doNotSave = true; await m.send(promise); }); // If we've gotten here with no error, we exit! window.log.info( `modifyGroupV2/${idLog}: Update complete, with attempt ${attempt}!` ); break; } catch (error) { if (error.code === 409 && Date.now() <= timeoutTime) { window.log.info( `modifyGroupV2/${idLog}: Conflict while updating. Trying again...` ); // eslint-disable-next-line no-await-in-loop await this.fetchLatestGroupV2Data(); } else if (error.code === 409) { window.log.error( `modifyGroupV2/${idLog}: Conflict while updating. Timed out; not retrying.` ); // We don't wait here because we're breaking out of the loop immediately. this.fetchLatestGroupV2Data(); throw error; } else { const errorString = error && error.stack ? error.stack : error; window.log.error( `modifyGroupV2/${idLog}: Error updating: ${errorString}` ); throw error; } } } } 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); } isGroupV1AndDisabled(): boolean { return ( this.isGroupV1() && window.Signal.RemoteConfig.isEnabled('desktop.disableGV1') ); } 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('block'); } } 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('unblock'); } 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('profileSharing'); } } 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('profileSharing'); } } 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) { const bodyRanges = this.get('draftBodyRanges') || []; return getTextWithMentions(bodyRanges, 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.isGroupV2()) { return; } await window.Signal.Groups.waitThenMaybeUpdateGroup({ conversation: this, }); } isValid(): boolean { return this.isPrivate() || this.isGroupV1() || this.isGroupV2(); } async maybeMigrateV1Group(): Promise { if (!this.isGroupV1()) { return; } const isMigrated = await window.Signal.Groups.hasV1GroupBeenMigrated(this); if (!isMigrated) { return; } await window.Signal.Groups.waitThenRespondToGroupV2Migration({ 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( options: { groupChange?: ArrayBuffer; includePendingMembers?: boolean } = {} ): GroupV2InfoType | undefined { const { groupChange, includePendingMembers } = options; if (this.isPrivate() || !this.isGroupV2()) { 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({ includePendingMembers, }), 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(), }; } getGroupIdBuffer(): ArrayBuffer | undefined { const groupIdString = this.get('groupId'); if (!groupIdString) { return undefined; } if (this.isGroupV1()) { return fromEncodedBinaryToArrayBuffer(groupIdString); } if (this.isGroupV2()) { return base64ToArrayBuffer(groupIdString); } return undefined; } 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.getGroupIdBuffer(); 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. addIncomingMessage(message: MessageModel): void { if (!this.incomingMessageQueue) { this.incomingMessageQueue = new window.PQueue({ concurrency: 1, timeout: 1000 * 60 * 2, }); } // We use a queue here to ensure messages are added to the UI in the order received this.incomingMessageQueue.add(async () => { await this.inProgressFetch; this.addSingleMessage(message); }); } format(): ConversationType { if (this.cachedProps) { return this.cachedProps; } const oldFormat = this.format; // We don't want to crash or have an infinite loop if we loop back into this function // again. We'll log a warning and returned old cached props or throw an error. this.format = () => { const { stack } = new Error('for stack'); window.log.warn( `Conversation.format()/${this.idForLogging()} reentrant call! ${stack}` ); if (!this.oldCachedProps) { throw new Error( `Conversation.format()/${this.idForLogging()} reentrant call, no old cached props!` ); } return this.oldCachedProps; }; this.cachedProps = this.getProps(); this.format = oldFormat; return this.cachedProps; } // Note: this should never be called directly. Use conversation.format() instead, which // maintains a cache, and protects against reentrant calls. // Note: When writing code inside this function, do not call .format() on a conversation // unless you are sure that it's not this very same conversation. private getProps(): ConversationType { // 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 draftBodyRanges = this.get('draftBodyRanges'); const shouldShowDraft = (this.hasDraft() && draftTimestamp && draftTimestamp >= timestamp) as boolean; const inboxPosition = this.get('inbox_position'); const messageRequestsEnabled = window.Signal.RemoteConfig.isEnabled( 'desktop.messageRequests' ); const ourConversationId = window.ConversationController.getOurConversationId(); let groupVersion: undefined | 1 | 2; if (this.isGroupV1()) { groupVersion = 1; } else if (this.isGroupV2()) { groupVersion = 2; } const sortedGroupMembers = this.isGroupV2() ? this.getMembers() .sort((left, right) => sortConversationTitles(left, right, this.intlCollator) ) .map(member => member.format()) .filter((member): member is ConversationType => member !== null) : undefined; // TODO: DESKTOP-720 /* eslint-disable @typescript-eslint/no-non-null-assertion */ const result: ConversationType = { id: this.id, uuid: this.get('uuid'), e164: this.get('e164'), about: this.getAboutText(), acceptedMessageRequest: this.getAccepted(), activeAt: this.get('active_at')!, areWePending: Boolean( ourConversationId && this.isMemberPending(ourConversationId) ), areWeAdmin: this.areWeAdmin(), canChangeTimer: this.canChangeTimer(), canEditGroupInfo: this.canEditGroupInfo(), avatarPath: this.getAvatarPath()!, color, draftBodyRanges, draftPreview, draftText, firstName: this.get('profileName')!, groupVersion, groupId: this.get('groupId'), groupLink: this.getGroupLink(), inboxPosition, isArchived: this.get('isArchived')!, isBlocked: this.isBlocked(), isMe: this.isMe(), isGroupV1AndDisabled: this.isGroupV1AndDisabled(), isPinned: this.get('isPinned'), isMissingMandatoryProfileSharing: this.isMissingRequiredProfileSharing(), isUntrusted: this.isUntrusted(), isVerified: this.isVerified(), lastMessage: { status: this.get('lastMessageStatus')!, text: this.get('lastMessage')!, deletedForEveryone: this.get('lastMessageDeletedForEveryone')!, }, lastUpdated: this.get('timestamp')!, left: Boolean(this.get('left')), markedUnread: this.get('markedUnread')!, membersCount: this.isPrivate() ? undefined : (this.get('membersV2')! || this.get('members')! || []).length, memberships: this.getMemberships(), pendingMemberships: this.getPendingMemberships(), pendingApprovalMemberships: this.getPendingApprovalMemberships(), messageRequestsEnabled, accessControlAddFromInviteLink: this.get('accessControl') ?.addFromInviteLink, accessControlAttributes: this.get('accessControl')?.attributes, accessControlMembers: this.get('accessControl')?.members, expireTimer: this.get('expireTimer'), muteExpiresAt: this.get('muteExpiresAt')!, name: this.get('name')!, phoneNumber: this.getNumber()!, profileName: this.getProfileName()!, publicParams: this.get('publicParams'), secretParams: this.get('secretParams'), sharedGroupNames: this.get('sharedGroupNames')!, shouldShowDraft, sortedGroupMembers, timestamp, title: this.getTitle()!, type: (this.isPrivate() ? 'direct' : 'group') as ConversationTypeType, unreadCount: this.get('unreadCount')! || 0, }; if (typingContact) { // We don't want to call .format() on our own conversation if (typingContact.id === this.id) { result.typingContact = result; } else { result.typingContact = typingContact.format(); } } /* 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( options: { isLocalAction?: boolean } = {} ): Promise { const { isLocalAction } = options; 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, sentAt: first ? first.get('sent_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(), })); if (isLocalAction) { // 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(async m => { const registered = window.MessageController.register(m.id, m); const shouldSave = await registered.queueAttachmentDownloads(); if (shouldSave) { await window.Signal.Data.saveMessage(registered.attributes, { Message: window.Whisper.Message, }); } }) ); } while (messages.length > 0); } async applyMessageRequestResponse( response: number, { fromSync = false, viaStorageServiceSync = false } = {} ): Promise { try { const messageRequestEnum = window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; const isLocalAction = !fromSync && !viaStorageServiceSync; const ourConversationId = window.ConversationController.getOurConversationId(); const currentMessageRequestState = this.get('messageRequestResponseType'); const didResponseChange = response !== currentMessageRequestState; const wasPreviouslyAccepted = this.getAccepted(); // Apply message request response locally this.set({ messageRequestResponseType: response, }); if (response === messageRequestEnum.ACCEPT) { this.unblock({ viaStorageServiceSync }); this.enableProfileSharing({ viaStorageServiceSync }); // We really don't want to call this if we don't have to. It can take a lot of // time to go through old messages to download attachments. if (didResponseChange && !wasPreviouslyAccepted) { await this.handleReadAndDownloadAttachments({ isLocalAction }); } if (isLocalAction) { if (this.isGroupV1() || this.isPrivate()) { this.sendProfileKeyUpdate(); } else if ( ourConversationId && this.isGroupV2() && this.isMemberPending(ourConversationId) ) { await this.modifyGroupV2({ name: 'promotePendingMember', createGroupChange: () => this.promotePendingMember(ourConversationId), }); } else if ( ourConversationId && this.isGroupV2() && this.isMember(ourConversationId) ) { window.log.info( 'applyMessageRequestResponse/accept: Already a member of v2 group' ); } else { window.log.error( 'applyMessageRequestResponse/accept: Neither member nor pending member of v2 group' ); } } } else if (response === messageRequestEnum.BLOCK) { // Block locally, other devices should block upon receiving the sync message this.block({ viaStorageServiceSync }); this.disableProfileSharing({ viaStorageServiceSync }); if (isLocalAction) { if (this.isGroupV1() || this.isPrivate()) { await this.leaveGroup(); } else if (this.isGroupV2()) { await this.leaveGroupV2(); } } } else if (response === messageRequestEnum.DELETE) { this.disableProfileSharing({ viaStorageServiceSync }); // Delete messages locally, other devices should delete upon receiving // the sync message await this.destroyMessages(); this.updateLastMessage(); if (isLocalAction) { this.trigger('unload', 'deleted from message request'); if (this.isGroupV1() || this.isPrivate()) { await this.leaveGroup(); } else if (this.isGroupV2()) { await this.leaveGroupV2(); } } } else if (response === messageRequestEnum.BLOCK_AND_DELETE) { // Block locally, other devices should block upon receiving the sync message this.block({ viaStorageServiceSync }); this.disableProfileSharing({ viaStorageServiceSync }); // Delete messages locally, other devices should delete upon receiving // the sync message await this.destroyMessages(); this.updateLastMessage(); if (isLocalAction) { this.trigger('unload', 'blocked and deleted from message request'); if (this.isGroupV1() || this.isPrivate()) { await this.leaveGroup(); } else if (this.isGroupV2()) { await this.leaveGroupV2(); } } } } finally { window.Signal.Data.updateConversation(this.attributes); } } async leaveGroupV2(): Promise { const ourConversationId = window.ConversationController.getOurConversationId(); if ( ourConversationId && this.isGroupV2() && this.isMemberPending(ourConversationId) ) { await this.modifyGroupV2({ name: 'delete', createGroupChange: () => this.removePendingMember([ourConversationId]), }); } else if ( ourConversationId && this.isGroupV2() && this.isMember(ourConversationId) ) { await this.modifyGroupV2({ name: 'delete', createGroupChange: () => this.removeMember(ourConversationId), }); } else { window.log.error( 'leaveGroupV2: We were neither a member nor a pending member of the group' ); } } async toggleAdmin(conversationId: string): Promise { if (!this.isGroupV2()) { return; } if (!this.isMember(conversationId)) { window.log.error( `toggleAdmin: Member ${conversationId} is not a member of the group` ); return; } await this.modifyGroupV2({ name: 'toggleAdmin', createGroupChange: () => this.toggleAdminChange(conversationId), }); } async approvePendingMembershipFromGroupV2( conversationId: string ): Promise { if (this.isGroupV2() && this.isMemberRequestingToJoin(conversationId)) { await this.modifyGroupV2({ name: 'approvePendingApprovalRequest', createGroupChange: () => this.approvePendingApprovalRequest(conversationId), }); } } async revokePendingMembershipsFromGroupV2( conversationIds: Array ): Promise { if (!this.isGroupV2()) { return; } const [conversationId] = conversationIds; // Only pending memberships can be revoked for multiple members at once if (conversationIds.length > 1) { await this.modifyGroupV2({ name: 'removePendingMember', createGroupChange: () => this.removePendingMember(conversationIds), }); } else if (this.isMemberRequestingToJoin(conversationId)) { await this.modifyGroupV2({ name: 'denyPendingApprovalRequest', createGroupChange: () => this.denyPendingApprovalRequest(conversationId), }); } else if (this.isMemberPending(conversationId)) { await this.modifyGroupV2({ name: 'removePendingMember', createGroupChange: () => this.removePendingMember([conversationId]), }); } } async removeFromGroupV2(conversationId: string): Promise { if (this.isGroupV2() && this.isMemberRequestingToJoin(conversationId)) { await this.modifyGroupV2({ name: 'denyPendingApprovalRequest', createGroupChange: () => this.denyPendingApprovalRequest(conversationId), }); } else if (this.isGroupV2() && this.isMemberPending(conversationId)) { await this.modifyGroupV2({ name: 'removePendingMember', createGroupChange: () => this.removePendingMember([conversationId]), }); } else if (this.isGroupV2() && this.isMember(conversationId)) { await this.modifyGroupV2({ name: 'removeFromGroup', createGroupChange: () => this.removeMember(conversationId), }); } else { window.log.error( `removeFromGroupV2: Member ${conversationId} is neither a member nor a pending member of the group` ); } } async syncMessageRequestResponse(response: number): Promise { // In GroupsV2, this may modify the server. We only want to continue if those // server updates were successful. 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, } ); const groupId = this.getGroupIdBuffer(); try { await wrap( window.textsecure.messaging.syncMessageRequestResponse( { threadE164: this.get('e164'), threadUuid: this.get('uuid'), groupId, type: response, }, sendOptions ) ); } catch (result) { this.processSendResponse(result); } } // We only want to throw if there's a 'real' error contained with this information // coming back from our low-level send infrastructure. processSendResponse( result: Error | CallbackResultType ): result is CallbackResultType { if (result instanceof Error) { throw result; } else if (result && result.errors) { // We filter out unregistered user errors, because we ignore those in groups const wasThereARealError = window._.some( result.errors, error => error.name !== 'UnregisteredUserError' ); if (wasThereARealError) { throw result; } return true; } return true; } 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('verified'); } // 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); } safeIsUntrusted(): boolean { try { return window.textsecure.storage.protocol.isUntrusted(this.id); } catch (err) { return false; } } isUntrusted(): boolean { if (this.isPrivate()) { return this.safeIsUntrusted(); } // 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!.any(contact => { if (contact.isMe()) { return false; } return contact.safeIsUntrusted(); }); } getUntrusted(): Backbone.Collection { if (this.isPrivate()) { if (this.isUntrusted()) { return new window.Backbone.Collection([this]); } return new window.Backbone.Collection(); } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const results = this.contactCollection!.map(contact => { if (contact.isMe()) { return [false, contact]; } return [contact.isUntrusted(), contact]; }); return new window.Backbone.Collection( results.filter(result => result[0]).map(result => result[1]) ); } getSentMessageCount(): number { return this.get('sentMessageCount') || 0; } getMessageRequestResponseType(): number { return this.get('messageRequestResponseType') || 0; } isMissingRequiredProfileSharing(): boolean { const mandatoryProfileSharingEnabled = window.Signal.RemoteConfig.isEnabled( 'desktop.mandatoryProfileSharing' ); if (!mandatoryProfileSharingEnabled) { return false; } const hasNoMessages = (this.get('messageCount') || 0) === 0; if (hasNoMessages) { return false; } return !this.get('profileSharing'); } getAboutText(): string | undefined { if (!this.get('about')) { return undefined; } return window.i18n('message--getNotificationText--text-with-emoji', { text: this.get('about'), emoji: this.get('aboutEmoji'), }); } /** * 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; } const messageRequestEnum = window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; if (this.getMessageRequestResponseType() === 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, }) ); const isUntrusted = await this.isUntrusted(); this.trigger('newmessage', model); const uuid = this.get('uuid'); // Group calls are always with folks that have a UUID if (isUntrusted && uuid) { window.reduxActions.calling.keyChanged({ uuid }); } } 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: CallHistoryDetailsType ): Promise { let timestamp: number; let unread: boolean; let detailsToSave: CallHistoryDetailsType; switch (callHistoryDetails.callMode) { case CallMode.Direct: timestamp = callHistoryDetails.endedTime; unread = !callHistoryDetails.wasDeclined && !callHistoryDetails.acceptedTime; detailsToSave = { ...callHistoryDetails, callMode: CallMode.Direct, }; break; case CallMode.Group: timestamp = callHistoryDetails.startedTime; unread = false; detailsToSave = callHistoryDetails; break; default: throw missingCaseError(callHistoryDetails); } const message = ({ conversationId: this.id, type: 'call-history', sent_at: timestamp, received_at: timestamp, unread, callHistoryDetails: detailsToSave, // 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 updateCallHistoryForGroupCall( eraId: string, creatorUuid: string ): Promise { // We want to update the cache quickly in case this function is called multiple times. const oldCachedEraId = this.cachedLatestGroupCallEraId; this.cachedLatestGroupCallEraId = eraId; const alreadyHasMessage = (oldCachedEraId && oldCachedEraId === eraId) || (await window.Signal.Data.hasGroupCallHistoryMessage(this.id, eraId)); if (!alreadyHasMessage) { this.addCallHistory({ callMode: CallMode.Group, creatorUuid, eraId, startedTime: Date.now(), }); } } 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); } isAdmin(conversationId: string): boolean { if (!this.isGroupV2()) { return false; } const members = this.get('membersV2') || []; const member = members.find(x => x.conversationId === conversationId); if (!member) { return false; } const MEMBER_ROLES = window.textsecure.protobuf.Member.Role; return member.role === MEMBER_ROLES.ADMINISTRATOR; } getMemberships(): Array { if (!this.isGroupV2()) { return []; } const members = this.get('membersV2') || []; return members .map(member => { const conversationModel = window.ConversationController.get( member.conversationId ); if (!conversationModel || conversationModel.isUnregistered()) { return null; } return { isAdmin: member.role === window.textsecure.protobuf.Member.Role.ADMINISTRATOR, metadata: member, member: conversationModel.format(), }; }) .filter( (membership): membership is GroupV2Membership => membership !== null ); } getGroupLink(): string | undefined { if (!this.isGroupV2()) { return undefined; } if (!this.get('groupInviteLinkPassword')) { return undefined; } return window.Signal.Groups.buildGroupLink(this); } getPendingMemberships(): Array { if (!this.isGroupV2()) { return []; } const members = this.get('pendingMembersV2') || []; return members .map(member => { const conversationModel = window.ConversationController.get( member.conversationId ); if (!conversationModel || conversationModel.isUnregistered()) { return null; } return { metadata: member, member: conversationModel.format(), }; }) .filter( (membership): membership is GroupV2PendingMembership => membership !== null ); } getPendingApprovalMemberships(): Array { if (!this.isGroupV2()) { return []; } const members = this.get('pendingAdminApprovalV2') || []; return members .map(member => { const conversationModel = window.ConversationController.get( member.conversationId ); if (!conversationModel || conversationModel.isUnregistered()) { return null; } return { metadata: member, member: conversationModel.format(), }; }) .filter( (membership): membership is GroupV2RequestingMembership => membership !== null ); } getMembers( options: { includePendingMembers?: boolean } = {} ): Array { if (this.isPrivate()) { return [this]; } if (this.get('membersV2')) { const { includePendingMembers } = options; const members: Array<{ conversationId: string }> = includePendingMembers ? [ ...(this.get('membersV2') || []), ...(this.get('pendingMembersV2') || []), ] : this.get('membersV2') || []; return window._.compact( members.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 send 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( options: { includePendingMembers?: boolean } = {} ): Array { const { includePendingMembers } = options; const members = this.getMembers({ includePendingMembers }); // 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 { 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); // We need this content type to be an image so we can display an `` instead of a // `