// Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { compact, has, isNumber, throttle, debounce, head, sortBy, } from 'lodash'; import { batch as batchDispatch } from 'react-redux'; import { v4 as generateGuid } from 'uuid'; import PQueue from 'p-queue'; import type { ReadonlyDeep } from 'type-fest'; import type { ConversationAttributesType, ConversationLastProfileType, ConversationRenderInfoType, MessageAttributesType, QuotedMessageType, SenderKeyInfoType, } from '../model-types.d'; import { drop } from '../util/drop'; import { isShallowEqual } from '../util/isShallowEqual'; import { memoizeByThis } from '../util/memoizeByThis'; import { getInitials } from '../util/getInitials'; import { normalizeUuid } from '../util/normalizeUuid'; import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary'; import type { AttachmentType, ThumbnailType } from '../types/Attachment'; import { toDayMillis } from '../util/timestamp'; import { isGIF } from '../types/Attachment'; import type { CallHistoryDetailsType } from '../types/Calling'; import { CallMode } from '../types/Calling'; import * as EmbeddedContact from '../types/EmbeddedContact'; import * as Conversation from '../types/Conversation'; import type { StickerType, StickerWithHydratedData } from '../types/Stickers'; import * as Stickers from '../types/Stickers'; import { StorySendMode } from '../types/Stories'; import type { ContactWithHydratedAvatar, GroupV1InfoType, GroupV2InfoType, } from '../textsecure/SendMessage'; import createTaskWithTimeout from '../textsecure/TaskWithTimeout'; import MessageSender from '../textsecure/SendMessage'; import type { CallbackResultType, PniSignatureMessageType, } from '../textsecure/Types.d'; import type { ConversationType, LastMessageType, } from '../state/ducks/conversations'; import type { AvatarColorType, ConversationColorType, CustomColorType, } from '../types/Colors'; import type { MessageModel } from './messages'; import { getContact } from '../messages/helpers'; import { assertDev, strictAssert } from '../util/assert'; import { isConversationMuted } from '../util/isConversationMuted'; import { isConversationSMSOnly } from '../util/isConversationSMSOnly'; import { isConversationEverUnregistered, isConversationUnregistered, isConversationUnregisteredAndStale, } from '../util/isConversationUnregistered'; import { missingCaseError } from '../util/missingCaseError'; import { sniffImageMimeType } from '../util/sniffImageMimeType'; import { isValidE164 } from '../util/isValidE164'; import type { MIMEType } from '../types/MIME'; import { IMAGE_JPEG, IMAGE_GIF, IMAGE_WEBP } from '../types/MIME'; import { UUID, UUIDKind } from '../types/UUID'; import type { UUIDStringType } from '../types/UUID'; import { constantTimeEqual, decryptProfile, decryptProfileName, deriveAccessKey, } from '../Crypto'; import * as Bytes from '../Bytes'; import type { BodyRangesType, DraftBodyRangesType } from '../types/Util'; import { getTextWithMentions } from '../util/getTextWithMentions'; import { migrateColor } from '../util/migrateColor'; import { isNotNil } from '../util/isNotNil'; import { dropNull } from '../util/dropNull'; import { notificationService } from '../services/notifications'; import { storageServiceUploadJob } from '../services/storage'; import { scheduleOptimizeFTS } from '../services/ftsOptimizer'; import { getSendOptions } from '../util/getSendOptions'; import { isConversationAccepted } from '../util/isConversationAccepted'; import { getNumber, getProfileName, getTitle, getTitleNoDefault, } from '../util/getTitle'; import { markConversationRead } from '../util/markConversationRead'; import { handleMessageSend } from '../util/handleMessageSend'; import { getConversationMembers } from '../util/getConversationMembers'; import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup'; import { ReadStatus } from '../messages/MessageReadStatus'; import { SendStatus } from '../messages/MessageSendState'; import type { LinkPreviewType } from '../types/message/LinkPreviews'; import { MINUTE, SECOND, DurationInSeconds } from '../util/durations'; import { concat, filter, map, take, repeat, zipObject, collect, } from '../util/iterables'; import * as universalExpireTimer from '../util/universalExpireTimer'; import type { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions'; import { isDirectConversation, isGroup, isGroupV1, isGroupV2, isMe, } from '../util/whatTypeOfConversation'; import { SignalService as Proto } from '../protobuf'; import { getMessagePropStatus, hasErrors, isGiftBadge, isIncoming, isStory, isTapToView, } from '../state/selectors/message'; import { conversationJobQueue, conversationQueueJobEnum, } from '../jobs/conversationJobQueue'; import { readReceiptsJobQueue } from '../jobs/readReceiptsJobQueue'; import type { ReactionModel } from '../messageModifiers/Reactions'; import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady'; import { getProfile } from '../util/getProfile'; import { SEALED_SENDER } from '../types/SealedSender'; import { getAvatarData } from '../util/getAvatarData'; import { createIdenticon } from '../util/createIdenticon'; import * as log from '../logging/log'; import * as Errors from '../types/errors'; import { isMessageUnread } from '../util/isMessageUnread'; import type { SenderKeyTargetType } from '../util/sendToGroup'; import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue'; import { TimelineMessageLoadingState } from '../util/timelineUtil'; import { SeenStatus } from '../MessageSeenStatus'; import { getConversationIdForLogging } from '../util/idForLogging'; import { getSendTarget } from '../util/getSendTarget'; import { getRecipients } from '../util/getRecipients'; import { validateConversation } from '../util/validateConversation'; import { isSignalConversation } from '../util/isSignalConversation'; import { isMemberRequestingToJoin } from '../util/isMemberRequestingToJoin'; import { removePendingMember } from '../util/removePendingMember'; import { isMemberPending } from '../util/isMemberPending'; import { imageToBlurHash } from '../util/imageToBlurHash'; const EMPTY_ARRAY: Readonly<[]> = []; const EMPTY_GROUP_COLLISIONS: GroupNameCollisionsWithIdsByTitle = {}; /* eslint-disable more/no-then */ window.Whisper = window.Whisper || {}; const { Util } = window.Signal; const { Message } = window.Signal.Types; const { deleteAttachmentData, doesAttachmentExist, getAbsoluteAttachmentPath, loadAttachmentData, readStickerData, upgradeMessageSchema, writeNewAttachmentData, } = window.Signal.Migrations; const { addStickerPackReference, getConversationRangeCenteredOnMessage, getOlderMessagesByConversation, getMessageMetricsForConversation, getMessageById, getNewerMessagesByConversation, } = window.Signal.Data; const FIVE_MINUTES = MINUTE * 5; const JOB_REPORTING_THRESHOLD_MS = 25; const SEND_REPORTING_THRESHOLD_MS = 25; const MESSAGE_LOAD_CHUNK_SIZE = 30; const ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE = new Set([ 'lastProfile', 'profileLastFetchedAt', 'needsStorageServiceSync', 'storageID', 'storageVersion', 'storageUnknownFields', ]); type CachedIdenticon = { readonly url: string; readonly content: string; readonly color: AvatarColorType; }; export class ConversationModel extends window.Backbone .Model { static COLORS: string; cachedProps?: ConversationType | null; oldCachedProps?: ConversationType | null; contactTypingTimers?: Record< string, { senderId: string; timer: NodeJS.Timer } >; contactCollection?: Backbone.Collection; debouncedUpdateLastMessage?: (() => void) & { flush(): void }; initialPromise?: Promise; inProgressFetch?: Promise; newMessageQueue?: PQueue; jobQueue?: PQueue; storeName?: string | null; throttledBumpTyping?: () => void; throttledFetchSMSOnlyUUID?: () => Promise | undefined; throttledMaybeMigrateV1Group?: () => Promise | undefined; throttledGetProfiles?: () => Promise; throttledUpdateVerified?: () => void; typingRefreshTimer?: NodeJS.Timer | null; typingPauseTimer?: NodeJS.Timer | null; intlCollator = new Intl.Collator(undefined, { sensitivity: 'base' }); lastSuccessfulGroupFetch?: number; throttledUpdateSharedGroups?: () => Promise; private cachedLatestGroupCallEraId?: string; private cachedIdenticon?: CachedIdenticon; private isFetchingUUID?: boolean; private lastIsTyping?: boolean; private muteTimer?: NodeJS.Timer; private isInReduxBatch = false; private privVerifiedEnum?: typeof window.textsecure.storage.protocol.VerifiedStatus; override defaults(): Partial { return { unreadCount: 0, verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT, messageCount: 0, sentMessageCount: 0, }; } idForLogging(): string { return getConversationIdForLogging(this.attributes); } // 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 getSendTarget(this.attributes); } getContactCollection(): Backbone.Collection { const collection = new window.Backbone.Collection(); const collator = new Intl.Collator(undefined, { sensitivity: 'base' }); collection.comparator = ( left: ConversationModel, right: ConversationModel ) => { return collator.compare(left.getTitle(), right.getTitle()); }; return collection; } constructor(attributes: ConversationAttributesType) { super(attributes); // Note that we intentionally don't use `initialize()` method because it // isn't compatible with esnext output of esbuild. const uuid = this.get('uuid'); const normalizedUuid = uuid && normalizeUuid(uuid, 'ConversationModel.initialize'); if (uuid && normalizedUuid !== uuid) { log.warn( 'ConversationModel.initialize: normalizing uuid from ' + `${uuid} to ${normalizedUuid}` ); this.set('uuid', normalizedUuid); } if (isValidE164(attributes.id, false)) { this.set({ id: UUID.generate().toString(), e164: attributes.id }); } this.storeName = 'conversations'; this.privVerifiedEnum = 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.debouncedUpdateLastMessage = debounce( this.updateLastMessage.bind(this), 200 ); this.contactCollection = this.getContactCollection(); this.contactCollection.on( 'change:name change:profileName change:profileFamilyName change:e164', this.debouncedUpdateLastMessage, this ); if (!isDirectConversation(this.attributes)) { this.contactCollection.on( 'change:verified', this.onMemberVerifiedChange.bind(this) ); } this.on('newmessage', this.onNewMessage); this.on('change:profileKey', this.onChangeProfileKey); 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.on('change:members change:membersV2', this.fetchContacts); 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', (_model: MessageModel, options: { force?: boolean } = {}) => { const changedKeys = Object.keys(this.changed || {}); const isPropsCacheStillValid = !options.force && Boolean( changedKeys.length && changedKeys.every(key => ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE.has(key) ) ); if (isPropsCacheStillValid) { return; } if (this.cachedProps) { this.oldCachedProps = this.cachedProps; } this.cachedProps = null; this.trigger('props-change', this, this.isInReduxBatch); } ); // Set `isFetchingUUID` eagerly to avoid UI flicker when opening the // conversation for the first time. this.isFetchingUUID = this.isSMSOnly(); this.throttledBumpTyping = throttle(this.bumpTyping, 300); this.throttledUpdateSharedGroups = throttle( this.updateSharedGroups.bind(this), FIVE_MINUTES ); this.throttledFetchSMSOnlyUUID = throttle( this.fetchSMSOnlyUUID.bind(this), FIVE_MINUTES ); this.throttledMaybeMigrateV1Group = throttle( this.maybeMigrateV1Group.bind(this), FIVE_MINUTES ); this.throttledGetProfiles = throttle( this.getProfiles.bind(this), FIVE_MINUTES ); this.throttledUpdateVerified = throttle( this.updateVerified.bind(this), SECOND ); this.on('newmessage', this.throttledUpdateVerified); const migratedColor = this.getColor(); if (this.get('color') !== migratedColor) { this.set('color', migratedColor); // Not saving the conversation here we're hoping it'll be saved elsewhere // this may cause some color thrashing if Signal is restarted without // the convo saving. If that is indeed the case and it's too disruptive // we should add batched saving. } } toSenderKeyTarget(): SenderKeyTargetType { return { getGroupId: () => this.get('groupId'), getMembers: () => this.getMembers(), hasMember: (uuid: UUIDStringType) => this.hasMember(new UUID(uuid)), idForLogging: () => this.idForLogging(), isGroupV2: () => isGroupV2(this.attributes), isValid: () => isGroupV2(this.attributes), getSenderKeyInfo: () => this.get('senderKeyInfo'), saveSenderKeyInfo: async (senderKeyInfo: SenderKeyInfoType) => { this.set({ senderKeyInfo }); window.Signal.Data.updateConversation(this.attributes); }, }; } private get verifiedEnum(): typeof window.textsecure.storage.protocol.VerifiedStatus { strictAssert(this.privVerifiedEnum, 'ConversationModel not initialize'); return this.privVerifiedEnum; } private isMemberRequestingToJoin(uuid: UUID): boolean { return isMemberRequestingToJoin(this.attributes, uuid); } isMemberPending(uuid: UUID): boolean { return isMemberPending(this.attributes, uuid); } private isMemberBanned(uuid: UUID): boolean { if (!isGroupV2(this.attributes)) { return false; } const bannedMembersV2 = this.get('bannedMembersV2'); if (!bannedMembersV2 || !bannedMembersV2.length) { return false; } return bannedMembersV2.some(member => member.uuid === uuid.toString()); } isMemberAwaitingApproval(uuid: UUID): boolean { if (!isGroupV2(this.attributes)) { return false; } const pendingAdminApprovalV2 = this.get('pendingAdminApprovalV2'); if (!pendingAdminApprovalV2 || !pendingAdminApprovalV2.length) { return false; } return pendingAdminApprovalV2.some( member => member.uuid === uuid.toString() ); } isMember(uuid: UUID): boolean { if (!isGroupV2(this.attributes)) { return false; } const membersV2 = this.get('membersV2'); if (!membersV2 || !membersV2.length) { return false; } return membersV2.some(item => item.uuid === uuid.toString()); } async updateExpirationTimerInGroupV2( seconds?: DurationInSeconds ): Promise { const idLog = this.idForLogging(); const current = this.get('expireTimer'); const bothFalsey = Boolean(current) === false && Boolean(seconds) === false; if (current === seconds || bothFalsey) { log.warn( `updateExpirationTimerInGroupV2/${idLog}: Requested timer ${seconds} is unchanged from existing ${current}.` ); return undefined; } return window.Signal.Groups.buildDisappearingMessagesTimerChange({ expireTimer: seconds || DurationInSeconds.ZERO, group: this.attributes, }); } private async promotePendingMember( uuidKind: UUIDKind ): Promise { const idLog = this.idForLogging(); const us = window.ConversationController.getOurConversationOrThrow(); const uuid = window.storage.user.getCheckedUuid(uuidKind); // 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(uuid)) { log.warn( `promotePendingMember/${idLog}: we are not a pending member of group. Returning early.` ); return undefined; } // 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. if (!us.get('profileKeyCredential')) { await us.getProfiles(); } const profileKeyCredentialBase64 = us.get('profileKeyCredential'); strictAssert(profileKeyCredentialBase64, 'Must have profileKeyCredential'); if (uuidKind === UUIDKind.ACI) { return window.Signal.Groups.buildPromoteMemberChange({ group: this.attributes, isPendingPniAciProfileKey: false, profileKeyCredentialBase64, serverPublicParamsBase64: window.getServerPublicParams(), }); } strictAssert(uuidKind === UUIDKind.PNI, 'Must be a PNI promotion'); return window.Signal.Groups.buildPromoteMemberChange({ group: this.attributes, isPendingPniAciProfileKey: true, profileKeyCredentialBase64, serverPublicParamsBase64: window.getServerPublicParams(), }); } private async denyPendingApprovalRequest( uuid: UUID ): 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(uuid)) { log.warn( `denyPendingApprovalRequest/${idLog}: ${uuid} is not requesting ` + 'to join the group. Returning early.' ); return undefined; } const ourUuid = window.textsecure.storage.user.getCheckedUuid(UUIDKind.ACI); return window.Signal.Groups.buildDeletePendingAdminApprovalMemberChange({ group: this.attributes, ourUuid, uuid, }); } async addPendingApprovalRequest(): Promise< Proto.GroupChange.Actions | undefined > { const idLog = this.idForLogging(); // Hard-coded to our own ID, because you don't add other users for admin approval const conversationId = window.ConversationController.getOurConversationIdOrThrow(); const toRequest = window.ConversationController.get(conversationId); if (!toRequest) { throw new Error( `addPendingApprovalRequest/${idLog}: No conversation found for conversation ${conversationId}` ); } const uuid = toRequest.getCheckedUuid(`addPendingApprovalRequest/${idLog}`); // 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 = toRequest.get('profileKeyCredential'); if (!profileKeyCredentialBase64) { await toRequest.getProfiles(); profileKeyCredentialBase64 = toRequest.get('profileKeyCredential'); if (!profileKeyCredentialBase64) { throw new Error( `promotePendingMember/${idLog}: No profileKeyCredential for conversation ${toRequest.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.isMemberAwaitingApproval(uuid)) { log.warn( `addPendingApprovalRequest/${idLog}: ` + `${toRequest.idForLogging()} already in pending approval.` ); return undefined; } return window.Signal.Groups.buildAddPendingAdminApprovalMemberChange({ group: this.attributes, profileKeyCredentialBase64, serverPublicParamsBase64: window.getServerPublicParams(), }); } async addMember(uuid: UUID): Promise { const idLog = this.idForLogging(); const toRequest = window.ConversationController.get(uuid.toString()); if (!toRequest) { throw new Error(`addMember/${idLog}: No conversation found for ${uuid}`); } // 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 = toRequest.get('profileKeyCredential'); if (!profileKeyCredentialBase64) { await toRequest.getProfiles(); profileKeyCredentialBase64 = toRequest.get('profileKeyCredential'); if (!profileKeyCredentialBase64) { throw new Error( `addMember/${idLog}: No profileKeyCredential for conversation ${toRequest.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(uuid)) { log.warn( `addMember/${idLog}: ${toRequest.idForLogging()} ` + 'is already a member.' ); return undefined; } return window.Signal.Groups.buildAddMember({ group: this.attributes, profileKeyCredentialBase64, serverPublicParamsBase64: window.getServerPublicParams(), uuid, }); } private async removePendingMember( uuids: ReadonlyArray ): Promise { return removePendingMember(this.attributes, uuids); } private async removeMember( uuid: UUID ): 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(uuid)) { log.warn( `removeMember/${idLog}: ${uuid} is not a pending member of group. Returning early.` ); return undefined; } const ourUuid = window.textsecure.storage.user.getCheckedUuid(UUIDKind.ACI); return window.Signal.Groups.buildDeleteMemberChange({ group: this.attributes, ourUuid, uuid, }); } private async toggleAdminChange( uuid: UUID ): Promise { if (!isGroupV2(this.attributes)) { return undefined; } const idLog = this.idForLogging(); if (!this.isMember(uuid)) { log.warn( `toggleAdminChange/${idLog}: ${uuid} is not a pending member of group. Returning early.` ); return undefined; } const MEMBER_ROLES = Proto.Member.Role; const role = this.isAdmin(uuid) ? MEMBER_ROLES.DEFAULT : MEMBER_ROLES.ADMINISTRATOR; return window.Signal.Groups.buildModifyMemberRoleChange({ group: this.attributes, uuid, role, }); } async modifyGroupV2({ usingCredentialsFrom, createGroupChange, extraConversationsForSend, inviteLinkPassword, name, }: { usingCredentialsFrom: ReadonlyArray; createGroupChange: () => Promise; extraConversationsForSend?: ReadonlyArray; inviteLinkPassword?: string; name: string; }): Promise { await window.Signal.Groups.modifyGroupV2({ conversation: this, usingCredentialsFrom, createGroupChange, extraConversationsForSend, inviteLinkPassword, name, }); } isEverUnregistered(): boolean { return isConversationEverUnregistered(this.attributes); } isUnregistered(): boolean { return isConversationUnregistered(this.attributes); } isUnregisteredAndStale(): boolean { return isConversationUnregisteredAndStale(this.attributes); } isSMSOnly(): boolean { return isConversationSMSOnly({ ...this.attributes, type: isDirectConversation(this.attributes) ? 'direct' : 'unknown', }); } setUnregistered({ timestamp = Date.now(), fromStorageService = false, shouldSave = true, }: { timestamp?: number; fromStorageService?: boolean; shouldSave?: boolean; } = {}): void { log.info( `Conversation ${this.idForLogging()} is now unregistered, ` + `timestamp=${timestamp}` ); const oldFirstUnregisteredAt = this.get('firstUnregisteredAt'); this.set({ // We always keep the latest `discoveredUnregisteredAt` because if it // was less than 6 hours ago - `isUnregistered()` has to return `false` // and let us retry sends. discoveredUnregisteredAt: Math.max( this.get('discoveredUnregisteredAt') ?? timestamp, timestamp ), // Here we keep the oldest `firstUnregisteredAt` unless timestamp is // coming from storage service where remote value always wins. firstUnregisteredAt: fromStorageService ? timestamp : Math.min(this.get('firstUnregisteredAt') ?? timestamp, timestamp), }); if (shouldSave) { window.Signal.Data.updateConversation(this.attributes); } if ( !fromStorageService && oldFirstUnregisteredAt !== this.get('firstUnregisteredAt') ) { this.captureChange('setUnregistered'); } } setRegistered({ shouldSave = true, fromStorageService = false, }: { shouldSave?: boolean; fromStorageService?: boolean; } = {}): void { if ( this.get('discoveredUnregisteredAt') === undefined && this.get('firstUnregisteredAt') === undefined ) { return; } const oldFirstUnregisteredAt = this.get('firstUnregisteredAt'); log.info(`Conversation ${this.idForLogging()} is registered once again`); this.set({ discoveredUnregisteredAt: undefined, firstUnregisteredAt: undefined, }); if (shouldSave) { window.Signal.Data.updateConversation(this.attributes); } if ( !fromStorageService && oldFirstUnregisteredAt !== this.get('firstUnregisteredAt') ) { this.captureChange('setRegistered'); } } isGroupV1AndDisabled(): boolean { return isGroupV1(this.attributes); } isBlocked(): boolean { const uuid = this.get('uuid'); if (uuid) { return window.storage.blocked.isUuidBlocked(uuid); } const e164 = this.get('e164'); if (e164) { return window.storage.blocked.isBlocked(e164); } const groupId = this.get('groupId'); if (groupId) { return window.storage.blocked.isGroupBlocked(groupId); } return false; } block({ viaStorageServiceSync = false } = {}): void { let blocked = false; const wasBlocked = this.isBlocked(); const uuid = this.get('uuid'); if (uuid) { drop(window.storage.blocked.addBlockedUuid(uuid)); blocked = true; } const e164 = this.get('e164'); if (e164) { drop(window.storage.blocked.addBlockedNumber(e164)); blocked = true; } const groupId = this.get('groupId'); if (groupId) { drop(window.storage.blocked.addBlockedGroup(groupId)); blocked = true; } if (blocked && !wasBlocked) { // We need to force a props refresh - blocked state is not in backbone attributes this.trigger('change', this, { force: true }); if (!viaStorageServiceSync) { this.captureChange('block'); } } } unblock({ viaStorageServiceSync = false } = {}): boolean { let unblocked = false; const wasBlocked = this.isBlocked(); const uuid = this.get('uuid'); if (uuid) { drop(window.storage.blocked.removeBlockedUuid(uuid)); unblocked = true; } const e164 = this.get('e164'); if (e164) { drop(window.storage.blocked.removeBlockedNumber(e164)); unblocked = true; } const groupId = this.get('groupId'); if (groupId) { drop(window.storage.blocked.removeBlockedGroup(groupId)); unblocked = true; } if (unblocked && wasBlocked) { // We need to force a props refresh - blocked state is not in backbone attributes this.trigger('change', this, { force: true }); if (!viaStorageServiceSync) { this.captureChange('unblock'); } void this.fetchLatestGroupV2Data({ force: true }); } return unblocked; } enableProfileSharing({ viaStorageServiceSync = false } = {}): void { log.info( `enableProfileSharing: ${this.idForLogging()} storage? ${viaStorageServiceSync}` ); const before = this.get('profileSharing'); this.set({ profileSharing: true }); const after = this.get('profileSharing'); if (!viaStorageServiceSync && Boolean(before) !== Boolean(after)) { this.captureChange('enableProfileSharing'); } } disableProfileSharing({ viaStorageServiceSync = false } = {}): void { log.info( `disableProfileSharing: ${this.idForLogging()} storage? ${viaStorageServiceSync}` ); const before = this.get('profileSharing'); this.set({ profileSharing: false }); const after = this.get('profileSharing'); if (!viaStorageServiceSync && Boolean(before) !== Boolean(after)) { this.captureChange('disableProfileSharing'); } } 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.Events.getTypingIndicatorSetting()) { return; } if (!this.typingRefreshTimer) { const isTyping = true; this.setTypingRefreshTimer(); void this.sendTypingMessage(isTyping); } this.setTypingPauseTimer(); } setTypingRefreshTimer(): void { clearTimeoutIfNecessary(this.typingRefreshTimer); this.typingRefreshTimer = setTimeout( this.onTypingRefreshTimeout.bind(this), 10 * 1000 ); } onTypingRefreshTimeout(): void { const isTyping = true; void this.sendTypingMessage(isTyping); // This timer will continue to reset itself until the pause timer stops it this.setTypingRefreshTimer(); } setTypingPauseTimer(): void { clearTimeoutIfNecessary(this.typingPauseTimer); this.typingPauseTimer = setTimeout( this.onTypingPauseTimeout.bind(this), 3 * 1000 ); } onTypingPauseTimeout(): void { const isTyping = false; void this.sendTypingMessage(isTyping); this.clearTypingTimers(); } clearTypingTimers(): void { clearTimeoutIfNecessary(this.typingPauseTimer); this.typingPauseTimer = null; clearTimeoutIfNecessary(this.typingRefreshTimer); this.typingRefreshTimer = null; } async fetchLatestGroupV2Data( options: { force?: boolean } = {} ): Promise { if (!isGroupV2(this.attributes)) { return; } await window.Signal.Groups.waitThenMaybeUpdateGroup({ force: options.force, conversation: this, }); } async fetchSMSOnlyUUID(): Promise { const { server } = window.textsecure; if (!server) { return; } if (!this.isSMSOnly()) { return; } log.info( `Fetching uuid for a sms-only conversation ${this.idForLogging()}` ); this.isFetchingUUID = true; this.trigger('change', this, { force: true }); try { // Attempt to fetch UUID await updateConversationsWithUuidLookup({ conversationController: window.ConversationController, conversations: [this], server, }); } finally { // No redux update here this.isFetchingUUID = false; this.trigger('change', this, { force: true }); log.info( `Done fetching uuid for a sms-only conversation ${this.idForLogging()}` ); } if (!this.get('uuid')) { return; } // On successful fetch - mark contact as registered. this.setRegistered(); } override isValid(): boolean { return ( isDirectConversation(this.attributes) || isGroupV1(this.attributes) || isGroupV2(this.attributes) ); } async maybeMigrateV1Group(): Promise { if (!isGroupV1(this.attributes)) { 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; } 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: Readonly< { groupChange?: Uint8Array } & ( | { includePendingMembers?: boolean; extraConversationsForSend?: ReadonlyArray; } | { members: ReadonlyArray } ) > = {} ): GroupV2InfoType | undefined { if (isDirectConversation(this.attributes) || !isGroupV2(this.attributes)) { return undefined; } return { masterKey: Bytes.fromBase64( // 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: 'members' in options ? options.members : this.getRecipients(options), groupChange: options.groupChange, }; } getGroupV1Info(members?: Array): GroupV1InfoType | undefined { const groupId = this.get('groupId'); const groupVersion = this.get('groupVersion'); if ( isDirectConversation(this.attributes) || !groupId || (groupVersion && groupVersion > 0) ) { return undefined; } return { id: groupId, members: members || this.getRecipients(), }; } getGroupIdBuffer(): Uint8Array | undefined { const groupIdString = this.get('groupId'); if (!groupIdString) { return undefined; } if (isGroupV1(this.attributes)) { return Bytes.fromBinary(groupIdString); } if (isGroupV2(this.attributes)) { return Bytes.fromBase64(groupIdString); } return undefined; } async sendTypingMessage(isTyping: boolean): Promise { const { messaging } = window.textsecure; if (!messaging) { return; } // We don't send typing messages to our other devices if (isMe(this.attributes)) { return; } // Coalesce multiple sendTypingMessage calls into one. // // `lastIsTyping` is set to the last `isTyping` value passed to the // `sendTypingMessage`. The first 'sendTypingMessage' job to run will // pick it and reset it back to `undefined` so that later jobs will // in effect be ignored. this.lastIsTyping = isTyping; await this.queueJob('sendTypingMessage', async () => { const groupMembers = this.getRecipients(); // We don't send typing messages if our recipients list is empty if (!isDirectConversation(this.attributes) && !groupMembers.length) { return; } if (this.lastIsTyping === undefined) { log.info(`sendTypingMessage(${this.idForLogging()}): ignoring`); return; } const recipientId = isDirectConversation(this.attributes) ? this.getSendTarget() : undefined; const groupId = this.getGroupIdBuffer(); const timestamp = Date.now(); const content = { recipientId, groupId, groupMembers, isTyping: this.lastIsTyping, timestamp, }; this.lastIsTyping = undefined; log.info( `sendTypingMessage(${this.idForLogging()}): sending ${content.isTyping}` ); const contentMessage = messaging.getTypingContentMessage(content); const { ContentHint } = Proto.UnidentifiedSenderMessage.Message; const sendOptions = { ...(await getSendOptions(this.attributes)), online: true, }; if (isDirectConversation(this.attributes)) { await handleMessageSend( messaging.sendMessageProtoAndWait({ contentHint: ContentHint.IMPLICIT, groupId: undefined, options: sendOptions, proto: contentMessage, recipients: groupMembers, timestamp, urgent: false, }), { messageIds: [], sendType: 'typing' } ); } else { await handleMessageSend( window.Signal.Util.sendContentMessageToGroup({ contentHint: ContentHint.IMPLICIT, contentMessage, messageId: undefined, online: true, recipients: groupMembers, sendOptions, sendTarget: this.toSenderKeyTarget(), sendType: 'typing', timestamp, urgent: false, }), { messageIds: [], sendType: 'typing' } ); } }); } async onNewMessage(message: MessageModel): Promise { const uuid = message.get('sourceUuid'); const e164 = message.get('source'); const sourceDevice = message.get('sourceDevice'); const source = window.ConversationController.lookupOrCreate({ uuid, e164, reason: 'ConversationModel.onNewMessage', }); const typingToken = `${source?.id}.${sourceDevice}`; // Clear typing indicator for a given contact if we receive a message from them this.clearContactTypingTimer(typingToken); // If it's a group story reply or a story message, we don't want to update // the last message or add new messages to redux. const isGroupStoryReply = isGroup(this.attributes) && message.get('storyId'); if (isGroupStoryReply || isStory(message.attributes)) { return; } void this.addSingleMessage(message); } // New messages might arrive while we're in the middle of a bulk fetch from the // database. We'll wait until that is done before moving forward. async addSingleMessage( message: MessageModel, { isJustSent }: { isJustSent: boolean } = { isJustSent: false } ): Promise { await this.beforeAddSingleMessage(message); this.doAddSingleMessage(message, { isJustSent }); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.debouncedUpdateLastMessage!(); } private async beforeAddSingleMessage(message: MessageModel): Promise { await message.hydrateStoryContext(); if (!this.newMessageQueue) { this.newMessageQueue = new PQueue({ concurrency: 1, timeout: MINUTE * 30, }); } // We use a queue here to ensure messages are added to the UI in the order received await this.newMessageQueue.add(async () => { await this.inProgressFetch; }); } private doAddSingleMessage( message: MessageModel, { isJustSent }: { isJustSent: boolean } ): void { const { messagesAdded } = window.reduxActions.conversations; const { conversations } = window.reduxStore.getState(); const { messagesByConversation } = conversations; const conversationId = this.id; const existingConversation = messagesByConversation[conversationId]; const newestId = existingConversation?.metrics?.newest?.id; const messageIds = existingConversation?.messageIds; const isLatestInMemory = newestId && messageIds && messageIds[messageIds.length - 1] === newestId; if (isJustSent && existingConversation && !isLatestInMemory) { void this.loadNewestMessages(undefined, undefined); } else if ( // The message has to be not a story or has to be a story reply in direct // conversation. !isStory(message.attributes) && (message.get('storyId') == null || isDirectConversation(this.attributes)) ) { messagesAdded({ conversationId, messages: [{ ...message.attributes }], isActive: window.SignalContext.activeWindowService.isActive(), isJustSent, isNewMessage: true, }); } } setInProgressFetch(): () => unknown { let resolvePromise: (value?: unknown) => void; this.inProgressFetch = new Promise(resolve => { resolvePromise = resolve; }); const finish = () => { resolvePromise(); this.inProgressFetch = undefined; }; return finish; } async loadNewestMessages( newestMessageId: string | undefined, setFocus: boolean | undefined ): Promise { const { messagesReset, setMessageLoadingState } = window.reduxActions.conversations; const conversationId = this.id; setMessageLoadingState( conversationId, TimelineMessageLoadingState.DoingInitialLoad ); const finish = this.setInProgressFetch(); try { let scrollToLatestUnread = true; if (newestMessageId) { const newestInMemoryMessage = await getMessageById(newestMessageId); if (newestInMemoryMessage) { // If newest in-memory message is unread, scrolling down would mean going to // the very bottom, not the oldest unread. if (isMessageUnread(newestInMemoryMessage)) { scrollToLatestUnread = false; } } else { log.warn( `loadNewestMessages: did not find message ${newestMessageId}` ); } } const metrics = await getMessageMetricsForConversation(conversationId, { includeStoryReplies: !isGroup(this.attributes), }); // If this is a message request that has not yet been accepted, we always show the // oldest messages, to ensure that the ConversationHero is shown. We don't want to // scroll directly to the oldest message, because that could scroll the hero off // the screen. if (!newestMessageId && !this.getAccepted() && metrics.oldest) { void this.loadAndScroll(metrics.oldest.id, { disableScroll: true }); return; } if (scrollToLatestUnread && metrics.oldestUnseen) { void this.loadAndScroll(metrics.oldestUnseen.id, { disableScroll: !setFocus, }); return; } const messages = await getOlderMessagesByConversation(conversationId, { includeStoryReplies: !isGroup(this.attributes), limit: MESSAGE_LOAD_CHUNK_SIZE, storyId: undefined, }); const cleaned: Array = await this.cleanModels(messages); const scrollToMessageId = setFocus && metrics.newest ? metrics.newest.id : undefined; // Because our `getOlderMessages` fetch above didn't specify a receivedAt, we got // the most recent N 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, messages: cleaned.map((messageModel: MessageModel) => ({ ...messageModel.attributes, })), metrics, scrollToMessageId, unboundedFetch, }); } catch (error) { setMessageLoadingState(conversationId, undefined); throw error; } finally { finish(); } } async loadOlderMessages(oldestMessageId: string): Promise { const { messagesAdded, setMessageLoadingState, repairOldestMessage } = window.reduxActions.conversations; const conversationId = this.id; setMessageLoadingState( conversationId, TimelineMessageLoadingState.LoadingOlderMessages ); const finish = this.setInProgressFetch(); try { const message = await getMessageById(oldestMessageId); if (!message) { throw new Error( `loadOlderMessages: failed to load message ${oldestMessageId}` ); } const receivedAt = message.received_at; const sentAt = message.sent_at; const models = await getOlderMessagesByConversation(conversationId, { includeStoryReplies: !isGroup(this.attributes), limit: MESSAGE_LOAD_CHUNK_SIZE, messageId: oldestMessageId, receivedAt, sentAt, storyId: undefined, }); if (models.length < 1) { log.warn('loadOlderMessages: requested, but loaded no messages'); repairOldestMessage(conversationId); return; } const cleaned = await this.cleanModels(models); messagesAdded({ conversationId, messages: cleaned.map((messageModel: MessageModel) => ({ ...messageModel.attributes, })), isActive: window.SignalContext.activeWindowService.isActive(), isJustSent: false, isNewMessage: false, }); } catch (error) { setMessageLoadingState(conversationId, undefined); throw error; } finally { finish(); } } async loadNewerMessages(newestMessageId: string): Promise { const { messagesAdded, setMessageLoadingState, repairNewestMessage } = window.reduxActions.conversations; const conversationId = this.id; setMessageLoadingState( conversationId, TimelineMessageLoadingState.LoadingNewerMessages ); const finish = this.setInProgressFetch(); try { const message = await getMessageById(newestMessageId); if (!message) { throw new Error( `loadNewerMessages: failed to load message ${newestMessageId}` ); } const receivedAt = message.received_at; const sentAt = message.sent_at; const models = await getNewerMessagesByConversation(conversationId, { includeStoryReplies: !isGroup(this.attributes), limit: MESSAGE_LOAD_CHUNK_SIZE, receivedAt, sentAt, storyId: undefined, }); if (models.length < 1) { log.warn('loadNewerMessages: requested, but loaded no messages'); repairNewestMessage(conversationId); return; } const cleaned = await this.cleanModels(models); messagesAdded({ conversationId, messages: cleaned.map((messageModel: MessageModel) => ({ ...messageModel.attributes, })), isActive: window.SignalContext.activeWindowService.isActive(), isJustSent: false, isNewMessage: false, }); } catch (error) { setMessageLoadingState(conversationId, undefined); throw error; } finally { finish(); } } async loadAndScroll( messageId: string, options?: { disableScroll?: boolean } ): Promise { const { messagesReset, setMessageLoadingState } = window.reduxActions.conversations; const conversationId = this.id; setMessageLoadingState( conversationId, TimelineMessageLoadingState.DoingInitialLoad ); const finish = this.setInProgressFetch(); try { const message = await getMessageById(messageId); if (!message) { throw new Error( `loadMoreAndScroll: failed to load message ${messageId}` ); } const receivedAt = message.received_at; const sentAt = message.sent_at; const { older, newer, metrics } = await getConversationRangeCenteredOnMessage({ conversationId, includeStoryReplies: !isGroup(this.attributes), limit: MESSAGE_LOAD_CHUNK_SIZE, messageId, receivedAt, sentAt, storyId: undefined, }); const all = [...older, message, ...newer]; const cleaned: Array = await this.cleanModels(all); const scrollToMessageId = options && options.disableScroll ? undefined : messageId; messagesReset({ conversationId, messages: cleaned.map((messageModel: MessageModel) => ({ ...messageModel.attributes, })), metrics, scrollToMessageId, }); } catch (error) { setMessageLoadingState(conversationId, undefined); throw error; } finally { finish(); } } async cleanModels( messages: ReadonlyArray ): Promise> { const result = messages .filter(message => Boolean(message.id)) .map(message => window.MessageController.register(message.id, message)); const eliminated = messages.length - result.length; if (eliminated > 0) { log.warn(`cleanModels: Eliminated ${eliminated} messages without an id`); } const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString(); let upgraded = 0; for (let max = result.length, i = 0; i < max; i += 1) { const message = result[i]; const { attributes } = message; const { schemaVersion } = attributes; if ((schemaVersion || 0) < 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, { ourUuid }); upgraded += 1; } } if (upgraded > 0) { log.warn(`cleanModels: Upgraded schema of ${upgraded} messages`); } await Promise.all(result.map(model => model.hydrateStoryContext())); return result; } 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 = () => { if (!this.oldCachedProps) { throw new Error( `Conversation.format()/${this.idForLogging()} reentrant call, no old cached props!` ); } const { stack } = new Error('for stack'); log.warn( `Conversation.format()/${this.idForLogging()} reentrant call! ${stack}` ); return this.oldCachedProps; }; try { const { oldCachedProps } = this; const newCachedProps = this.getProps(); if (oldCachedProps && isShallowEqual(oldCachedProps, newCachedProps)) { this.cachedProps = oldCachedProps; } else { this.cachedProps = newCachedProps; } return this.cachedProps; } finally { this.format = oldFormat; } } // 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. // Note: If you start relying on an attribute that is in // `ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE`, remove it from that list. private getProps(): ConversationType { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const color = this.getColor()!; const typingValues = Object.values(this.contactTypingTimers || {}); const typingMostRecent = head(sortBy(typingValues, 'timestamp')); // 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 = dropNull(this.get('draft')); const shouldShowDraft = Boolean( this.hasDraft() && draftTimestamp && draftTimestamp >= timestamp ); 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 (isGroupV1(this.attributes)) { groupVersion = 1; } else if (isGroupV2(this.attributes)) { groupVersion = 2; } const sortedGroupMembers = isGroupV2(this.attributes) ? this.getMembers() .sort((left, right) => sortConversationTitles(left, right, this.intlCollator) ) .map(member => member.format()) .filter(isNotNil) : undefined; const { customColor, customColorId } = this.getCustomColorData(); const ourACI = window.textsecure.storage.user.getUuid(UUIDKind.ACI); const ourPNI = window.textsecure.storage.user.getUuid(UUIDKind.PNI); // TODO: DESKTOP-720 return { id: this.id, uuid: this.get('uuid'), pni: this.get('pni'), e164: this.get('e164'), // We had previously stored `null` instead of `undefined` in some cases. We should // be able to remove this `dropNull` once usernames have gone to production. username: dropNull(this.get('username')), about: this.getAboutText(), aboutText: this.get('about'), aboutEmoji: this.get('aboutEmoji'), acceptedMessageRequest: this.getAccepted(), // eslint-disable-next-line @typescript-eslint/no-non-null-assertion activeAt: this.get('active_at')!, areWePending: ourACI && (this.isMemberPending(ourACI) || Boolean( ourPNI && !this.isMember(ourACI) && this.isMemberPending(ourPNI) )), areWePendingApproval: Boolean( ourConversationId && ourACI && this.isMemberAwaitingApproval(ourACI) ), areWeAdmin: this.areWeAdmin(), avatars: getAvatarData(this.attributes), badges: this.get('badges') ?? EMPTY_ARRAY, canChangeTimer: this.canChangeTimer(), canEditGroupInfo: this.canEditGroupInfo(), canAddNewMembers: this.canAddNewMembers(), avatarPath: this.getAbsoluteAvatarPath(), avatarHash: this.getAvatarHash(), unblurredAvatarPath: this.getAbsoluteUnblurredAvatarPath(), profileAvatarPath: this.getAbsoluteProfileAvatarPath(), color, conversationColor: this.getConversationColor(), customColor, customColorId, discoveredUnregisteredAt: this.get('discoveredUnregisteredAt'), draftBodyRanges: this.getDraftBodyRanges(), draftPreview, draftText, familyName: this.get('profileFamilyName'), firstName: this.get('profileName'), groupDescription: this.get('description'), groupVersion, groupId: this.get('groupId'), groupLink: this.getGroupLink(), hideStory: Boolean(this.get('hideStory')), inboxPosition, isArchived: this.get('isArchived'), isBlocked: this.isBlocked(), isMe: isMe(this.attributes), isGroupV1AndDisabled: this.isGroupV1AndDisabled(), isPinned: this.get('isPinned'), isUntrusted: this.isUntrusted(), isVerified: this.isVerified(), isFetchingUUID: this.isFetchingUUID, lastMessage: this.getLastMessage(), // eslint-disable-next-line @typescript-eslint/no-non-null-assertion lastUpdated: this.get('timestamp')!, left: Boolean(this.get('left')), markedUnread: this.get('markedUnread'), membersCount: this.getMembersCount(), memberships: this.getMemberships(), hasMessages: (this.get('messageCount') ?? 0) > 0, pendingMemberships: this.getPendingMemberships(), pendingApprovalMemberships: this.getPendingApprovalMemberships(), bannedMemberships: this.getBannedMemberships(), profileKey: this.get('profileKey'), messageRequestsEnabled, accessControlAddFromInviteLink: this.get('accessControl')?.addFromInviteLink, accessControlAttributes: this.get('accessControl')?.attributes, accessControlMembers: this.get('accessControl')?.members, announcementsOnly: Boolean(this.get('announcementsOnly')), announcementsOnlyReady: this.canBeAnnouncementGroup(), expireTimer: this.get('expireTimer'), muteExpiresAt: this.get('muteExpiresAt'), dontNotifyForMentionsIfMuted: this.get('dontNotifyForMentionsIfMuted'), name: this.get('name'), systemGivenName: this.get('systemGivenName'), systemFamilyName: this.get('systemFamilyName'), phoneNumber: this.getNumber(), profileName: this.getProfileName(), profileSharing: this.get('profileSharing'), publicParams: this.get('publicParams'), secretParams: this.get('secretParams'), shouldShowDraft, sortedGroupMembers, timestamp, title: this.getTitle(), titleNoDefault: this.getTitleNoDefault(), typingContactId: typingMostRecent?.senderId, searchableTitle: isMe(this.attributes) ? window.i18n('noteToSelf') : this.getTitle(), unreadCount: this.get('unreadCount') || 0, ...(isDirectConversation(this.attributes) ? { type: 'direct' as const, sharedGroupNames: this.get('sharedGroupNames') || EMPTY_ARRAY, } : { type: 'group' as const, acknowledgedGroupNameCollisions: this.get('acknowledgedGroupNameCollisions') || EMPTY_GROUP_COLLISIONS, sharedGroupNames: EMPTY_ARRAY, storySendMode: this.getGroupStorySendMode(), }), voiceNotePlaybackRate: this.get('voiceNotePlaybackRate'), }; } updateE164(e164?: string | null): void { const oldValue = this.get('e164'); if (e164 === oldValue) { return; } this.set('e164', e164 || undefined); // This user changed their phone number if (oldValue && e164) { void this.addChangeNumberNotification(oldValue, e164); } window.Signal.Data.updateConversation(this.attributes); this.trigger('idUpdated', this, 'e164', oldValue); this.captureChange('updateE164'); } updateUuid(uuid?: string): void { const oldValue = this.get('uuid'); if (uuid === oldValue) { return; } this.set('uuid', uuid ? UUID.cast(uuid.toLowerCase()) : undefined); window.Signal.Data.updateConversation(this.attributes); this.trigger('idUpdated', this, 'uuid', oldValue); // We should delete the old sessions and identity information in all situations except // for the case where we need to do old and new PNI comparisons. We'll wait // for the PNI update to do that. if (oldValue && oldValue !== this.get('pni')) { drop( window.textsecure.storage.protocol.removeIdentityKey( UUID.cast(oldValue) ) ); } this.captureChange('updateUuid'); } trackPreviousIdentityKey(publicKey: Uint8Array): void { const logId = `trackPreviousIdentityKey/${this.idForLogging()}`; const identityKey = Bytes.toBase64(publicKey); if (!isDirectConversation(this.attributes)) { throw new Error(`${logId}: Called for non-private conversation`); } const existingIdentityKey = this.get('previousIdentityKey'); if (existingIdentityKey && existingIdentityKey !== identityKey) { log.warn( `${logId}: Already had previousIdentityKey, new one does not match` ); void this.addKeyChange('trackPreviousIdentityKey - change'); } log.warn(`${logId}: Setting new previousIdentityKey`); this.set({ previousIdentityKey: identityKey, }); window.Signal.Data.updateConversation(this.attributes); } updatePni(pni?: string): void { const oldValue = this.get('pni'); if (pni === oldValue) { return; } this.set('pni', pni ? UUID.cast(pni.toLowerCase()) : undefined); const pniIsPrimaryId = !this.get('uuid') || this.get('uuid') === oldValue || this.get('uuid') === pni; const haveSentMessage = Boolean( this.get('profileSharing') || this.get('sentMessageCount') ); if (oldValue && pniIsPrimaryId && haveSentMessage) { // We're going from an old PNI to a new PNI if (pni) { const oldIdentityRecord = window.textsecure.storage.protocol.getIdentityRecord( UUID.cast(oldValue) ); const newIdentityRecord = window.textsecure.storage.protocol.getIdentityRecord( UUID.checkedLookup(pni) ); if ( newIdentityRecord && oldIdentityRecord && !constantTimeEqual( oldIdentityRecord.publicKey, newIdentityRecord.publicKey ) ) { void this.addKeyChange('updatePni - change'); } else if (!newIdentityRecord && oldIdentityRecord) { this.trackPreviousIdentityKey(oldIdentityRecord.publicKey); } } // We're just dropping the PNI if (!pni) { const oldIdentityRecord = window.textsecure.storage.protocol.getIdentityRecord( UUID.cast(oldValue) ); if (oldIdentityRecord) { this.trackPreviousIdentityKey(oldIdentityRecord.publicKey); } } } // If this PNI is going away or going to someone else, we'll delete all its sessions if (oldValue) { drop( window.textsecure.storage.protocol.removeIdentityKey( UUID.cast(oldValue) ) ); } if (pni && !this.get('uuid')) { log.warn( `updatePni/${this.idForLogging()}: pni field set to ${pni}, but uuid field is empty!` ); } window.Signal.Data.updateConversation(this.attributes); this.trigger('idUpdated', this, 'pni', oldValue); this.captureChange('updatePni'); } 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); } getMembersCount(): number | undefined { if (isDirectConversation(this.attributes)) { return undefined; } const memberList = this.get('membersV2') || this.get('members'); // We'll fail over if the member list is empty if (memberList && memberList.length) { return memberList.length; } const temporaryMemberCount = this.get('temporaryMemberCount'); if (isNumber(temporaryMemberCount)) { return temporaryMemberCount; } return undefined; } decrementMessageCount(): void { this.set({ messageCount: Math.max((this.get('messageCount') || 0) - 1, 0), }); window.Signal.Data.updateConversation(this.attributes); } incrementSentMessageCount({ dry = false }: { dry?: boolean } = {}): | Partial | undefined { const update = { messageCount: (this.get('messageCount') || 0) + 1, sentMessageCount: (this.get('sentMessageCount') || 0) + 1, }; if (dry) { return update; } this.set(update); window.Signal.Data.updateConversation(this.attributes); return undefined; } 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; const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString(); let messages: Array | undefined; do { const first = messages ? messages[0] : undefined; // eslint-disable-next-line no-await-in-loop messages = await window.Signal.Data.getOlderMessagesByConversation( this.get('id'), { includeStoryReplies: !isGroup(this.attributes), limit: 100, messageId: first ? first.id : undefined, receivedAt: first ? first.received_at : undefined, sentAt: first ? first.sent_at : undefined, storyId: undefined, } ); if (!messages.length) { return; } const readMessages = messages.filter(m => !hasErrors(m) && isIncoming(m)); if (isLocalAction) { // eslint-disable-next-line no-await-in-loop await readReceiptsJobQueue.addIfAllowedByUser( window.storage, readMessages.map(m => ({ messageId: m.id, senderE164: m.source, senderUuid: m.sourceUuid, timestamp: m.sent_at, isDirectConversation: isDirectConversation(this.attributes), })) ); } // 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, { ourUuid, }); } }) ); } while (messages.length > 0); } async applyMessageRequestResponse( response: number, { fromSync = false, viaStorageServiceSync = false } = {} ): Promise { try { const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type; const isLocalAction = !fromSync && !viaStorageServiceSync; 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) { const ourACI = window.textsecure.storage.user.getCheckedUuid( UUIDKind.ACI ); const ourPNI = window.textsecure.storage.user.getUuid(UUIDKind.PNI); const ourConversation = window.ConversationController.getOurConversationOrThrow(); if ( isGroupV1(this.attributes) || isDirectConversation(this.attributes) ) { void this.sendProfileKeyUpdate(); } else if ( isGroupV2(this.attributes) && this.isMemberPending(ourACI) ) { await this.modifyGroupV2({ name: 'promotePendingMember', usingCredentialsFrom: [ourConversation], createGroupChange: () => this.promotePendingMember(UUIDKind.ACI), }); } else if ( ourPNI && isGroupV2(this.attributes) && this.isMemberPending(ourPNI) ) { await this.modifyGroupV2({ name: 'promotePendingMember', usingCredentialsFrom: [ourConversation], createGroupChange: () => this.promotePendingMember(UUIDKind.PNI), }); } else if (isGroupV2(this.attributes) && this.isMember(ourACI)) { log.info( 'applyMessageRequestResponse/accept: Already a member of v2 group' ); } else { 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 (isGroupV1(this.attributes)) { await this.leaveGroup(); } else if (isGroupV2(this.attributes)) { 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(); void this.updateLastMessage(); if (isLocalAction) { window.reduxActions.conversations.onConversationClosed( this.id, 'deleted from message request' ); if (isGroupV1(this.attributes)) { await this.leaveGroup(); } else if (isGroupV2(this.attributes)) { 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(); void this.updateLastMessage(); if (isLocalAction) { window.reduxActions.conversations.onConversationClosed( this.id, 'blocked and deleted from message request' ); if (isGroupV1(this.attributes)) { await this.leaveGroup(); } else if (isGroupV2(this.attributes)) { await this.leaveGroupV2(); } } } } finally { window.Signal.Data.updateConversation(this.attributes); } } async joinGroupV2ViaLinkAndMigrate({ approvalRequired, inviteLinkPassword, revision, }: { approvalRequired: boolean; inviteLinkPassword: string; revision: number; }): Promise { await window.Signal.Groups.joinGroupV2ViaLinkAndMigrate({ approvalRequired, conversation: this, inviteLinkPassword, revision, }); } async joinGroupV2ViaLink({ inviteLinkPassword, approvalRequired, }: { inviteLinkPassword: string; approvalRequired: boolean; }): Promise { const ourACI = window.textsecure.storage.user.getCheckedUuid(); const ourConversation = window.ConversationController.getOurConversationOrThrow(); try { if (approvalRequired) { await this.modifyGroupV2({ name: 'requestToJoin', usingCredentialsFrom: [ourConversation], inviteLinkPassword, createGroupChange: () => this.addPendingApprovalRequest(), }); } else { await this.modifyGroupV2({ name: 'joinGroup', usingCredentialsFrom: [ourConversation], inviteLinkPassword, createGroupChange: () => this.addMember(ourACI), }); } } catch (error) { const ALREADY_REQUESTED_TO_JOIN = '{"code":400,"message":"cannot ask to join via invite link if already asked to join"}'; if (!error.response) { throw error; } else { const errorDetails = Bytes.toString(error.response); if (errorDetails !== ALREADY_REQUESTED_TO_JOIN) { throw error; } else { log.info( 'joinGroupV2ViaLink: Got 400, but server is telling us we have already requested to join. Forcing that local state' ); this.set({ pendingAdminApprovalV2: [ { uuid: ourACI.toString(), timestamp: Date.now(), }, ], }); } } } const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type; // Ensure active_at is set, because this is an event that justifies putting the group // in the left pane. this.set({ messageRequestResponseType: messageRequestEnum.ACCEPT, active_at: this.get('active_at') || Date.now(), }); window.Signal.Data.updateConversation(this.attributes); } async cancelJoinRequest(): Promise { const ourACI = window.storage.user.getCheckedUuid(UUIDKind.ACI); const inviteLinkPassword = this.get('groupInviteLinkPassword'); if (!inviteLinkPassword) { log.warn( `cancelJoinRequest/${this.idForLogging()}: We don't have an inviteLinkPassword!` ); } await this.modifyGroupV2({ name: 'cancelJoinRequest', usingCredentialsFrom: [], inviteLinkPassword, createGroupChange: () => this.denyPendingApprovalRequest(ourACI), }); } async leaveGroupV2(): Promise { if (!isGroupV2(this.attributes)) { return; } const ourACI = window.textsecure.storage.user.getCheckedUuid(UUIDKind.ACI); const ourPNI = window.textsecure.storage.user.getUuid(UUIDKind.PNI); const ourConversation = window.ConversationController.getOurConversationOrThrow(); if (this.isMemberPending(ourACI)) { await this.modifyGroupV2({ name: 'delete', usingCredentialsFrom: [], createGroupChange: () => this.removePendingMember([ourACI]), }); } else if (this.isMember(ourACI)) { await this.modifyGroupV2({ name: 'delete', usingCredentialsFrom: [ourConversation], createGroupChange: () => this.removeMember(ourACI), }); // Keep PNI in pending if ACI was a member. } else if (ourPNI && this.isMemberPending(ourPNI)) { await this.modifyGroupV2({ name: 'delete', usingCredentialsFrom: [], createGroupChange: () => this.removePendingMember([ourPNI]), }); } else { const logId = this.idForLogging(); log.error( 'leaveGroupV2: We were neither a member nor a pending member of ' + `the group ${logId}` ); } } async addBannedMember( uuid: UUID ): Promise { if (this.isMember(uuid)) { log.warn('addBannedMember: Member is a part of the group!'); return; } if (this.isMemberPending(uuid)) { log.warn('addBannedMember: Member is pending to be added to group!'); return; } if (this.isMemberBanned(uuid)) { log.warn('addBannedMember: Member is already banned!'); return; } return window.Signal.Groups.buildAddBannedMemberChange({ group: this.attributes, uuid, }); } async blockGroupLinkRequests(uuid: UUIDStringType): Promise { await this.modifyGroupV2({ name: 'addBannedMember', usingCredentialsFrom: [], createGroupChange: async () => this.addBannedMember(new UUID(uuid)), }); } async toggleAdmin(conversationId: string): Promise { if (!isGroupV2(this.attributes)) { return; } const logId = this.idForLogging(); const member = window.ConversationController.get(conversationId); if (!member) { log.error(`toggleAdmin/${logId}: ${conversationId} does not exist`); return; } const uuid = member.getCheckedUuid(`toggleAdmin/${logId}`); if (!this.isMember(uuid)) { log.error( `toggleAdmin: Member ${conversationId} is not a member of the group` ); return; } await this.modifyGroupV2({ name: 'toggleAdmin', usingCredentialsFrom: [member], createGroupChange: () => this.toggleAdminChange(uuid), }); } async removeFromGroupV2(conversationId: string): Promise { if (!isGroupV2(this.attributes)) { return; } const logId = this.idForLogging(); const pendingMember = window.ConversationController.get(conversationId); if (!pendingMember) { throw new Error( `removeFromGroupV2/${logId}: No conversation found for conversation ${conversationId}` ); } const uuid = pendingMember.getCheckedUuid(`removeFromGroupV2/${logId}`); if (this.isMemberRequestingToJoin(uuid)) { await this.modifyGroupV2({ name: 'denyPendingApprovalRequest', usingCredentialsFrom: [], createGroupChange: () => this.denyPendingApprovalRequest(uuid), extraConversationsForSend: [conversationId], }); } else if (this.isMemberPending(uuid)) { await this.modifyGroupV2({ name: 'removePendingMember', usingCredentialsFrom: [], createGroupChange: () => this.removePendingMember([uuid]), extraConversationsForSend: [conversationId], }); } else if (this.isMember(uuid)) { await this.modifyGroupV2({ name: 'removeFromGroup', usingCredentialsFrom: [pendingMember], createGroupChange: () => this.removeMember(uuid), extraConversationsForSend: [conversationId], }); } else { 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 groupId = this.getGroupIdBuffer(); if (window.ConversationController.areWePrimaryDevice()) { log.warn( 'syncMessageRequestResponse: We are primary device; not sending message request sync' ); return; } try { await singleProtoJobQueue.add( MessageSender.getMessageRequestResponseSync({ threadE164: this.get('e164'), threadUuid: this.get('uuid'), groupId, type: response, }) ); } catch (error) { log.error( 'syncMessageRequestResponse: Failed to queue sync message', Errors.toLogFormat(error) ); } } async safeGetVerified(): Promise { const uuid = this.getUuid(); if (!uuid) { return this.verifiedEnum.DEFAULT; } try { return await window.textsecure.storage.protocol.getVerified(uuid); } catch { return this.verifiedEnum.DEFAULT; } } async updateVerified(): Promise { if (isDirectConversation(this.attributes)) { 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 (!isMe(contact.attributes)) { await contact.updateVerified(); } }) ); } setVerifiedDefault(): Promise { const { DEFAULT } = this.verifiedEnum; return this.queueJob('setVerifiedDefault', () => this._setVerified(DEFAULT) ); } setVerified(): Promise { const { VERIFIED } = this.verifiedEnum; return this.queueJob('setVerified', () => this._setVerified(VERIFIED)); } setUnverified(): Promise { const { UNVERIFIED } = this.verifiedEnum; return this.queueJob('setUnverified', () => this._setVerified(UNVERIFIED)); } private async _setVerified(verified: number): Promise { const { VERIFIED, DEFAULT } = this.verifiedEnum; if (!isDirectConversation(this.attributes)) { throw new Error( 'You cannot verify a group conversation. ' + 'You must verify individual contacts.' ); } const uuid = this.getUuid(); const beginningVerified = this.get('verified') ?? DEFAULT; const keyChange = false; if (uuid) { if (verified === this.verifiedEnum.DEFAULT) { await window.textsecure.storage.protocol.setVerified(uuid, verified); } else { await window.textsecure.storage.protocol.setVerified(uuid, verified, { firstUse: false, nonblockingApproval: true, }); } } else { log.warn(`_setVerified(${this.id}): no uuid to update protocol storage`); } this.set({ verified }); window.Signal.Data.updateConversation(this.attributes); if (beginningVerified !== verified) { this.captureChange(`verified from=${beginningVerified} to=${verified}`); } const didVerifiedChange = beginningVerified !== verified; const isExplicitUserAction = true; if ( // The message came from an explicit verification in a client (not // storage service sync) (didVerifiedChange && isExplicitUserAction) || // Our local verification status is VERIFIED and it hasn't changed, but the key did // change (Key1/VERIFIED -> Key2/VERIFIED), but we don't want to show DEFAULT -> // DEFAULT or UNVERIFIED -> UNVERIFIED (keyChange && verified === VERIFIED) ) { await this.addVerifiedChange(this.id, verified === VERIFIED, { local: isExplicitUserAction, }); } if (isExplicitUserAction && uuid) { await this.sendVerifySyncMessage(this.get('e164'), uuid, verified); } return keyChange; } async sendVerifySyncMessage( e164: string | undefined, uuid: UUID, state: number ): Promise { const identifier = uuid ? uuid.toString() : e164; if (!identifier) { throw new Error( 'sendVerifySyncMessage: Neither e164 nor UUID were provided' ); } if (window.ConversationController.areWePrimaryDevice()) { log.warn( 'sendVerifySyncMessage: We are primary device; not sending sync' ); return; } const key = await window.textsecure.storage.protocol.loadIdentityKey( UUID.checkedLookup(identifier) ); if (!key) { throw new Error( `sendVerifySyncMessage: No identity key found for identifier ${identifier}` ); } try { await singleProtoJobQueue.add( MessageSender.getVerificationSync(e164, uuid.toString(), state, key) ); } catch (error) { log.error( 'sendVerifySyncMessage: Failed to queue sync message', Errors.toLogFormat(error) ); } } isVerified(): boolean { if (isDirectConversation(this.attributes)) { return this.get('verified') === this.verifiedEnum.VERIFIED; } if (!this.contactCollection?.length) { return false; } return this.contactCollection?.every(contact => { if (isMe(contact.attributes)) { return true; } return contact.isVerified(); }); } isUnverified(): boolean { if (isDirectConversation(this.attributes)) { const verified = this.get('verified'); return ( verified !== this.verifiedEnum.VERIFIED && verified !== this.verifiedEnum.DEFAULT ); } if (!this.contactCollection?.length) { return true; } return this.contactCollection?.some(contact => { if (isMe(contact.attributes)) { return false; } return contact.isUnverified(); }); } getUnverified(): Array { if (isDirectConversation(this.attributes)) { return this.isUnverified() ? [this] : []; } return ( this.contactCollection?.filter(contact => { if (isMe(contact.attributes)) { return false; } return contact.isUnverified(); }) || [] ); } async setApproved(): Promise { if (!isDirectConversation(this.attributes)) { throw new Error( 'You cannot set a group conversation as trusted. ' + 'You must set individual contacts as trusted.' ); } const uuid = this.getUuid(); if (!uuid) { log.warn(`setApproved(${this.id}): no uuid, ignoring`); return; } return window.textsecure.storage.protocol.setApproval(uuid, true); } safeIsUntrusted(timestampThreshold?: number): boolean { try { const uuid = this.getUuid(); strictAssert(uuid, `No uuid for conversation: ${this.id}`); return window.textsecure.storage.protocol.isUntrusted( uuid, timestampThreshold ); } catch (err) { return false; } } isUntrusted(timestampThreshold?: number): boolean { if (isDirectConversation(this.attributes)) { return this.safeIsUntrusted(timestampThreshold); } const { contactCollection } = this; if (!contactCollection?.length) { return false; } return contactCollection.some(contact => { if (isMe(contact.attributes)) { return false; } return contact.safeIsUntrusted(timestampThreshold); }); } getUntrusted(timestampThreshold?: number): Array { if (isDirectConversation(this.attributes)) { if (this.isUntrusted(timestampThreshold)) { return [this]; } return []; } return ( this.contactCollection?.filter(contact => { if (isMe(contact.attributes)) { return false; } return contact.isUntrusted(timestampThreshold); }) || [] ); } getSentMessageCount(): number { return this.get('sentMessageCount') || 0; } getMessageRequestResponseType(): number { return this.get('messageRequestResponseType') || 0; } getAboutText(): string | undefined { if (!this.get('about')) { return undefined; } const emoji = this.get('aboutEmoji'); const text = this.get('about'); if (!emoji) { return text; } return window.i18n('message--getNotificationText--text-with-emoji', { text, emoji, }); } /** * Determine if this conversation should be considered "accepted" in terms * of message requests */ getAccepted(): boolean { return isConversationAccepted(this.attributes); } 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, { force: true }); } async toggleVerified(): Promise { if (this.isVerified()) { return this.setVerifiedDefault(); } return this.setVerified(); } async addChatSessionRefreshed({ receivedAt, receivedAtCounter, }: { receivedAt: number; receivedAtCounter: number; }): Promise { log.info(`addChatSessionRefreshed: adding for ${this.idForLogging()}`, { receivedAt, }); const message = { conversationId: this.id, type: 'chat-session-refreshed', sent_at: receivedAt, received_at: receivedAtCounter, received_at_ms: receivedAt, readStatus: ReadStatus.Unread, seenStatus: SeenStatus.Unseen, // TODO: DESKTOP-722 // this type does not fully implement the interface it is expected to } as unknown as MessageAttributesType; const id = await window.Signal.Data.saveMessage(message, { ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), }); const model = window.MessageController.register( id, new window.Whisper.Message({ ...message, id, }) ); this.trigger('newmessage', model); void this.updateUnread(); } async addDeliveryIssue({ receivedAt, receivedAtCounter, senderUuid, sentAt, }: { receivedAt: number; receivedAtCounter: number; senderUuid: string; sentAt: number; }): Promise { log.info(`addDeliveryIssue: adding for ${this.idForLogging()}`, { sentAt, senderUuid, }); const message = { conversationId: this.id, type: 'delivery-issue', sourceUuid: senderUuid, sent_at: receivedAt, received_at: receivedAtCounter, received_at_ms: receivedAt, readStatus: ReadStatus.Unread, seenStatus: SeenStatus.Unseen, // TODO: DESKTOP-722 // this type does not fully implement the interface it is expected to } as unknown as MessageAttributesType; const id = await window.Signal.Data.saveMessage(message, { ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), }); const model = window.MessageController.register( id, new window.Whisper.Message({ ...message, id, }) ); this.trigger('newmessage', model); await this.notify(model); void this.updateUnread(); } async addKeyChange(reason: string, keyChangedId?: UUID): Promise { const keyChangedIdString = keyChangedId?.toString(); return this.queueJob(`addKeyChange(${keyChangedIdString})`, async () => { log.info( 'adding key change advisory in', this.idForLogging(), 'for', keyChangedIdString || 'this conversation', this.get('timestamp'), 'reason:', reason ); if (!keyChangedId && !isDirectConversation(this.attributes)) { throw new Error( 'addKeyChange: Cannot omit keyChangedId in group conversation!' ); } const timestamp = Date.now(); const message: MessageAttributesType = { id: generateGuid(), conversationId: this.id, type: 'keychange', sent_at: timestamp, timestamp, received_at: window.Signal.Util.incrementMessageCounter(), received_at_ms: timestamp, key_changed: keyChangedIdString, readStatus: ReadStatus.Read, seenStatus: SeenStatus.Unseen, schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY, }; await window.Signal.Data.saveMessage(message, { ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), forceSave: true, }); const model = window.MessageController.register( message.id, new window.Whisper.Message(message) ); 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 }); } if (isDirectConversation(this.attributes) && uuid) { const parsedUuid = UUID.checkedLookup(uuid); const groups = await window.ConversationController.getAllGroupsInvolvingUuid( parsedUuid ); groups.forEach(group => { void group.addKeyChange('addKeyChange - group fan-out', parsedUuid); }); } // Drop a member from sender key distribution list. const senderKeyInfo = this.get('senderKeyInfo'); if (senderKeyInfo) { const updatedSenderKeyInfo = { ...senderKeyInfo, memberDevices: senderKeyInfo.memberDevices.filter( ({ identifier }) => { return identifier !== keyChangedIdString; } ), }; this.set('senderKeyInfo', updatedSenderKeyInfo); window.Signal.Data.updateConversation(this.attributes); } }); } async addConversationMerge( renderInfo: ConversationRenderInfoType ): Promise { log.info( `addConversationMerge/${this.idForLogging()}: Adding notification` ); const timestamp = Date.now(); const message: MessageAttributesType = { id: generateGuid(), conversationId: this.id, type: 'conversation-merge', sent_at: timestamp, timestamp, received_at: window.Signal.Util.incrementMessageCounter(), received_at_ms: timestamp, conversationMerge: { renderInfo, }, readStatus: ReadStatus.Read, seenStatus: SeenStatus.Unseen, schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY, }; const id = await window.Signal.Data.saveMessage(message, { ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), forceSave: true, }); const model = window.MessageController.register( id, new window.Whisper.Message({ ...message, id, }) ); this.trigger('newmessage', model); } async addVerifiedChange( verifiedChangeId: string, verified: boolean, options: { local?: boolean } = { local: true } ): Promise { if (isMe(this.attributes)) { log.info('refusing to add verified change advisory for our own number'); return; } const lastMessage = this.get('timestamp') || Date.now(); log.info( 'adding verified change advisory for', this.idForLogging(), verifiedChangeId, lastMessage ); const shouldBeUnseen = !options.local && !verified; const timestamp = Date.now(); const message: MessageAttributesType = { id: generateGuid(), conversationId: this.id, local: Boolean(options.local), readStatus: shouldBeUnseen ? ReadStatus.Unread : ReadStatus.Read, received_at_ms: timestamp, received_at: window.Signal.Util.incrementMessageCounter(), seenStatus: shouldBeUnseen ? SeenStatus.Unseen : SeenStatus.Unseen, sent_at: lastMessage, timestamp, type: 'verified-change', verified, verifiedChanged: verifiedChangeId, }; await window.Signal.Data.saveMessage(message, { ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), forceSave: true, }); const model = window.MessageController.register( message.id, new window.Whisper.Message(message) ); this.trigger('newmessage', model); void this.updateUnread(); const uuid = this.getUuid(); if (isDirectConversation(this.attributes) && uuid) { void window.ConversationController.getAllGroupsInvolvingUuid(uuid).then( groups => { groups.forEach(group => { void group.addVerifiedChange(this.id, verified, options); }); } ); } } async addCallHistory( callHistoryDetails: CallHistoryDetailsType, receivedAtCounter: number | undefined ): Promise { let timestamp: number; let unread: boolean; let detailsToSave: CallHistoryDetailsType; switch (callHistoryDetails.callMode) { case CallMode.Direct: { log.info( `addCallHistory: Adding direct call to history (Call ID ${callHistoryDetails.callId})` ); const resolvedTime = callHistoryDetails.acceptedTime ?? callHistoryDetails.endedTime; assertDev(resolvedTime, 'Direct call must have accepted or ended time'); timestamp = resolvedTime; 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: receivedAtCounter || window.Signal.Util.incrementMessageCounter(), received_at_ms: timestamp, readStatus: unread ? ReadStatus.Unread : ReadStatus.Read, seenStatus: unread ? SeenStatus.Unseen : SeenStatus.NotApplicable, callHistoryDetails: detailsToSave, // TODO: DESKTOP-722 } as unknown as MessageAttributesType; if (callHistoryDetails.callMode === CallMode.Direct) { const messageId = await window.Signal.Data.getCallHistoryMessageByCallId( this.id, callHistoryDetails.callId ); if (messageId != null) { log.info( `addCallHistory: Found existing call history message (Call ID ${callHistoryDetails.callId}, Message ID: ${messageId})` ); message.id = messageId; } } const id = await window.Signal.Data.saveMessage(message, { ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), }); const model = window.MessageController.register( id, new window.Whisper.Message({ ...message, id, }) ); this.trigger('newmessage', model); void this.updateUnread(); if (this.get('isArchived')) { this.setArchived(false); } } /** * Adds a group call history message if one is needed. It won't add history messages for * the same group call era ID. * * Resolves with `true` if a new message was added, and `false` otherwise. */ 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) { void this.updateLastMessage(); return false; } await this.addCallHistory( { callMode: CallMode.Group, creatorUuid, eraId, startedTime: Date.now(), }, undefined ); return true; } async addProfileChange( profileChange: unknown, conversationId?: string ): Promise { const now = Date.now(); const message = { conversationId: this.id, type: 'profile-change', sent_at: now, received_at: window.Signal.Util.incrementMessageCounter(), received_at_ms: now, readStatus: ReadStatus.Read, seenStatus: SeenStatus.NotApplicable, changedId: conversationId || this.id, profileChange, // TODO: DESKTOP-722 } as unknown as MessageAttributesType; const id = await window.Signal.Data.saveMessage(message, { ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), }); const model = window.MessageController.register( id, new window.Whisper.Message({ ...message, id, }) ); this.trigger('newmessage', model); const uuid = this.getUuid(); if (isDirectConversation(this.attributes) && uuid) { void window.ConversationController.getAllGroupsInvolvingUuid(uuid).then( groups => { groups.forEach(group => { void group.addProfileChange(profileChange, this.id); }); } ); } } async addNotification( type: MessageAttributesType['type'], extra: Partial = {} ): Promise { const now = Date.now(); const message: Partial = { conversationId: this.id, type, sent_at: now, received_at: window.Signal.Util.incrementMessageCounter(), received_at_ms: now, readStatus: ReadStatus.Read, seenStatus: SeenStatus.NotApplicable, ...extra, }; const id = await window.Signal.Data.saveMessage( // TODO: DESKTOP-722 message as MessageAttributesType, { ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), } ); const model = window.MessageController.register( id, new window.Whisper.Message({ ...(message as MessageAttributesType), id, }) ); this.trigger('newmessage', model); return id; } async maybeSetPendingUniversalTimer( hasUserInitiatedMessages: boolean ): Promise { if (!isDirectConversation(this.attributes)) { return; } if (this.isSMSOnly()) { return; } if (isSignalConversation(this.attributes)) { return; } if (hasUserInitiatedMessages) { await this.maybeRemoveUniversalTimer(); return; } if (this.get('pendingUniversalTimer') || this.get('expireTimer')) { return; } const expireTimer = universalExpireTimer.get(); if (!expireTimer) { return; } log.info( `maybeSetPendingUniversalTimer(${this.idForLogging()}): added notification` ); const notificationId = await this.addNotification( 'universal-timer-notification' ); this.set('pendingUniversalTimer', notificationId); } async maybeApplyUniversalTimer(): Promise { // Check if we had a notification if (!(await this.maybeRemoveUniversalTimer())) { return; } // We already have an expiration timer if (this.get('expireTimer')) { return; } const expireTimer = universalExpireTimer.get(); if (expireTimer) { log.info( `maybeApplyUniversalTimer(${this.idForLogging()}): applying timer` ); await this.updateExpirationTimer(expireTimer, { reason: 'maybeApplyUniversalTimer', }); } } async maybeRemoveUniversalTimer(): Promise { const notificationId = this.get('pendingUniversalTimer'); if (!notificationId) { return false; } this.set('pendingUniversalTimer', undefined); log.info( `maybeRemoveUniversalTimer(${this.idForLogging()}): removed notification` ); const message = window.MessageController.getById(notificationId); if (message) { await window.Signal.Data.removeMessage(message.id); } return true; } async addChangeNumberNotification( oldValue: string, newValue: string ): Promise { const sourceUuid = this.getCheckedUuid( 'Change number notification without uuid' ); const { storage } = window.textsecure; if (storage.user.getOurUuidKind(sourceUuid) !== UUIDKind.Unknown) { log.info( `Conversation ${this.idForLogging()}: not adding change number ` + 'notification for ourselves' ); return; } log.info( `Conversation ${this.idForLogging()}: adding change number ` + `notification for ${sourceUuid.toString()} from ${oldValue} to ${newValue}` ); const convos = [ this, ...(await window.ConversationController.getAllGroupsInvolvingUuid( sourceUuid )), ]; await Promise.all( convos.map(convo => { return convo.addNotification('change-number-notification', { readStatus: ReadStatus.Read, seenStatus: SeenStatus.Unseen, sourceUuid: sourceUuid.toString(), }); }) ); } 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('onReadMessage', () => // eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.markRead(message.get('received_at')!, { newestSentAt: message.get('sent_at'), sendReadReceipts: false, readAt, }) ); } override validate(attributes = this.attributes): string | null { return validateConversation(attributes); } queueJob( name: string, callback: (abortSignal: AbortSignal) => Promise ): Promise { this.jobQueue = this.jobQueue || new PQueue({ concurrency: 1 }); const logId = `conversation.queueJob(${this.idForLogging()}, ${name})`; const taskWithTimeout = createTaskWithTimeout(callback, logId); const abortController = new AbortController(); const { signal: abortSignal } = abortController; const queuedAt = Date.now(); return this.jobQueue.add(async () => { const startedAt = Date.now(); const waitTime = startedAt - queuedAt; if (waitTime > JOB_REPORTING_THRESHOLD_MS) { log.info(`${logId}: was blocked for ${waitTime}ms`); } try { return await taskWithTimeout(abortSignal); } catch (error) { abortController.abort(); throw error; } finally { const duration = Date.now() - startedAt; if (duration > JOB_REPORTING_THRESHOLD_MS) { log.info(`${logId}: took ${duration}ms`); } } }); } isAdmin(uuid: UUID): boolean { if (!isGroupV2(this.attributes)) { return false; } const members = this.get('membersV2') || []; const member = members.find(x => x.uuid === uuid.toString()); if (!member) { return false; } const MEMBER_ROLES = Proto.Member.Role; return member.role === MEMBER_ROLES.ADMINISTRATOR; } getUuid(): UUID | undefined { try { const value = this.get('uuid'); return value ? new UUID(value) : undefined; } catch (err) { log.warn( `getUuid(): failed to obtain conversation(${this.id}) uuid due to`, Errors.toLogFormat(err) ); return undefined; } } getCheckedUuid(reason: string): UUID { const result = this.getUuid(); strictAssert(result !== undefined, reason); return result; } private getDraftBodyRanges = memoizeByThis( (): DraftBodyRangesType | undefined => { return this.get('draftBodyRanges'); } ); private getLastMessage = memoizeByThis((): LastMessageType | undefined => { if (this.get('lastMessageDeletedForEveryone')) { return { deletedForEveryone: true }; } const lastMessageText = this.get('lastMessage'); if (!lastMessageText) { return undefined; } return { status: dropNull(this.get('lastMessageStatus')), text: lastMessageText, author: dropNull(this.get('lastMessageAuthor')), deletedForEveryone: false, }; }); private getMemberships = memoizeByThis( (): ReadonlyArray<{ uuid: UUIDStringType; isAdmin: boolean; }> => { if (!isGroupV2(this.attributes)) { return EMPTY_ARRAY; } const members = this.get('membersV2') || []; return members.map(member => ({ isAdmin: member.role === Proto.Member.Role.ADMINISTRATOR, uuid: member.uuid, })); } ); getGroupLink(): string | undefined { if (!isGroupV2(this.attributes)) { return undefined; } if (!this.get('groupInviteLinkPassword')) { return undefined; } return window.Signal.Groups.buildGroupLink(this); } private getPendingMemberships = memoizeByThis( (): ReadonlyArray<{ addedByUserId?: UUIDStringType; uuid: UUIDStringType; }> => { if (!isGroupV2(this.attributes)) { return EMPTY_ARRAY; } const members = this.get('pendingMembersV2') || []; return members.map(member => ({ addedByUserId: member.addedByUserId, uuid: member.uuid, })); } ); private getPendingApprovalMemberships = memoizeByThis( (): ReadonlyArray<{ uuid: UUIDStringType }> => { if (!isGroupV2(this.attributes)) { return EMPTY_ARRAY; } const members = this.get('pendingAdminApprovalV2') || []; return members.map(member => ({ uuid: member.uuid, })); } ); private getBannedMemberships = memoizeByThis( (): ReadonlyArray => { if (!isGroupV2(this.attributes)) { return EMPTY_ARRAY; } return (this.get('bannedMembersV2') || []).map(member => member.uuid); } ); getMembers( options: { includePendingMembers?: boolean } = {} ): Array { return compact( getConversationMembers(this.attributes, options).map(conversationAttrs => window.ConversationController.get(conversationAttrs.id) ) ); } canBeAnnouncementGroup(): boolean { if (!isGroupV2(this.attributes)) { return false; } if (!isAnnouncementGroupReady()) { return false; } return true; } getMemberIds(): Array { const members = this.getMembers(); return members.map(member => member.id); } getMemberUuids(): Array { const members = this.getMembers(); return members.map(member => { return member.getCheckedUuid('Group member without uuid'); }); } getRecipients({ includePendingMembers, extraConversationsForSend, isStoryReply = false, }: { includePendingMembers?: boolean; extraConversationsForSend?: ReadonlyArray; isStoryReply?: boolean; } = {}): Array { return getRecipients(this.attributes, { includePendingMembers, extraConversationsForSend, isStoryReply, }); } // Members is all people in the group getMemberConversationIds(): Set { return new Set(map(this.getMembers(), conversation => conversation.id)); } async getQuoteAttachment( attachments?: Array, preview?: Array, sticker?: StickerType ): Promise< Array<{ contentType: MIMEType; fileName: string | null; thumbnail: ThumbnailType | null; }> > { if (attachments && attachments.length) { const attachmentsToUse = Array.from(take(attachments, 1)); const isGIFQuote = isGIF(attachmentsToUse); return Promise.all( map(attachmentsToUse, async attachment => { const { path, fileName, thumbnail, contentType } = attachment; if (!path) { return { contentType: isGIFQuote ? IMAGE_GIF : contentType, // Our protos library complains about this field being undefined, so we // force it to null fileName: fileName || null, thumbnail: null, }; } return { contentType: isGIFQuote ? IMAGE_GIF : 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: thumbnail.path ? getAbsoluteAttachmentPath(thumbnail.path) : undefined, } : null, }; }) ); } if (preview && preview.length) { const previewImages = collect(preview, prev => prev.image); const previewImagesToUse = take(previewImages, 1); return Promise.all( map(previewImagesToUse, async image => { 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: image.path ? getAbsoluteAttachmentPath(image.path) : undefined, } : 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: path ? getAbsoluteAttachmentPath(path) : undefined, }, }, ]; } return []; } async makeQuote(quotedMessage: MessageModel): Promise { const { getName } = EmbeddedContact; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const contact = getContact(quotedMessage.attributes)!; 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'), attachments: isTapToView(quotedMessage.attributes) ? [{ contentType: IMAGE_JPEG, fileName: null }] : await this.getQuoteAttachment(attachments, preview, sticker), payment: quotedMessage.get('payment'), bodyRanges: quotedMessage.get('bodyRanges'), id: quotedMessage.get('sent_at'), isViewOnce: isTapToView(quotedMessage.attributes), isGiftBadge: isGiftBadge(quotedMessage.attributes), messageId: quotedMessage.get('id'), referencedMessageNotFound: false, text: body || embeddedContactName, }; } async sendStickerMessage(packId: string, stickerId: number): Promise { const packData = Stickers.getStickerPack(packId); const stickerData = Stickers.getSticker(packId, stickerId); if (!stickerData || !packData) { log.warn( `Attempted to send nonexistent (${packId}, ${stickerId}) sticker!` ); return; } const { key } = packData; const { emoji, path, width, height } = stickerData; const data = await readStickerData(path); // We need this content type to be an image so we can display an `` instead of a // `