![Fedor Indutny](/assets/img/avatar_default.png)
Generating identicons for notifications is costly even if they are cached. Avoid this extra load when notifications are not going to be shown anyway.
5484 lines
159 KiB
TypeScript
5484 lines
159 KiB
TypeScript
// Copyright 2020-2021 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
/* eslint-disable class-methods-use-this */
|
|
/* eslint-disable camelcase */
|
|
import { ProfileKeyCredentialRequestContext } from 'zkgroup';
|
|
import {
|
|
MessageModelCollectionType,
|
|
WhatIsThis,
|
|
MessageAttributesType,
|
|
ConversationAttributesType,
|
|
VerificationOptions,
|
|
} from '../model-types.d';
|
|
import {
|
|
GroupV2PendingMembership,
|
|
GroupV2RequestingMembership,
|
|
} from '../components/conversation/conversation-details/PendingInvites';
|
|
import { GroupV2Membership } from '../components/conversation/conversation-details/ConversationDetailsMembershipList';
|
|
import { CallMode, CallHistoryDetailsType } from '../types/Calling';
|
|
import {
|
|
CallbackResultType,
|
|
GroupV2InfoType,
|
|
SendMetadataType,
|
|
SendOptionsType,
|
|
} from '../textsecure/SendMessage';
|
|
import {
|
|
ConversationType,
|
|
ConversationTypeType,
|
|
} from '../state/ducks/conversations';
|
|
import { ColorType } from '../types/Colors';
|
|
import { MessageModel } from './messages';
|
|
import { isMuted } from '../util/isMuted';
|
|
import { isConversationUnregistered } from '../util/isConversationUnregistered';
|
|
import { assert } from '../util/assert';
|
|
import { missingCaseError } from '../util/missingCaseError';
|
|
import { sniffImageMimeType } from '../util/sniffImageMimeType';
|
|
import { MIMEType, IMAGE_WEBP } from '../types/MIME';
|
|
import {
|
|
arrayBufferToBase64,
|
|
base64ToArrayBuffer,
|
|
deriveAccessKey,
|
|
fromEncodedBinaryToArrayBuffer,
|
|
getRandomBytes,
|
|
stringFromBytes,
|
|
trimForDisplay,
|
|
verifyAccessKey,
|
|
} from '../Crypto';
|
|
import { GroupChangeClass } from '../textsecure.d';
|
|
import { BodyRangesType } from '../types/Util';
|
|
import { getTextWithMentions } from '../util';
|
|
import { migrateColor } from '../util/migrateColor';
|
|
import { isNotNil } from '../util/isNotNil';
|
|
import {
|
|
PhoneNumberSharingMode,
|
|
parsePhoneNumberSharingMode,
|
|
} from '../util/phoneNumberSharingMode';
|
|
import {
|
|
SenderCertificateMode,
|
|
SerializedCertificateType,
|
|
} from '../metadata/SecretSessionCipher';
|
|
import { senderCertificateService } from '../services/senderCertificate';
|
|
|
|
/* eslint-disable more/no-then */
|
|
window.Whisper = window.Whisper || {};
|
|
|
|
const SEALED_SENDER = {
|
|
UNKNOWN: 0,
|
|
ENABLED: 1,
|
|
DISABLED: 2,
|
|
UNRESTRICTED: 3,
|
|
};
|
|
|
|
const { Services, Util } = window.Signal;
|
|
const { Contact, Message } = window.Signal.Types;
|
|
const {
|
|
deleteAttachmentData,
|
|
doesAttachmentExist,
|
|
getAbsoluteAttachmentPath,
|
|
loadAttachmentData,
|
|
readStickerData,
|
|
upgradeMessageSchema,
|
|
writeNewAttachmentData,
|
|
} = window.Signal.Migrations;
|
|
const { addStickerPackReference } = window.Signal.Data;
|
|
|
|
const COLORS = [
|
|
'red',
|
|
'deep_orange',
|
|
'brown',
|
|
'pink',
|
|
'purple',
|
|
'indigo',
|
|
'blue',
|
|
'teal',
|
|
'green',
|
|
'light_green',
|
|
'blue_grey',
|
|
'ultramarine',
|
|
];
|
|
|
|
const THREE_HOURS = 3 * 60 * 60 * 1000;
|
|
|
|
type CustomError = Error & {
|
|
identifier?: string;
|
|
number?: string;
|
|
};
|
|
|
|
type CachedIdenticon = {
|
|
readonly url: string;
|
|
readonly content: string;
|
|
readonly color: ColorType;
|
|
};
|
|
|
|
export class ConversationModel extends window.Backbone.Model<
|
|
ConversationAttributesType
|
|
> {
|
|
static COLORS: string;
|
|
|
|
cachedProps?: ConversationType | null;
|
|
|
|
oldCachedProps?: ConversationType | null;
|
|
|
|
contactTypingTimers?: Record<
|
|
string,
|
|
{ senderId: string; timer: NodeJS.Timer }
|
|
>;
|
|
|
|
contactCollection?: Backbone.Collection<ConversationModel>;
|
|
|
|
debouncedUpdateLastMessage?: () => void;
|
|
|
|
// backbone ensures this exists
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
id: string;
|
|
|
|
initialPromise?: Promise<unknown>;
|
|
|
|
inProgressFetch?: Promise<unknown>;
|
|
|
|
incomingMessageQueue?: typeof window.PQueueType;
|
|
|
|
jobQueue?: typeof window.PQueueType;
|
|
|
|
messageCollection?: MessageModelCollectionType;
|
|
|
|
ourNumber?: string;
|
|
|
|
ourUuid?: string;
|
|
|
|
storeName?: string | null;
|
|
|
|
throttledBumpTyping: unknown;
|
|
|
|
typingRefreshTimer?: NodeJS.Timer | null;
|
|
|
|
typingPauseTimer?: NodeJS.Timer | null;
|
|
|
|
verifiedEnum?: typeof window.textsecure.storage.protocol.VerifiedStatus;
|
|
|
|
intlCollator = new Intl.Collator(undefined, { sensitivity: 'base' });
|
|
|
|
private cachedLatestGroupCallEraId?: string;
|
|
|
|
private cachedIdenticon?: CachedIdenticon;
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
defaults(): Partial<ConversationAttributesType> {
|
|
return {
|
|
unreadCount: 0,
|
|
verified: window.textsecure.storage.protocol.VerifiedStatus.DEFAULT,
|
|
messageCount: 0,
|
|
sentMessageCount: 0,
|
|
};
|
|
}
|
|
|
|
idForLogging(): string {
|
|
if (this.isPrivate()) {
|
|
const uuid = this.get('uuid');
|
|
const e164 = this.get('e164');
|
|
return `${uuid || e164} (${this.id})`;
|
|
}
|
|
if (this.isGroupV2()) {
|
|
return `groupv2(${this.get('groupId')})`;
|
|
}
|
|
|
|
const groupId = this.get('groupId');
|
|
return `group(${groupId})`;
|
|
}
|
|
|
|
// This is one of the few times that we want to collapse our uuid/e164 pair down into
|
|
// just one bit of data. If we have a UUID, we'll send using it.
|
|
getSendTarget(): string | undefined {
|
|
return this.get('uuid') || this.get('e164');
|
|
}
|
|
|
|
handleMessageError(message: unknown, errors: unknown): void {
|
|
this.trigger('messageError', message, errors);
|
|
}
|
|
|
|
// eslint-disable-next-line class-methods-use-this
|
|
getContactCollection(): Backbone.Collection<ConversationModel> {
|
|
const collection = new window.Backbone.Collection<ConversationModel>();
|
|
const collator = new Intl.Collator(undefined, { sensitivity: 'base' });
|
|
collection.comparator = (
|
|
left: ConversationModel,
|
|
right: ConversationModel
|
|
) => {
|
|
return collator.compare(left.getTitle(), right.getTitle());
|
|
};
|
|
return collection;
|
|
}
|
|
|
|
initialize(attributes: Partial<ConversationAttributesType> = {}): void {
|
|
if (window.isValidE164(attributes.id)) {
|
|
this.set({ id: window.getGuid(), e164: attributes.id });
|
|
}
|
|
|
|
this.storeName = 'conversations';
|
|
|
|
this.ourNumber = window.textsecure.storage.user.getNumber();
|
|
this.ourUuid = window.textsecure.storage.user.getUuid();
|
|
this.verifiedEnum = window.textsecure.storage.protocol.VerifiedStatus;
|
|
|
|
// This may be overridden by window.ConversationController.getOrCreate, and signify
|
|
// our first save to the database. Or first fetch from the database.
|
|
this.initialPromise = Promise.resolve();
|
|
|
|
this.throttledBumpTyping = window._.throttle(this.bumpTyping, 300);
|
|
this.debouncedUpdateLastMessage = window._.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 (!this.isPrivate()) {
|
|
this.contactCollection.on(
|
|
'change:verified',
|
|
this.onMemberVerifiedChange.bind(this)
|
|
);
|
|
}
|
|
|
|
this.messageCollection = new window.Whisper.MessageCollection([], {
|
|
conversation: this,
|
|
});
|
|
|
|
this.messageCollection.on('change:errors', this.handleMessageError, this);
|
|
this.messageCollection.on('send-error', this.onMessageError, this);
|
|
|
|
this.listenTo(
|
|
this.messageCollection,
|
|
'add remove destroy content-changed',
|
|
this.debouncedUpdateLastMessage
|
|
);
|
|
this.listenTo(this.messageCollection, 'sent', this.updateLastMessage);
|
|
this.listenTo(this.messageCollection, 'send-error', this.updateLastMessage);
|
|
|
|
this.on('newmessage', this.onNewMessage);
|
|
this.on('change:profileKey', this.onChangeProfileKey);
|
|
|
|
// Listening for out-of-band data updates
|
|
this.on('delivered', this.updateAndMerge);
|
|
this.on('read', this.updateAndMerge);
|
|
this.on('expiration-change', this.updateAndMerge);
|
|
this.on('expired', this.onExpired);
|
|
|
|
const sealedSender = this.get('sealedSender');
|
|
if (sealedSender === undefined) {
|
|
this.set({ sealedSender: SEALED_SENDER.UNKNOWN });
|
|
}
|
|
this.unset('unidentifiedDelivery');
|
|
this.unset('unidentifiedDeliveryUnrestricted');
|
|
this.unset('hasFetchedProfile');
|
|
this.unset('tokens');
|
|
|
|
this.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', () => {
|
|
if (this.cachedProps) {
|
|
this.oldCachedProps = this.cachedProps;
|
|
}
|
|
this.cachedProps = null;
|
|
});
|
|
}
|
|
|
|
isMe(): boolean {
|
|
const e164 = this.get('e164');
|
|
const uuid = this.get('uuid');
|
|
return Boolean(
|
|
(e164 && e164 === this.ourNumber) || (uuid && uuid === this.ourUuid)
|
|
);
|
|
}
|
|
|
|
isGroupV1(): boolean {
|
|
const groupId = this.get('groupId');
|
|
if (!groupId) {
|
|
return false;
|
|
}
|
|
|
|
const buffer = fromEncodedBinaryToArrayBuffer(groupId);
|
|
return buffer.byteLength === window.Signal.Groups.ID_V1_LENGTH;
|
|
}
|
|
|
|
isGroupV2(): boolean {
|
|
const groupId = this.get('groupId');
|
|
if (!groupId) {
|
|
return false;
|
|
}
|
|
|
|
const groupVersion = this.get('groupVersion') || 0;
|
|
|
|
try {
|
|
return (
|
|
groupVersion === 2 &&
|
|
base64ToArrayBuffer(groupId).byteLength ===
|
|
window.Signal.Groups.ID_LENGTH
|
|
);
|
|
} catch (error) {
|
|
window.log.error('isGroupV2: Failed to process groupId in base64!');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
isMemberRequestingToJoin(conversationId: string): boolean {
|
|
if (!this.isGroupV2()) {
|
|
return false;
|
|
}
|
|
const pendingAdminApprovalV2 = this.get('pendingAdminApprovalV2');
|
|
|
|
if (!pendingAdminApprovalV2 || !pendingAdminApprovalV2.length) {
|
|
return false;
|
|
}
|
|
|
|
return pendingAdminApprovalV2.some(
|
|
item => item.conversationId === conversationId
|
|
);
|
|
}
|
|
|
|
isMemberPending(conversationId: string): boolean {
|
|
if (!this.isGroupV2()) {
|
|
return false;
|
|
}
|
|
const pendingMembersV2 = this.get('pendingMembersV2');
|
|
|
|
if (!pendingMembersV2 || !pendingMembersV2.length) {
|
|
return false;
|
|
}
|
|
|
|
return window._.any(
|
|
pendingMembersV2,
|
|
item => item.conversationId === conversationId
|
|
);
|
|
}
|
|
|
|
isMemberAwaitingApproval(conversationId: string): boolean {
|
|
if (!this.isGroupV2()) {
|
|
return false;
|
|
}
|
|
const pendingAdminApprovalV2 = this.get('pendingAdminApprovalV2');
|
|
|
|
if (!pendingAdminApprovalV2 || !pendingAdminApprovalV2.length) {
|
|
return false;
|
|
}
|
|
|
|
return window._.any(
|
|
pendingAdminApprovalV2,
|
|
item => item.conversationId === conversationId
|
|
);
|
|
}
|
|
|
|
isMember(conversationId: string): boolean {
|
|
if (!this.isGroupV2()) {
|
|
throw new Error(
|
|
`isMember: Called for non-GroupV2 conversation ${this.idForLogging()}`
|
|
);
|
|
}
|
|
const membersV2 = this.get('membersV2');
|
|
|
|
if (!membersV2 || !membersV2.length) {
|
|
return false;
|
|
}
|
|
|
|
return window._.any(
|
|
membersV2,
|
|
item => item.conversationId === conversationId
|
|
);
|
|
}
|
|
|
|
async updateExpirationTimerInGroupV2(
|
|
seconds?: number
|
|
): Promise<GroupChangeClass.Actions | undefined> {
|
|
const idLog = this.idForLogging();
|
|
const current = this.get('expireTimer');
|
|
const bothFalsey = Boolean(current) === false && Boolean(seconds) === false;
|
|
|
|
if (current === seconds || bothFalsey) {
|
|
window.log.warn(
|
|
`updateExpirationTimerInGroupV2/${idLog}: Requested timer ${seconds} is unchanged from existing ${current}.`
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
return window.Signal.Groups.buildDisappearingMessagesTimerChange({
|
|
expireTimer: seconds || 0,
|
|
group: this.attributes,
|
|
});
|
|
}
|
|
|
|
async promotePendingMember(
|
|
conversationId: string
|
|
): Promise<GroupChangeClass.Actions | undefined> {
|
|
const idLog = this.idForLogging();
|
|
|
|
// This user's pending state may have changed in the time between the user's
|
|
// button press and when we get here. It's especially important to check here
|
|
// in conflict/retry cases.
|
|
if (!this.isMemberPending(conversationId)) {
|
|
window.log.warn(
|
|
`promotePendingMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.`
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
const pendingMember = window.ConversationController.get(conversationId);
|
|
if (!pendingMember) {
|
|
throw new Error(
|
|
`promotePendingMember/${idLog}: No conversation found for conversation ${conversationId}`
|
|
);
|
|
}
|
|
|
|
// We need the user's profileKeyCredential, which requires a roundtrip with the
|
|
// server, and most definitely their profileKey. A getProfiles() call will
|
|
// ensure that we have as much as we can get with the data we have.
|
|
let profileKeyCredentialBase64 = pendingMember.get('profileKeyCredential');
|
|
if (!profileKeyCredentialBase64) {
|
|
await pendingMember.getProfiles();
|
|
|
|
profileKeyCredentialBase64 = pendingMember.get('profileKeyCredential');
|
|
if (!profileKeyCredentialBase64) {
|
|
throw new Error(
|
|
`promotePendingMember/${idLog}: No profileKeyCredential for conversation ${pendingMember.idForLogging()}`
|
|
);
|
|
}
|
|
}
|
|
|
|
return window.Signal.Groups.buildPromoteMemberChange({
|
|
group: this.attributes,
|
|
profileKeyCredentialBase64,
|
|
serverPublicParamsBase64: window.getServerPublicParams(),
|
|
});
|
|
}
|
|
|
|
async approvePendingApprovalRequest(
|
|
conversationId: string
|
|
): Promise<GroupChangeClass.Actions | undefined> {
|
|
const idLog = this.idForLogging();
|
|
|
|
// This user's pending state may have changed in the time between the user's
|
|
// button press and when we get here. It's especially important to check here
|
|
// in conflict/retry cases.
|
|
if (!this.isMemberRequestingToJoin(conversationId)) {
|
|
window.log.warn(
|
|
`approvePendingApprovalRequest/${idLog}: ${conversationId} is not requesting to join the group. Returning early.`
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
const pendingMember = window.ConversationController.get(conversationId);
|
|
if (!pendingMember) {
|
|
throw new Error(
|
|
`approvePendingApprovalRequest/${idLog}: No conversation found for conversation ${conversationId}`
|
|
);
|
|
}
|
|
|
|
const uuid = pendingMember.get('uuid');
|
|
if (!uuid) {
|
|
throw new Error(
|
|
`approvePendingApprovalRequest/${idLog}: Missing uuid for conversation ${conversationId}`
|
|
);
|
|
}
|
|
|
|
return window.Signal.Groups.buildPromotePendingAdminApprovalMemberChange({
|
|
group: this.attributes,
|
|
uuid,
|
|
});
|
|
}
|
|
|
|
async denyPendingApprovalRequest(
|
|
conversationId: string
|
|
): Promise<GroupChangeClass.Actions | undefined> {
|
|
const idLog = this.idForLogging();
|
|
|
|
// This user's pending state may have changed in the time between the user's
|
|
// button press and when we get here. It's especially important to check here
|
|
// in conflict/retry cases.
|
|
if (!this.isMemberRequestingToJoin(conversationId)) {
|
|
window.log.warn(
|
|
`denyPendingApprovalRequest/${idLog}: ${conversationId} is not requesting to join the group. Returning early.`
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
const pendingMember = window.ConversationController.get(conversationId);
|
|
if (!pendingMember) {
|
|
throw new Error(
|
|
`denyPendingApprovalRequest/${idLog}: No conversation found for conversation ${conversationId}`
|
|
);
|
|
}
|
|
|
|
const uuid = pendingMember.get('uuid');
|
|
if (!uuid) {
|
|
throw new Error(
|
|
`denyPendingApprovalRequest/${idLog}: Missing uuid for conversation ${pendingMember.idForLogging()}`
|
|
);
|
|
}
|
|
|
|
return window.Signal.Groups.buildDeletePendingAdminApprovalMemberChange({
|
|
group: this.attributes,
|
|
uuid,
|
|
});
|
|
}
|
|
|
|
async addPendingApprovalRequest(): Promise<
|
|
GroupChangeClass.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}`
|
|
);
|
|
}
|
|
|
|
// 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(conversationId)) {
|
|
window.log.warn(
|
|
`addPendingApprovalRequest/${idLog}: ${conversationId} already in pending approval.`
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
return window.Signal.Groups.buildAddPendingAdminApprovalMemberChange({
|
|
group: this.attributes,
|
|
profileKeyCredentialBase64,
|
|
serverPublicParamsBase64: window.getServerPublicParams(),
|
|
});
|
|
}
|
|
|
|
async addMember(
|
|
conversationId: string
|
|
): Promise<GroupChangeClass.Actions | undefined> {
|
|
const idLog = this.idForLogging();
|
|
|
|
const toRequest = window.ConversationController.get(conversationId);
|
|
if (!toRequest) {
|
|
throw new Error(
|
|
`addMember/${idLog}: No conversation found for conversation ${conversationId}`
|
|
);
|
|
}
|
|
|
|
// We need the user's profileKeyCredential, which requires a roundtrip with the
|
|
// server, and most definitely their profileKey. A getProfiles() call will
|
|
// ensure that we have as much as we can get with the data we have.
|
|
let profileKeyCredentialBase64 = 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(conversationId)) {
|
|
window.log.warn(
|
|
`addMember/${idLog}: ${conversationId} already a member.`
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
return window.Signal.Groups.buildAddMember({
|
|
group: this.attributes,
|
|
profileKeyCredentialBase64,
|
|
serverPublicParamsBase64: window.getServerPublicParams(),
|
|
});
|
|
}
|
|
|
|
async removePendingMember(
|
|
conversationIds: Array<string>
|
|
): Promise<GroupChangeClass.Actions | undefined> {
|
|
const idLog = this.idForLogging();
|
|
|
|
const uuids = conversationIds
|
|
.map(conversationId => {
|
|
// This user's pending state may have changed in the time between the user's
|
|
// button press and when we get here. It's especially important to check here
|
|
// in conflict/retry cases.
|
|
if (!this.isMemberPending(conversationId)) {
|
|
window.log.warn(
|
|
`removePendingMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.`
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
const pendingMember = window.ConversationController.get(conversationId);
|
|
if (!pendingMember) {
|
|
window.log.warn(
|
|
`removePendingMember/${idLog}: No conversation found for conversation ${conversationId}`
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
const uuid = pendingMember.get('uuid');
|
|
if (!uuid) {
|
|
window.log.warn(
|
|
`removePendingMember/${idLog}: Missing uuid for conversation ${pendingMember.idForLogging()}`
|
|
);
|
|
return undefined;
|
|
}
|
|
return uuid;
|
|
})
|
|
.filter((uuid): uuid is string => Boolean(uuid));
|
|
|
|
if (!uuids.length) {
|
|
return undefined;
|
|
}
|
|
|
|
return window.Signal.Groups.buildDeletePendingMemberChange({
|
|
group: this.attributes,
|
|
uuids,
|
|
});
|
|
}
|
|
|
|
async removeMember(
|
|
conversationId: string
|
|
): Promise<GroupChangeClass.Actions | undefined> {
|
|
const idLog = this.idForLogging();
|
|
|
|
// This user's pending state may have changed in the time between the user's
|
|
// button press and when we get here. It's especially important to check here
|
|
// in conflict/retry cases.
|
|
if (!this.isMember(conversationId)) {
|
|
window.log.warn(
|
|
`removeMember/${idLog}: ${conversationId} is not a pending member of group. Returning early.`
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
const member = window.ConversationController.get(conversationId);
|
|
if (!member) {
|
|
throw new Error(
|
|
`removeMember/${idLog}: No conversation found for conversation ${conversationId}`
|
|
);
|
|
}
|
|
|
|
const uuid = member.get('uuid');
|
|
if (!uuid) {
|
|
throw new Error(
|
|
`removeMember/${idLog}: Missing uuid for conversation ${member.idForLogging()}`
|
|
);
|
|
}
|
|
|
|
return window.Signal.Groups.buildDeleteMemberChange({
|
|
group: this.attributes,
|
|
uuid,
|
|
});
|
|
}
|
|
|
|
async toggleAdminChange(
|
|
conversationId: string
|
|
): Promise<GroupChangeClass.Actions | undefined> {
|
|
if (!this.isGroupV2()) {
|
|
return undefined;
|
|
}
|
|
|
|
const idLog = this.idForLogging();
|
|
|
|
if (!this.isMember(conversationId)) {
|
|
window.log.warn(
|
|
`toggleAdminChange/${idLog}: ${conversationId} is not a pending member of group. Returning early.`
|
|
);
|
|
return undefined;
|
|
}
|
|
|
|
const conversation = window.ConversationController.get(conversationId);
|
|
if (!conversation) {
|
|
throw new Error(
|
|
`toggleAdminChange/${idLog}: No conversation found for conversation ${conversationId}`
|
|
);
|
|
}
|
|
|
|
const uuid = conversation.get('uuid');
|
|
if (!uuid) {
|
|
throw new Error(
|
|
`toggleAdminChange/${idLog}: Missing uuid for conversation ${conversationId}`
|
|
);
|
|
}
|
|
|
|
const MEMBER_ROLES = window.textsecure.protobuf.Member.Role;
|
|
|
|
const role = this.isAdmin(conversationId)
|
|
? MEMBER_ROLES.DEFAULT
|
|
: MEMBER_ROLES.ADMINISTRATOR;
|
|
|
|
return window.Signal.Groups.buildModifyMemberRoleChange({
|
|
group: this.attributes,
|
|
uuid,
|
|
role,
|
|
});
|
|
}
|
|
|
|
async modifyGroupV2({
|
|
createGroupChange,
|
|
extraConversationsForSend,
|
|
inviteLinkPassword,
|
|
name,
|
|
}: {
|
|
createGroupChange: () => Promise<GroupChangeClass.Actions | undefined>;
|
|
extraConversationsForSend?: Array<string>;
|
|
inviteLinkPassword?: string;
|
|
name: string;
|
|
}): Promise<void> {
|
|
await window.Signal.Groups.modifyGroupV2({
|
|
conversation: this,
|
|
createGroupChange,
|
|
extraConversationsForSend,
|
|
inviteLinkPassword,
|
|
name,
|
|
});
|
|
}
|
|
|
|
isEverUnregistered(): boolean {
|
|
return Boolean(this.get('discoveredUnregisteredAt'));
|
|
}
|
|
|
|
isUnregistered(): boolean {
|
|
return isConversationUnregistered(this.attributes);
|
|
}
|
|
|
|
setUnregistered(): void {
|
|
window.log.info(`Conversation ${this.idForLogging()} is now unregistered`);
|
|
this.set({
|
|
discoveredUnregisteredAt: Date.now(),
|
|
});
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
}
|
|
|
|
setRegistered(): void {
|
|
window.log.info(
|
|
`Conversation ${this.idForLogging()} is registered once again`
|
|
);
|
|
this.set({
|
|
discoveredUnregisteredAt: undefined,
|
|
});
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
}
|
|
|
|
isGroupV1AndDisabled(): boolean {
|
|
return (
|
|
this.isGroupV1() &&
|
|
window.Signal.RemoteConfig.isEnabled('desktop.disableGV1')
|
|
);
|
|
}
|
|
|
|
isBlocked(): boolean {
|
|
const uuid = this.get('uuid');
|
|
if (uuid) {
|
|
return window.storage.isUuidBlocked(uuid);
|
|
}
|
|
|
|
const e164 = this.get('e164');
|
|
if (e164) {
|
|
return window.storage.isBlocked(e164);
|
|
}
|
|
|
|
const groupId = this.get('groupId');
|
|
if (groupId) {
|
|
return window.storage.isGroupBlocked(groupId);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
block({ viaStorageServiceSync = false } = {}): void {
|
|
let blocked = false;
|
|
const isBlocked = this.isBlocked();
|
|
|
|
const uuid = this.get('uuid');
|
|
if (uuid) {
|
|
window.storage.addBlockedUuid(uuid);
|
|
blocked = true;
|
|
}
|
|
|
|
const e164 = this.get('e164');
|
|
if (e164) {
|
|
window.storage.addBlockedNumber(e164);
|
|
blocked = true;
|
|
}
|
|
|
|
const groupId = this.get('groupId');
|
|
if (groupId) {
|
|
window.storage.addBlockedGroup(groupId);
|
|
blocked = true;
|
|
}
|
|
|
|
if (!viaStorageServiceSync && !isBlocked && blocked) {
|
|
this.captureChange('block');
|
|
}
|
|
}
|
|
|
|
unblock({ viaStorageServiceSync = false } = {}): boolean {
|
|
let unblocked = false;
|
|
const isBlocked = this.isBlocked();
|
|
|
|
const uuid = this.get('uuid');
|
|
if (uuid) {
|
|
window.storage.removeBlockedUuid(uuid);
|
|
unblocked = true;
|
|
}
|
|
|
|
const e164 = this.get('e164');
|
|
if (e164) {
|
|
window.storage.removeBlockedNumber(e164);
|
|
unblocked = true;
|
|
}
|
|
|
|
const groupId = this.get('groupId');
|
|
if (groupId) {
|
|
window.storage.removeBlockedGroup(groupId);
|
|
unblocked = true;
|
|
}
|
|
|
|
if (!viaStorageServiceSync && isBlocked && unblocked) {
|
|
this.captureChange('unblock');
|
|
}
|
|
|
|
return unblocked;
|
|
}
|
|
|
|
enableProfileSharing({ viaStorageServiceSync = false } = {}): void {
|
|
const before = this.get('profileSharing');
|
|
|
|
this.set({ profileSharing: true });
|
|
|
|
const after = this.get('profileSharing');
|
|
|
|
if (!viaStorageServiceSync && Boolean(before) !== Boolean(after)) {
|
|
this.captureChange('enableProfileSharing');
|
|
}
|
|
}
|
|
|
|
disableProfileSharing({ viaStorageServiceSync = false } = {}): void {
|
|
const before = this.get('profileSharing');
|
|
|
|
this.set({ profileSharing: false });
|
|
|
|
const after = this.get('profileSharing');
|
|
|
|
if (!viaStorageServiceSync && Boolean(before) !== Boolean(after)) {
|
|
this.captureChange('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.storage.get('typingIndicators')) {
|
|
return;
|
|
}
|
|
|
|
if (!this.typingRefreshTimer) {
|
|
const isTyping = true;
|
|
this.setTypingRefreshTimer();
|
|
this.sendTypingMessage(isTyping);
|
|
}
|
|
|
|
this.setTypingPauseTimer();
|
|
}
|
|
|
|
setTypingRefreshTimer(): void {
|
|
if (this.typingRefreshTimer) {
|
|
clearTimeout(this.typingRefreshTimer);
|
|
}
|
|
this.typingRefreshTimer = setTimeout(
|
|
this.onTypingRefreshTimeout.bind(this),
|
|
10 * 1000
|
|
);
|
|
}
|
|
|
|
onTypingRefreshTimeout(): void {
|
|
const isTyping = true;
|
|
this.sendTypingMessage(isTyping);
|
|
|
|
// This timer will continue to reset itself until the pause timer stops it
|
|
this.setTypingRefreshTimer();
|
|
}
|
|
|
|
setTypingPauseTimer(): void {
|
|
if (this.typingPauseTimer) {
|
|
clearTimeout(this.typingPauseTimer);
|
|
}
|
|
this.typingPauseTimer = setTimeout(
|
|
this.onTypingPauseTimeout.bind(this),
|
|
3 * 1000
|
|
);
|
|
}
|
|
|
|
onTypingPauseTimeout(): void {
|
|
const isTyping = false;
|
|
this.sendTypingMessage(isTyping);
|
|
|
|
this.clearTypingTimers();
|
|
}
|
|
|
|
clearTypingTimers(): void {
|
|
if (this.typingPauseTimer) {
|
|
clearTimeout(this.typingPauseTimer);
|
|
this.typingPauseTimer = null;
|
|
}
|
|
if (this.typingRefreshTimer) {
|
|
clearTimeout(this.typingRefreshTimer);
|
|
this.typingRefreshTimer = null;
|
|
}
|
|
}
|
|
|
|
async fetchLatestGroupV2Data(): Promise<void> {
|
|
if (!this.isGroupV2()) {
|
|
return;
|
|
}
|
|
|
|
await window.Signal.Groups.waitThenMaybeUpdateGroup({
|
|
conversation: this,
|
|
});
|
|
}
|
|
|
|
isValid(): boolean {
|
|
return this.isPrivate() || this.isGroupV1() || this.isGroupV2();
|
|
}
|
|
|
|
async maybeMigrateV1Group(): Promise<void> {
|
|
if (!this.isGroupV1()) {
|
|
return;
|
|
}
|
|
|
|
const isMigrated = await window.Signal.Groups.hasV1GroupBeenMigrated(this);
|
|
if (!isMigrated) {
|
|
return;
|
|
}
|
|
|
|
await window.Signal.Groups.waitThenRespondToGroupV2Migration({
|
|
conversation: this,
|
|
});
|
|
}
|
|
|
|
maybeRepairGroupV2(data: {
|
|
masterKey: string;
|
|
secretParams: string;
|
|
publicParams: string;
|
|
}): void {
|
|
if (
|
|
this.get('groupVersion') &&
|
|
this.get('masterKey') &&
|
|
this.get('secretParams') &&
|
|
this.get('publicParams')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
window.log.info(`Repairing GroupV2 conversation ${this.idForLogging()}`);
|
|
const { masterKey, secretParams, publicParams } = data;
|
|
|
|
this.set({ masterKey, secretParams, publicParams, groupVersion: 2 });
|
|
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
}
|
|
|
|
getGroupV2Info({
|
|
groupChange,
|
|
includePendingMembers,
|
|
extraConversationsForSend,
|
|
}: {
|
|
groupChange?: ArrayBuffer;
|
|
includePendingMembers?: boolean;
|
|
extraConversationsForSend?: Array<string>;
|
|
} = {}): GroupV2InfoType | undefined {
|
|
if (this.isPrivate() || !this.isGroupV2()) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
masterKey: window.Signal.Crypto.base64ToArrayBuffer(
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.get('masterKey')!
|
|
),
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
revision: this.get('revision')!,
|
|
members: this.getRecipients({
|
|
includePendingMembers,
|
|
extraConversationsForSend,
|
|
}),
|
|
groupChange,
|
|
};
|
|
}
|
|
|
|
getGroupV1Info(): WhatIsThis {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
if (this.isPrivate() || this.get('groupVersion')! > 0) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
id: this.get('groupId'),
|
|
members: this.getRecipients(),
|
|
};
|
|
}
|
|
|
|
getGroupIdBuffer(): ArrayBuffer | undefined {
|
|
const groupIdString = this.get('groupId');
|
|
|
|
if (!groupIdString) {
|
|
return undefined;
|
|
}
|
|
|
|
if (this.isGroupV1()) {
|
|
return fromEncodedBinaryToArrayBuffer(groupIdString);
|
|
}
|
|
if (this.isGroupV2()) {
|
|
return base64ToArrayBuffer(groupIdString);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
async sendTypingMessage(isTyping: boolean): Promise<void> {
|
|
if (!window.textsecure.messaging) {
|
|
return;
|
|
}
|
|
|
|
// We don't send typing messages to our other devices
|
|
if (this.isMe()) {
|
|
return;
|
|
}
|
|
|
|
const recipientId = this.isPrivate() ? this.getSendTarget() : undefined;
|
|
const groupId = this.getGroupIdBuffer();
|
|
const groupMembers = this.getRecipients();
|
|
|
|
// We don't send typing messages if our recipients list is empty
|
|
if (!this.isPrivate() && !groupMembers.length) {
|
|
return;
|
|
}
|
|
|
|
const sendOptions = await this.getSendOptions();
|
|
this.wrapSend(
|
|
window.textsecure.messaging.sendTypingMessage(
|
|
{
|
|
isTyping,
|
|
recipientId,
|
|
groupId,
|
|
groupMembers,
|
|
},
|
|
sendOptions
|
|
)
|
|
);
|
|
}
|
|
|
|
async cleanup(): Promise<void> {
|
|
await window.Signal.Types.Conversation.deleteExternalFiles(
|
|
this.attributes,
|
|
{
|
|
deleteAttachmentData,
|
|
}
|
|
);
|
|
}
|
|
|
|
async updateAndMerge(message: MessageModel): Promise<void> {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.debouncedUpdateLastMessage!();
|
|
|
|
const mergeMessage = () => {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const existing = this.messageCollection!.get(message.id);
|
|
if (!existing) {
|
|
return;
|
|
}
|
|
|
|
existing.merge(message.attributes);
|
|
};
|
|
|
|
await this.inProgressFetch;
|
|
mergeMessage();
|
|
}
|
|
|
|
async onExpired(message: MessageModel): Promise<void> {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.debouncedUpdateLastMessage!();
|
|
|
|
const removeMessage = () => {
|
|
const { id } = message;
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const existing = this.messageCollection!.get(id);
|
|
if (!existing) {
|
|
return;
|
|
}
|
|
|
|
window.log.info('Remove expired message from collection', {
|
|
sentAt: existing.get('sent_at'),
|
|
});
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.messageCollection!.remove(id);
|
|
existing.trigger('expired');
|
|
existing.cleanup();
|
|
|
|
// An expired message only counts as decrementing the message count, not
|
|
// the sent message count
|
|
this.decrementMessageCount();
|
|
};
|
|
|
|
// If a fetch is in progress, then we need to wait until that's complete to
|
|
// do this removal. Otherwise we could remove from messageCollection, then
|
|
// the async database fetch could include the removed message.
|
|
|
|
await this.inProgressFetch;
|
|
removeMessage();
|
|
}
|
|
|
|
async onNewMessage(message: WhatIsThis): Promise<void> {
|
|
const uuid = message.get ? message.get('sourceUuid') : message.sourceUuid;
|
|
const e164 = message.get ? message.get('source') : message.source;
|
|
const sourceDevice = message.get
|
|
? message.get('sourceDevice')
|
|
: message.sourceDevice;
|
|
|
|
const sourceId = window.ConversationController.ensureContactIds({
|
|
uuid,
|
|
e164,
|
|
});
|
|
const typingToken = `${sourceId}.${sourceDevice}`;
|
|
|
|
// Clear typing indicator for a given contact if we receive a message from them
|
|
this.clearContactTypingTimer(typingToken);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.debouncedUpdateLastMessage!();
|
|
}
|
|
|
|
// For outgoing messages, we can call this directly. We're already loaded.
|
|
addSingleMessage(message: MessageModel): MessageModel {
|
|
const { id } = message;
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const existing = this.messageCollection!.get(id);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const model = this.messageCollection!.add(message, { merge: true });
|
|
model.setToExpire();
|
|
|
|
if (!existing) {
|
|
const { messagesAdded } = window.reduxActions.conversations;
|
|
const isNewMessage = true;
|
|
messagesAdded(
|
|
this.id,
|
|
[model.getReduxData()],
|
|
isNewMessage,
|
|
window.isActive()
|
|
);
|
|
}
|
|
|
|
return model;
|
|
}
|
|
|
|
// For incoming messages, they might arrive while we're in the middle of a bulk fetch
|
|
// from the database. We'll wait until that is done to process this newly-arrived
|
|
// message.
|
|
addIncomingMessage(message: MessageModel): void {
|
|
if (!this.incomingMessageQueue) {
|
|
this.incomingMessageQueue = new window.PQueue({
|
|
concurrency: 1,
|
|
timeout: 1000 * 60 * 2,
|
|
});
|
|
}
|
|
|
|
// We use a queue here to ensure messages are added to the UI in the order received
|
|
this.incomingMessageQueue.add(async () => {
|
|
await this.inProgressFetch;
|
|
|
|
this.addSingleMessage(message);
|
|
});
|
|
}
|
|
|
|
format(): ConversationType {
|
|
if (this.cachedProps) {
|
|
return this.cachedProps;
|
|
}
|
|
|
|
const oldFormat = this.format;
|
|
// We don't want to crash or have an infinite loop if we loop back into this function
|
|
// again. We'll log a warning and returned old cached props or throw an error.
|
|
this.format = () => {
|
|
const { stack } = new Error('for stack');
|
|
window.log.warn(
|
|
`Conversation.format()/${this.idForLogging()} reentrant call! ${stack}`
|
|
);
|
|
if (!this.oldCachedProps) {
|
|
throw new Error(
|
|
`Conversation.format()/${this.idForLogging()} reentrant call, no old cached props!`
|
|
);
|
|
}
|
|
return this.oldCachedProps;
|
|
};
|
|
|
|
this.cachedProps = this.getProps();
|
|
|
|
this.format = oldFormat;
|
|
|
|
return this.cachedProps;
|
|
}
|
|
|
|
// Note: this should never be called directly. Use conversation.format() instead, which
|
|
// maintains a cache, and protects against reentrant calls.
|
|
// Note: When writing code inside this function, do not call .format() on a conversation
|
|
// unless you are sure that it's not this very same conversation.
|
|
private getProps(): ConversationType {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const color = this.getColor()!;
|
|
|
|
const typingValues = window._.values(this.contactTypingTimers || {});
|
|
const typingMostRecent = window._.first(
|
|
window._.sortBy(typingValues, 'timestamp')
|
|
);
|
|
const typingContact = typingMostRecent
|
|
? window.ConversationController.get(typingMostRecent.senderId)
|
|
: null;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const timestamp = this.get('timestamp')!;
|
|
const draftTimestamp = this.get('draftTimestamp');
|
|
const draftPreview = this.getDraftPreview();
|
|
const draftText = this.get('draft');
|
|
const draftBodyRanges = this.get('draftBodyRanges');
|
|
const shouldShowDraft = (this.hasDraft() &&
|
|
draftTimestamp &&
|
|
draftTimestamp >= timestamp) as boolean;
|
|
const inboxPosition = this.get('inbox_position');
|
|
const messageRequestsEnabled = window.Signal.RemoteConfig.isEnabled(
|
|
'desktop.messageRequests'
|
|
);
|
|
const ourConversationId = window.ConversationController.getOurConversationId();
|
|
|
|
let groupVersion: undefined | 1 | 2;
|
|
if (this.isGroupV1()) {
|
|
groupVersion = 1;
|
|
} else if (this.isGroupV2()) {
|
|
groupVersion = 2;
|
|
}
|
|
|
|
const sortedGroupMembers = this.isGroupV2()
|
|
? this.getMembers()
|
|
.sort((left, right) =>
|
|
sortConversationTitles(left, right, this.intlCollator)
|
|
)
|
|
.map(member => member.format())
|
|
.filter((member): member is ConversationType => member !== null)
|
|
: undefined;
|
|
|
|
// TODO: DESKTOP-720
|
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
|
const result: ConversationType = {
|
|
id: this.id,
|
|
uuid: this.get('uuid'),
|
|
e164: this.get('e164'),
|
|
|
|
about: this.getAboutText(),
|
|
acceptedMessageRequest: this.getAccepted(),
|
|
activeAt: this.get('active_at')!,
|
|
areWePending: Boolean(
|
|
ourConversationId && this.isMemberPending(ourConversationId)
|
|
),
|
|
areWePendingApproval: Boolean(
|
|
ourConversationId && this.isMemberAwaitingApproval(ourConversationId)
|
|
),
|
|
areWeAdmin: this.areWeAdmin(),
|
|
canChangeTimer: this.canChangeTimer(),
|
|
canEditGroupInfo: this.canEditGroupInfo(),
|
|
avatarPath: this.getAvatarPath()!,
|
|
color,
|
|
discoveredUnregisteredAt: this.get('discoveredUnregisteredAt'),
|
|
draftBodyRanges,
|
|
draftPreview,
|
|
draftText,
|
|
firstName: this.get('profileName')!,
|
|
groupVersion,
|
|
groupId: this.get('groupId'),
|
|
groupLink: this.getGroupLink(),
|
|
inboxPosition,
|
|
isArchived: this.get('isArchived')!,
|
|
isBlocked: this.isBlocked(),
|
|
isMe: this.isMe(),
|
|
isGroupV1AndDisabled: this.isGroupV1AndDisabled(),
|
|
isGroupV2Capable: this.isPrivate()
|
|
? Boolean(this.get('capabilities')?.gv2)
|
|
: undefined,
|
|
isPinned: this.get('isPinned'),
|
|
isUntrusted: this.isUntrusted(),
|
|
isVerified: this.isVerified(),
|
|
lastMessage: {
|
|
status: this.get('lastMessageStatus')!,
|
|
text: this.get('lastMessage')!,
|
|
deletedForEveryone: this.get('lastMessageDeletedForEveryone')!,
|
|
},
|
|
lastUpdated: this.get('timestamp')!,
|
|
left: Boolean(this.get('left')),
|
|
markedUnread: this.get('markedUnread')!,
|
|
membersCount: this.getMembersCount(),
|
|
memberships: this.getMemberships(),
|
|
messageCount: this.get('messageCount') || 0,
|
|
pendingMemberships: this.getPendingMemberships(),
|
|
pendingApprovalMemberships: this.getPendingApprovalMemberships(),
|
|
messageRequestsEnabled,
|
|
accessControlAddFromInviteLink: this.get('accessControl')
|
|
?.addFromInviteLink,
|
|
accessControlAttributes: this.get('accessControl')?.attributes,
|
|
accessControlMembers: this.get('accessControl')?.members,
|
|
expireTimer: this.get('expireTimer'),
|
|
muteExpiresAt: this.get('muteExpiresAt')!,
|
|
name: this.get('name')!,
|
|
phoneNumber: this.getNumber()!,
|
|
profileName: this.getProfileName()!,
|
|
profileSharing: this.get('profileSharing'),
|
|
publicParams: this.get('publicParams'),
|
|
secretParams: this.get('secretParams'),
|
|
sharedGroupNames: this.get('sharedGroupNames'),
|
|
shouldShowDraft,
|
|
sortedGroupMembers,
|
|
timestamp,
|
|
title: this.getTitle()!,
|
|
type: (this.isPrivate() ? 'direct' : 'group') as ConversationTypeType,
|
|
unreadCount: this.get('unreadCount')! || 0,
|
|
};
|
|
|
|
if (typingContact) {
|
|
// We don't want to call .format() on our own conversation
|
|
if (typingContact.id === this.id) {
|
|
result.typingContact = result;
|
|
} else {
|
|
result.typingContact = typingContact.format();
|
|
}
|
|
}
|
|
/* eslint-enable @typescript-eslint/no-non-null-assertion */
|
|
|
|
return result;
|
|
}
|
|
|
|
updateE164(e164?: string | null): void {
|
|
const oldValue = this.get('e164');
|
|
if (e164 && e164 !== oldValue) {
|
|
this.set('e164', e164);
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
this.trigger('idUpdated', this, 'e164', oldValue);
|
|
}
|
|
}
|
|
|
|
updateUuid(uuid?: string): void {
|
|
const oldValue = this.get('uuid');
|
|
if (uuid && uuid !== oldValue) {
|
|
this.set('uuid', uuid.toLowerCase());
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
this.trigger('idUpdated', this, 'uuid', oldValue);
|
|
}
|
|
}
|
|
|
|
updateGroupId(groupId?: string): void {
|
|
const oldValue = this.get('groupId');
|
|
if (groupId && groupId !== oldValue) {
|
|
this.set('groupId', groupId);
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
this.trigger('idUpdated', this, 'groupId', oldValue);
|
|
}
|
|
}
|
|
|
|
incrementMessageCount(): void {
|
|
this.set({
|
|
messageCount: (this.get('messageCount') || 0) + 1,
|
|
});
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
}
|
|
|
|
getMembersCount(): number | undefined {
|
|
if (this.isPrivate()) {
|
|
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 (window._.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(): void {
|
|
this.set({
|
|
messageCount: (this.get('messageCount') || 0) + 1,
|
|
sentMessageCount: (this.get('sentMessageCount') || 0) + 1,
|
|
});
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
}
|
|
|
|
decrementSentMessageCount(): void {
|
|
this.set({
|
|
messageCount: Math.max((this.get('messageCount') || 0) - 1, 0),
|
|
sentMessageCount: Math.max((this.get('sentMessageCount') || 0) - 1, 0),
|
|
});
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
}
|
|
|
|
/**
|
|
* This function is called when a message request is accepted in order to
|
|
* handle sending read receipts and download any pending attachments.
|
|
*/
|
|
async handleReadAndDownloadAttachments(
|
|
options: { isLocalAction?: boolean } = {}
|
|
): Promise<void> {
|
|
const { isLocalAction } = options;
|
|
|
|
let messages: MessageModelCollectionType | undefined;
|
|
do {
|
|
const first = messages ? messages.first() : undefined;
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
messages = await window.Signal.Data.getOlderMessagesByConversation(
|
|
this.get('id'),
|
|
{
|
|
MessageCollection: window.Whisper.MessageCollection,
|
|
limit: 100,
|
|
receivedAt: first ? first.get('received_at') : undefined,
|
|
sentAt: first ? first.get('sent_at') : undefined,
|
|
messageId: first ? first.id : undefined,
|
|
}
|
|
);
|
|
|
|
if (!messages.length) {
|
|
return;
|
|
}
|
|
|
|
const readMessages = messages.filter(
|
|
m => !m.hasErrors() && m.isIncoming()
|
|
);
|
|
const receiptSpecs = readMessages.map(m => ({
|
|
senderE164: m.get('source'),
|
|
senderUuid: m.get('sourceUuid'),
|
|
senderId: window.ConversationController.ensureContactIds({
|
|
e164: m.get('source'),
|
|
uuid: m.get('sourceUuid'),
|
|
}),
|
|
timestamp: m.get('sent_at'),
|
|
hasErrors: m.hasErrors(),
|
|
}));
|
|
|
|
if (isLocalAction) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await this.sendReadReceiptsFor(receiptSpecs);
|
|
}
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await Promise.all(
|
|
readMessages.map(async m => {
|
|
const registered = window.MessageController.register(m.id, m);
|
|
const shouldSave = await registered.queueAttachmentDownloads();
|
|
if (shouldSave) {
|
|
await window.Signal.Data.saveMessage(registered.attributes, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
}
|
|
})
|
|
);
|
|
} while (messages.length > 0);
|
|
}
|
|
|
|
async applyMessageRequestResponse(
|
|
response: number,
|
|
{ fromSync = false, viaStorageServiceSync = false } = {}
|
|
): Promise<void> {
|
|
try {
|
|
const messageRequestEnum =
|
|
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
|
|
const isLocalAction = !fromSync && !viaStorageServiceSync;
|
|
const ourConversationId = window.ConversationController.getOurConversationId();
|
|
|
|
const currentMessageRequestState = this.get('messageRequestResponseType');
|
|
const didResponseChange = response !== currentMessageRequestState;
|
|
const wasPreviouslyAccepted = this.getAccepted();
|
|
|
|
// Apply message request response locally
|
|
this.set({
|
|
messageRequestResponseType: response,
|
|
});
|
|
|
|
if (response === messageRequestEnum.ACCEPT) {
|
|
this.unblock({ viaStorageServiceSync });
|
|
this.enableProfileSharing({ viaStorageServiceSync });
|
|
|
|
// We really don't want to call this if we don't have to. It can take a lot of
|
|
// time to go through old messages to download attachments.
|
|
if (didResponseChange && !wasPreviouslyAccepted) {
|
|
await this.handleReadAndDownloadAttachments({ isLocalAction });
|
|
}
|
|
|
|
if (isLocalAction) {
|
|
if (this.isGroupV1() || this.isPrivate()) {
|
|
this.sendProfileKeyUpdate();
|
|
} else if (
|
|
ourConversationId &&
|
|
this.isGroupV2() &&
|
|
this.isMemberPending(ourConversationId)
|
|
) {
|
|
await this.modifyGroupV2({
|
|
name: 'promotePendingMember',
|
|
createGroupChange: () =>
|
|
this.promotePendingMember(ourConversationId),
|
|
});
|
|
} else if (
|
|
ourConversationId &&
|
|
this.isGroupV2() &&
|
|
this.isMember(ourConversationId)
|
|
) {
|
|
window.log.info(
|
|
'applyMessageRequestResponse/accept: Already a member of v2 group'
|
|
);
|
|
} else {
|
|
window.log.error(
|
|
'applyMessageRequestResponse/accept: Neither member nor pending member of v2 group'
|
|
);
|
|
}
|
|
}
|
|
} else if (response === messageRequestEnum.BLOCK) {
|
|
// Block locally, other devices should block upon receiving the sync message
|
|
this.block({ viaStorageServiceSync });
|
|
this.disableProfileSharing({ viaStorageServiceSync });
|
|
|
|
if (isLocalAction) {
|
|
if (this.isGroupV1() || this.isPrivate()) {
|
|
await this.leaveGroup();
|
|
} else if (this.isGroupV2()) {
|
|
await this.leaveGroupV2();
|
|
}
|
|
}
|
|
} else if (response === messageRequestEnum.DELETE) {
|
|
this.disableProfileSharing({ viaStorageServiceSync });
|
|
|
|
// Delete messages locally, other devices should delete upon receiving
|
|
// the sync message
|
|
await this.destroyMessages();
|
|
this.updateLastMessage();
|
|
|
|
if (isLocalAction) {
|
|
this.trigger('unload', 'deleted from message request');
|
|
|
|
if (this.isGroupV1() || this.isPrivate()) {
|
|
await this.leaveGroup();
|
|
} else if (this.isGroupV2()) {
|
|
await this.leaveGroupV2();
|
|
}
|
|
}
|
|
} else if (response === messageRequestEnum.BLOCK_AND_DELETE) {
|
|
// Block locally, other devices should block upon receiving the sync message
|
|
this.block({ viaStorageServiceSync });
|
|
this.disableProfileSharing({ viaStorageServiceSync });
|
|
|
|
// Delete messages locally, other devices should delete upon receiving
|
|
// the sync message
|
|
await this.destroyMessages();
|
|
this.updateLastMessage();
|
|
|
|
if (isLocalAction) {
|
|
this.trigger('unload', 'blocked and deleted from message request');
|
|
|
|
if (this.isGroupV1() || this.isPrivate()) {
|
|
await this.leaveGroup();
|
|
} else if (this.isGroupV2()) {
|
|
await this.leaveGroupV2();
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
}
|
|
}
|
|
|
|
async joinGroupV2ViaLinkAndMigrate({
|
|
approvalRequired,
|
|
inviteLinkPassword,
|
|
revision,
|
|
}: {
|
|
approvalRequired: boolean;
|
|
inviteLinkPassword: string;
|
|
revision: number;
|
|
}): Promise<void> {
|
|
await window.Signal.Groups.joinGroupV2ViaLinkAndMigrate({
|
|
approvalRequired,
|
|
conversation: this,
|
|
inviteLinkPassword,
|
|
revision,
|
|
});
|
|
}
|
|
|
|
async joinGroupV2ViaLink({
|
|
inviteLinkPassword,
|
|
approvalRequired,
|
|
}: {
|
|
inviteLinkPassword: string;
|
|
approvalRequired: boolean;
|
|
}): Promise<void> {
|
|
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
|
|
try {
|
|
if (approvalRequired) {
|
|
await this.modifyGroupV2({
|
|
name: 'requestToJoin',
|
|
inviteLinkPassword,
|
|
createGroupChange: () => this.addPendingApprovalRequest(),
|
|
});
|
|
} else {
|
|
await this.modifyGroupV2({
|
|
name: 'joinGroup',
|
|
inviteLinkPassword,
|
|
createGroupChange: () => this.addMember(ourConversationId),
|
|
});
|
|
}
|
|
} 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 = stringFromBytes(error.response);
|
|
if (errorDetails !== ALREADY_REQUESTED_TO_JOIN) {
|
|
throw error;
|
|
} else {
|
|
window.log.info(
|
|
'joinGroupV2ViaLink: Got 400, but server is telling us we have already requested to join. Forcing that local state'
|
|
);
|
|
this.set({
|
|
pendingAdminApprovalV2: [
|
|
{
|
|
conversationId: ourConversationId,
|
|
timestamp: Date.now(),
|
|
},
|
|
],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const messageRequestEnum =
|
|
window.textsecure.protobuf.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<void> {
|
|
const ourConversationId = window.ConversationController.getOurConversationIdOrThrow();
|
|
|
|
const inviteLinkPassword = this.get('groupInviteLinkPassword');
|
|
if (!inviteLinkPassword) {
|
|
throw new Error('Missing groupInviteLinkPassword!');
|
|
}
|
|
|
|
await this.modifyGroupV2({
|
|
name: 'cancelJoinRequest',
|
|
inviteLinkPassword,
|
|
createGroupChange: () =>
|
|
this.denyPendingApprovalRequest(ourConversationId),
|
|
});
|
|
}
|
|
|
|
async addMembersV2(conversationIds: ReadonlyArray<string>): Promise<void> {
|
|
await this.modifyGroupV2({
|
|
name: 'addMembersV2',
|
|
createGroupChange: () =>
|
|
window.Signal.Groups.buildAddMembersChange(
|
|
{
|
|
id: this.id,
|
|
publicParams: this.get('publicParams'),
|
|
revision: this.get('revision'),
|
|
secretParams: this.get('secretParams'),
|
|
},
|
|
conversationIds
|
|
),
|
|
});
|
|
}
|
|
|
|
async updateGroupAttributesV2(
|
|
attributes: Readonly<{
|
|
avatar?: undefined | ArrayBuffer;
|
|
title?: string;
|
|
}>
|
|
): Promise<void> {
|
|
await this.modifyGroupV2({
|
|
name: 'updateGroupAttributesV2',
|
|
createGroupChange: () =>
|
|
window.Signal.Groups.buildUpdateAttributesChange(
|
|
{
|
|
id: this.id,
|
|
publicParams: this.get('publicParams'),
|
|
revision: this.get('revision'),
|
|
secretParams: this.get('secretParams'),
|
|
},
|
|
attributes
|
|
),
|
|
});
|
|
}
|
|
|
|
async leaveGroupV2(): Promise<void> {
|
|
const ourConversationId = window.ConversationController.getOurConversationId();
|
|
|
|
if (
|
|
ourConversationId &&
|
|
this.isGroupV2() &&
|
|
this.isMemberPending(ourConversationId)
|
|
) {
|
|
await this.modifyGroupV2({
|
|
name: 'delete',
|
|
createGroupChange: () => this.removePendingMember([ourConversationId]),
|
|
});
|
|
} else if (
|
|
ourConversationId &&
|
|
this.isGroupV2() &&
|
|
this.isMember(ourConversationId)
|
|
) {
|
|
await this.modifyGroupV2({
|
|
name: 'delete',
|
|
createGroupChange: () => this.removeMember(ourConversationId),
|
|
});
|
|
} else {
|
|
window.log.error(
|
|
'leaveGroupV2: We were neither a member nor a pending member of the group'
|
|
);
|
|
}
|
|
}
|
|
|
|
async toggleAdmin(conversationId: string): Promise<void> {
|
|
if (!this.isGroupV2()) {
|
|
return;
|
|
}
|
|
|
|
if (!this.isMember(conversationId)) {
|
|
window.log.error(
|
|
`toggleAdmin: Member ${conversationId} is not a member of the group`
|
|
);
|
|
return;
|
|
}
|
|
|
|
await this.modifyGroupV2({
|
|
name: 'toggleAdmin',
|
|
createGroupChange: () => this.toggleAdminChange(conversationId),
|
|
});
|
|
}
|
|
|
|
async approvePendingMembershipFromGroupV2(
|
|
conversationId: string
|
|
): Promise<void> {
|
|
if (this.isGroupV2() && this.isMemberRequestingToJoin(conversationId)) {
|
|
await this.modifyGroupV2({
|
|
name: 'approvePendingApprovalRequest',
|
|
createGroupChange: () =>
|
|
this.approvePendingApprovalRequest(conversationId),
|
|
});
|
|
}
|
|
}
|
|
|
|
async revokePendingMembershipsFromGroupV2(
|
|
conversationIds: Array<string>
|
|
): Promise<void> {
|
|
if (!this.isGroupV2()) {
|
|
return;
|
|
}
|
|
|
|
const [conversationId] = conversationIds;
|
|
|
|
// Only pending memberships can be revoked for multiple members at once
|
|
if (conversationIds.length > 1) {
|
|
await this.modifyGroupV2({
|
|
name: 'removePendingMember',
|
|
createGroupChange: () => this.removePendingMember(conversationIds),
|
|
extraConversationsForSend: conversationIds,
|
|
});
|
|
} else if (this.isMemberRequestingToJoin(conversationId)) {
|
|
await this.modifyGroupV2({
|
|
name: 'denyPendingApprovalRequest',
|
|
createGroupChange: () =>
|
|
this.denyPendingApprovalRequest(conversationId),
|
|
extraConversationsForSend: [conversationId],
|
|
});
|
|
} else if (this.isMemberPending(conversationId)) {
|
|
await this.modifyGroupV2({
|
|
name: 'removePendingMember',
|
|
createGroupChange: () => this.removePendingMember([conversationId]),
|
|
extraConversationsForSend: [conversationId],
|
|
});
|
|
}
|
|
}
|
|
|
|
async removeFromGroupV2(conversationId: string): Promise<void> {
|
|
if (this.isGroupV2() && this.isMemberRequestingToJoin(conversationId)) {
|
|
await this.modifyGroupV2({
|
|
name: 'denyPendingApprovalRequest',
|
|
createGroupChange: () =>
|
|
this.denyPendingApprovalRequest(conversationId),
|
|
extraConversationsForSend: [conversationId],
|
|
});
|
|
} else if (this.isGroupV2() && this.isMemberPending(conversationId)) {
|
|
await this.modifyGroupV2({
|
|
name: 'removePendingMember',
|
|
createGroupChange: () => this.removePendingMember([conversationId]),
|
|
extraConversationsForSend: [conversationId],
|
|
});
|
|
} else if (this.isGroupV2() && this.isMember(conversationId)) {
|
|
await this.modifyGroupV2({
|
|
name: 'removeFromGroup',
|
|
createGroupChange: () => this.removeMember(conversationId),
|
|
extraConversationsForSend: [conversationId],
|
|
});
|
|
} else {
|
|
window.log.error(
|
|
`removeFromGroupV2: Member ${conversationId} is neither a member nor a pending member of the group`
|
|
);
|
|
}
|
|
}
|
|
|
|
async syncMessageRequestResponse(response: number): Promise<void> {
|
|
// In GroupsV2, this may modify the server. We only want to continue if those
|
|
// server updates were successful.
|
|
await this.applyMessageRequestResponse(response);
|
|
|
|
const { ourNumber, ourUuid } = this;
|
|
const {
|
|
wrap,
|
|
sendOptions,
|
|
} = await window.ConversationController.prepareForSend(
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
ourNumber || ourUuid!,
|
|
{
|
|
syncMessage: true,
|
|
}
|
|
);
|
|
|
|
const groupId = this.getGroupIdBuffer();
|
|
|
|
try {
|
|
await wrap(
|
|
window.textsecure.messaging.syncMessageRequestResponse(
|
|
{
|
|
threadE164: this.get('e164'),
|
|
threadUuid: this.get('uuid'),
|
|
groupId,
|
|
type: response,
|
|
},
|
|
sendOptions
|
|
)
|
|
);
|
|
} catch (result) {
|
|
this.processSendResponse(result);
|
|
}
|
|
}
|
|
|
|
// We only want to throw if there's a 'real' error contained with this information
|
|
// coming back from our low-level send infrastructure.
|
|
processSendResponse(
|
|
result: Error | CallbackResultType
|
|
): result is CallbackResultType {
|
|
if (result instanceof Error) {
|
|
throw result;
|
|
} else if (result && result.errors) {
|
|
// We filter out unregistered user errors, because we ignore those in groups
|
|
const wasThereARealError = window._.some(
|
|
result.errors,
|
|
error => error.name !== 'UnregisteredUserError'
|
|
);
|
|
if (wasThereARealError) {
|
|
throw result;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
onMessageError(): void {
|
|
this.updateVerified();
|
|
}
|
|
|
|
async safeGetVerified(): Promise<number> {
|
|
const promise = window.textsecure.storage.protocol.getVerified(this.id);
|
|
return promise.catch(
|
|
() => window.textsecure.storage.protocol.VerifiedStatus.DEFAULT
|
|
);
|
|
}
|
|
|
|
async updateVerified(): Promise<void> {
|
|
if (this.isPrivate()) {
|
|
await this.initialPromise;
|
|
const verified = await this.safeGetVerified();
|
|
|
|
if (this.get('verified') !== verified) {
|
|
this.set({ verified });
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
this.fetchContacts();
|
|
|
|
await Promise.all(
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.contactCollection!.map(async contact => {
|
|
if (!contact.isMe()) {
|
|
await contact.updateVerified();
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
setVerifiedDefault(options?: VerificationOptions): Promise<unknown> {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const { DEFAULT } = this.verifiedEnum!;
|
|
return this.queueJob(() => this._setVerified(DEFAULT, options));
|
|
}
|
|
|
|
setVerified(options?: VerificationOptions): Promise<unknown> {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const { VERIFIED } = this.verifiedEnum!;
|
|
return this.queueJob(() => this._setVerified(VERIFIED, options));
|
|
}
|
|
|
|
setUnverified(options: VerificationOptions): Promise<unknown> {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const { UNVERIFIED } = this.verifiedEnum!;
|
|
return this.queueJob(() => this._setVerified(UNVERIFIED, options));
|
|
}
|
|
|
|
async _setVerified(
|
|
verified: number,
|
|
providedOptions?: VerificationOptions
|
|
): Promise<boolean | void> {
|
|
const options = providedOptions || {};
|
|
window._.defaults(options, {
|
|
viaStorageServiceSync: false,
|
|
viaSyncMessage: false,
|
|
viaContactSync: false,
|
|
key: null,
|
|
});
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const { VERIFIED, UNVERIFIED } = this.verifiedEnum!;
|
|
|
|
if (!this.isPrivate()) {
|
|
throw new Error(
|
|
'You cannot verify a group conversation. ' +
|
|
'You must verify individual contacts.'
|
|
);
|
|
}
|
|
|
|
const beginningVerified = this.get('verified');
|
|
let keyChange;
|
|
if (options.viaSyncMessage) {
|
|
// handle the incoming key from the sync messages - need different
|
|
// behavior if that key doesn't match the current key
|
|
keyChange = await window.textsecure.storage.protocol.processVerifiedMessage(
|
|
this.id,
|
|
verified,
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
options.key!
|
|
);
|
|
} else {
|
|
keyChange = await window.textsecure.storage.protocol.setVerified(
|
|
this.id,
|
|
verified
|
|
);
|
|
}
|
|
|
|
this.set({ verified });
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
|
|
if (
|
|
!options.viaStorageServiceSync &&
|
|
!keyChange &&
|
|
beginningVerified !== verified
|
|
) {
|
|
this.captureChange('verified');
|
|
}
|
|
|
|
// Three situations result in a verification notice in the conversation:
|
|
// 1) The message came from an explicit verification in another client (not
|
|
// a contact sync)
|
|
// 2) The verification value received by the contact sync is different
|
|
// from what we have on record (and it's not a transition to UNVERIFIED)
|
|
// 3) Our local verification status is VERIFIED and it hasn't changed,
|
|
// but the key did change (Key1/VERIFIED to Key2/VERIFIED - but we don't
|
|
// want to show DEFAULT->DEFAULT or UNVERIFIED->UNVERIFIED)
|
|
if (
|
|
!options.viaContactSync ||
|
|
(beginningVerified !== verified && verified !== UNVERIFIED) ||
|
|
(keyChange && verified === VERIFIED)
|
|
) {
|
|
await this.addVerifiedChange(this.id, verified === VERIFIED, {
|
|
local: !options.viaSyncMessage,
|
|
});
|
|
}
|
|
if (!options.viaSyncMessage) {
|
|
await this.sendVerifySyncMessage(
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.get('e164')!,
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.get('uuid')!,
|
|
verified
|
|
);
|
|
}
|
|
|
|
return keyChange;
|
|
}
|
|
|
|
async sendVerifySyncMessage(
|
|
e164: string,
|
|
uuid: string,
|
|
state: number
|
|
): Promise<WhatIsThis> {
|
|
// Because syncVerification sends a (null) message to the target of the verify and
|
|
// a sync message to our own devices, we need to send the accessKeys down for both
|
|
// contacts. So we merge their sendOptions.
|
|
const { sendOptions } = await window.ConversationController.prepareForSend(
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.ourNumber || this.ourUuid!,
|
|
{ syncMessage: true }
|
|
);
|
|
const contactSendOptions = await this.getSendOptions();
|
|
const options = { ...sendOptions, ...contactSendOptions };
|
|
|
|
const promise = window.textsecure.storage.protocol.loadIdentityKey(e164);
|
|
return promise.then(key =>
|
|
this.wrapSend(
|
|
window.textsecure.messaging.syncVerification(
|
|
e164,
|
|
uuid,
|
|
state,
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
key!,
|
|
options
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
isVerified(): boolean {
|
|
if (this.isPrivate()) {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
return this.get('verified') === this.verifiedEnum!.VERIFIED;
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
if (!this.contactCollection!.length) {
|
|
return false;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
return this.contactCollection!.every(contact => {
|
|
if (contact.isMe()) {
|
|
return true;
|
|
}
|
|
return contact.isVerified();
|
|
});
|
|
}
|
|
|
|
isUnverified(): boolean {
|
|
if (this.isPrivate()) {
|
|
const verified = this.get('verified');
|
|
return (
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
verified !== this.verifiedEnum!.VERIFIED &&
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
verified !== this.verifiedEnum!.DEFAULT
|
|
);
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
if (!this.contactCollection!.length) {
|
|
return true;
|
|
}
|
|
|
|
// Array.any does not exist. This is probably broken.
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
return this.contactCollection!.any(contact => {
|
|
if (contact.isMe()) {
|
|
return false;
|
|
}
|
|
return contact.isUnverified();
|
|
});
|
|
}
|
|
|
|
getUnverified(): Backbone.Collection<ConversationModel> {
|
|
if (this.isPrivate()) {
|
|
return this.isUnverified()
|
|
? new window.Backbone.Collection([this])
|
|
: new window.Backbone.Collection();
|
|
}
|
|
return new window.Backbone.Collection(
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.contactCollection!.filter(contact => {
|
|
if (contact.isMe()) {
|
|
return false;
|
|
}
|
|
return contact.isUnverified();
|
|
})
|
|
);
|
|
}
|
|
|
|
setApproved(): boolean | void {
|
|
if (!this.isPrivate()) {
|
|
throw new Error(
|
|
'You cannot set a group conversation as trusted. ' +
|
|
'You must set individual contacts as trusted.'
|
|
);
|
|
}
|
|
|
|
return window.textsecure.storage.protocol.setApproval(this.id, true);
|
|
}
|
|
|
|
safeIsUntrusted(): boolean {
|
|
try {
|
|
return window.textsecure.storage.protocol.isUntrusted(this.id);
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
isUntrusted(): boolean {
|
|
if (this.isPrivate()) {
|
|
return this.safeIsUntrusted();
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
if (!this.contactCollection!.length) {
|
|
return false;
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
return this.contactCollection!.any(contact => {
|
|
if (contact.isMe()) {
|
|
return false;
|
|
}
|
|
return contact.safeIsUntrusted();
|
|
});
|
|
}
|
|
|
|
getUntrusted(): Backbone.Collection {
|
|
if (this.isPrivate()) {
|
|
if (this.isUntrusted()) {
|
|
return new window.Backbone.Collection([this]);
|
|
}
|
|
return new window.Backbone.Collection();
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const results = this.contactCollection!.map(contact => {
|
|
if (contact.isMe()) {
|
|
return [false, contact];
|
|
}
|
|
return [contact.isUntrusted(), contact];
|
|
});
|
|
|
|
return new window.Backbone.Collection(
|
|
results.filter(result => result[0]).map(result => result[1])
|
|
);
|
|
}
|
|
|
|
getSentMessageCount(): number {
|
|
return this.get('sentMessageCount') || 0;
|
|
}
|
|
|
|
getMessageRequestResponseType(): number {
|
|
return this.get('messageRequestResponseType') || 0;
|
|
}
|
|
|
|
isMissingRequiredProfileSharing(): boolean {
|
|
const mandatoryProfileSharingEnabled = window.Signal.RemoteConfig.isEnabled(
|
|
'desktop.mandatoryProfileSharing'
|
|
);
|
|
|
|
if (!mandatoryProfileSharingEnabled) {
|
|
return false;
|
|
}
|
|
|
|
const hasNoMessages = (this.get('messageCount') || 0) === 0;
|
|
if (hasNoMessages) {
|
|
return false;
|
|
}
|
|
|
|
if (!this.isGroupV1() && !this.isPrivate()) {
|
|
return false;
|
|
}
|
|
|
|
return !this.get('profileSharing');
|
|
}
|
|
|
|
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 {
|
|
const messageRequestsEnabled = window.Signal.RemoteConfig.isEnabled(
|
|
'desktop.messageRequests'
|
|
);
|
|
|
|
if (!messageRequestsEnabled) {
|
|
return true;
|
|
}
|
|
|
|
if (this.isMe()) {
|
|
return true;
|
|
}
|
|
|
|
const messageRequestEnum =
|
|
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
|
|
if (this.getMessageRequestResponseType() === messageRequestEnum.ACCEPT) {
|
|
return true;
|
|
}
|
|
|
|
const isFromOrAddedByTrustedContact = this.isFromOrAddedByTrustedContact();
|
|
const hasSentMessages = this.getSentMessageCount() > 0;
|
|
const hasMessagesBeforeMessageRequests =
|
|
(this.get('messageCountBeforeMessageRequests') || 0) > 0;
|
|
const hasNoMessages = (this.get('messageCount') || 0) === 0;
|
|
|
|
const isEmptyPrivateConvo = hasNoMessages && this.isPrivate();
|
|
const isEmptyWhitelistedGroup =
|
|
hasNoMessages && !this.isPrivate() && this.get('profileSharing');
|
|
|
|
return (
|
|
isFromOrAddedByTrustedContact ||
|
|
hasSentMessages ||
|
|
hasMessagesBeforeMessageRequests ||
|
|
// an empty group is the scenario where we need to rely on
|
|
// whether the profile has already been shared or not
|
|
isEmptyPrivateConvo ||
|
|
isEmptyWhitelistedGroup
|
|
);
|
|
}
|
|
|
|
onMemberVerifiedChange(): void {
|
|
// If the verified state of a member changes, our aggregate state changes.
|
|
// We trigger both events to replicate the behavior of window.Backbone.Model.set()
|
|
this.trigger('change:verified', this);
|
|
this.trigger('change', this);
|
|
}
|
|
|
|
async toggleVerified(): Promise<unknown> {
|
|
if (this.isVerified()) {
|
|
return this.setVerifiedDefault();
|
|
}
|
|
return this.setVerified();
|
|
}
|
|
|
|
async addChatSessionRefreshed(receivedAt: number): Promise<void> {
|
|
window.log.info(
|
|
`addChatSessionRefreshed: adding for ${this.idForLogging()}`
|
|
);
|
|
|
|
const message = ({
|
|
conversationId: this.id,
|
|
type: 'chat-session-refreshed',
|
|
sent_at: receivedAt,
|
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
|
received_at_ms: receivedAt,
|
|
unread: 1,
|
|
// TODO: DESKTOP-722
|
|
// this type does not fully implement the interface it is expected to
|
|
} as unknown) as typeof window.Whisper.MessageAttributesType;
|
|
|
|
const id = await window.Signal.Data.saveMessage(message, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
const model = window.MessageController.register(
|
|
id,
|
|
new window.Whisper.Message({
|
|
...message,
|
|
id,
|
|
})
|
|
);
|
|
|
|
this.trigger('newmessage', model);
|
|
}
|
|
|
|
async addKeyChange(keyChangedId: string): Promise<void> {
|
|
window.log.info(
|
|
'adding key change advisory for',
|
|
this.idForLogging(),
|
|
keyChangedId,
|
|
this.get('timestamp')
|
|
);
|
|
|
|
const timestamp = Date.now();
|
|
const message = ({
|
|
conversationId: this.id,
|
|
type: 'keychange',
|
|
sent_at: this.get('timestamp'),
|
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
|
received_at_ms: timestamp,
|
|
key_changed: keyChangedId,
|
|
unread: 1,
|
|
schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY,
|
|
// TODO: DESKTOP-722
|
|
// this type does not fully implement the interface it is expected to
|
|
} as unknown) as typeof window.Whisper.MessageAttributesType;
|
|
|
|
const id = await window.Signal.Data.saveMessage(message, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
const model = window.MessageController.register(
|
|
id,
|
|
new window.Whisper.Message({
|
|
...message,
|
|
id,
|
|
})
|
|
);
|
|
|
|
const isUntrusted = await this.isUntrusted();
|
|
|
|
this.trigger('newmessage', model);
|
|
|
|
const uuid = this.get('uuid');
|
|
// Group calls are always with folks that have a UUID
|
|
if (isUntrusted && uuid) {
|
|
window.reduxActions.calling.keyChanged({ uuid });
|
|
}
|
|
}
|
|
|
|
async addVerifiedChange(
|
|
verifiedChangeId: string,
|
|
verified: boolean,
|
|
providedOptions: Record<string, unknown>
|
|
): Promise<void> {
|
|
const options = providedOptions || {};
|
|
window._.defaults(options, { local: true });
|
|
|
|
if (this.isMe()) {
|
|
window.log.info(
|
|
'refusing to add verified change advisory for our own number'
|
|
);
|
|
return;
|
|
}
|
|
|
|
const lastMessage = this.get('timestamp') || Date.now();
|
|
|
|
window.log.info(
|
|
'adding verified change advisory for',
|
|
this.idForLogging(),
|
|
verifiedChangeId,
|
|
lastMessage
|
|
);
|
|
|
|
const timestamp = Date.now();
|
|
const message = ({
|
|
conversationId: this.id,
|
|
type: 'verified-change',
|
|
sent_at: lastMessage,
|
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
|
received_at_ms: timestamp,
|
|
verifiedChanged: verifiedChangeId,
|
|
verified,
|
|
local: options.local,
|
|
unread: 1,
|
|
// TODO: DESKTOP-722
|
|
} as unknown) as typeof window.Whisper.MessageAttributesType;
|
|
|
|
const id = await window.Signal.Data.saveMessage(message, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
const model = window.MessageController.register(
|
|
id,
|
|
new window.Whisper.Message({
|
|
...message,
|
|
id,
|
|
})
|
|
);
|
|
|
|
this.trigger('newmessage', model);
|
|
|
|
if (this.isPrivate()) {
|
|
window.ConversationController.getAllGroupsInvolvingId(this.id).then(
|
|
groups => {
|
|
window._.forEach(groups, group => {
|
|
group.addVerifiedChange(this.id, verified, options);
|
|
});
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
async addCallHistory(
|
|
callHistoryDetails: CallHistoryDetailsType
|
|
): Promise<void> {
|
|
let timestamp: number;
|
|
let unread: boolean;
|
|
let detailsToSave: CallHistoryDetailsType;
|
|
|
|
switch (callHistoryDetails.callMode) {
|
|
case CallMode.Direct:
|
|
timestamp = callHistoryDetails.endedTime;
|
|
unread =
|
|
!callHistoryDetails.wasDeclined && !callHistoryDetails.acceptedTime;
|
|
detailsToSave = {
|
|
...callHistoryDetails,
|
|
callMode: CallMode.Direct,
|
|
};
|
|
break;
|
|
case CallMode.Group:
|
|
timestamp = callHistoryDetails.startedTime;
|
|
unread = false;
|
|
detailsToSave = callHistoryDetails;
|
|
break;
|
|
default:
|
|
throw missingCaseError(callHistoryDetails);
|
|
}
|
|
|
|
const message = ({
|
|
conversationId: this.id,
|
|
type: 'call-history',
|
|
sent_at: timestamp,
|
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
|
received_at_ms: timestamp,
|
|
unread,
|
|
callHistoryDetails: detailsToSave,
|
|
// TODO: DESKTOP-722
|
|
} as unknown) as typeof window.Whisper.MessageAttributesType;
|
|
|
|
const id = await window.Signal.Data.saveMessage(message, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
const model = window.MessageController.register(
|
|
id,
|
|
new window.Whisper.Message({
|
|
...message,
|
|
id,
|
|
})
|
|
);
|
|
|
|
this.trigger('newmessage', model);
|
|
}
|
|
|
|
async updateCallHistoryForGroupCall(
|
|
eraId: string,
|
|
creatorUuid: string
|
|
): Promise<void> {
|
|
// We want to update the cache quickly in case this function is called multiple times.
|
|
const oldCachedEraId = this.cachedLatestGroupCallEraId;
|
|
this.cachedLatestGroupCallEraId = eraId;
|
|
|
|
const alreadyHasMessage =
|
|
(oldCachedEraId && oldCachedEraId === eraId) ||
|
|
(await window.Signal.Data.hasGroupCallHistoryMessage(this.id, eraId));
|
|
|
|
if (!alreadyHasMessage) {
|
|
this.addCallHistory({
|
|
callMode: CallMode.Group,
|
|
creatorUuid,
|
|
eraId,
|
|
startedTime: Date.now(),
|
|
});
|
|
}
|
|
}
|
|
|
|
async addProfileChange(
|
|
profileChange: unknown,
|
|
conversationId?: string
|
|
): Promise<void> {
|
|
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,
|
|
unread: 1,
|
|
changedId: conversationId || this.id,
|
|
profileChange,
|
|
// TODO: DESKTOP-722
|
|
} as unknown) as typeof window.Whisper.MessageAttributesType;
|
|
|
|
const id = await window.Signal.Data.saveMessage(message, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
const model = window.MessageController.register(
|
|
id,
|
|
new window.Whisper.Message({
|
|
...message,
|
|
id,
|
|
})
|
|
);
|
|
|
|
this.trigger('newmessage', model);
|
|
|
|
if (this.isPrivate()) {
|
|
window.ConversationController.getAllGroupsInvolvingId(this.id).then(
|
|
groups => {
|
|
window._.forEach(groups, group => {
|
|
group.addProfileChange(profileChange, this.id);
|
|
});
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
async onReadMessage(
|
|
message: MessageModel,
|
|
readAt?: number
|
|
): Promise<WhatIsThis> {
|
|
// We mark as read everything older than this message - to clean up old stuff
|
|
// still marked unread in the database. If the user generally doesn't read in
|
|
// the desktop app, so the desktop app only gets read syncs, we can very
|
|
// easily end up with messages never marked as read (our previous early read
|
|
// sync handling, read syncs never sent because app was offline)
|
|
|
|
// We queue it because we often get a whole lot of read syncs at once, and
|
|
// their markRead calls could very easily overlap given the async pull from DB.
|
|
|
|
// Lastly, we don't send read syncs for any message marked read due to a read
|
|
// sync. That's a notification explosion we don't need.
|
|
return this.queueJob(() =>
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.markRead(message.get('received_at')!, {
|
|
sendReadReceipts: false,
|
|
readAt,
|
|
})
|
|
);
|
|
}
|
|
|
|
getUnread(): Promise<MessageModelCollectionType> {
|
|
return window.Signal.Data.getUnreadByConversation(this.id, {
|
|
MessageCollection: window.Whisper.MessageCollection,
|
|
});
|
|
}
|
|
|
|
validate(attributes = this.attributes): string | null {
|
|
const required = ['type'];
|
|
const missing = window._.filter(required, attr => !attributes[attr]);
|
|
if (missing.length) {
|
|
return `Conversation must have ${missing}`;
|
|
}
|
|
|
|
if (attributes.type !== 'private' && attributes.type !== 'group') {
|
|
return `Invalid conversation type: ${attributes.type}`;
|
|
}
|
|
|
|
const atLeastOneOf = ['e164', 'uuid', 'groupId'];
|
|
const hasAtLeastOneOf =
|
|
window._.filter(atLeastOneOf, attr => attributes[attr]).length > 0;
|
|
|
|
if (!hasAtLeastOneOf) {
|
|
return 'Missing one of e164, uuid, or groupId';
|
|
}
|
|
|
|
const error = this.validateNumber() || this.validateUuid();
|
|
|
|
if (error) {
|
|
return error;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
validateNumber(): string | null {
|
|
if (this.isPrivate() && this.get('e164')) {
|
|
const regionCode = window.storage.get('regionCode');
|
|
const number = window.libphonenumber.util.parseNumber(
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.get('e164')!,
|
|
regionCode
|
|
);
|
|
// TODO: DESKTOP-723
|
|
// This is valid, but the typing thinks it's a function.
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
if (number.isValidNumber) {
|
|
this.set({ e164: number.e164 });
|
|
return null;
|
|
}
|
|
|
|
return number.error || 'Invalid phone number';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
validateUuid(): string | null {
|
|
if (this.isPrivate() && this.get('uuid')) {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
if (window.isValidGuid(this.get('uuid')!)) {
|
|
return null;
|
|
}
|
|
|
|
return 'Invalid UUID';
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
queueJob(callback: () => unknown | Promise<unknown>): Promise<WhatIsThis> {
|
|
this.jobQueue = this.jobQueue || new window.PQueue({ concurrency: 1 });
|
|
|
|
const taskWithTimeout = window.textsecure.createTaskWithTimeout(
|
|
callback,
|
|
`conversation ${this.idForLogging()}`
|
|
);
|
|
|
|
return this.jobQueue.add(taskWithTimeout);
|
|
}
|
|
|
|
isAdmin(conversationId: string): boolean {
|
|
if (!this.isGroupV2()) {
|
|
return false;
|
|
}
|
|
|
|
const members = this.get('membersV2') || [];
|
|
const member = members.find(x => x.conversationId === conversationId);
|
|
if (!member) {
|
|
return false;
|
|
}
|
|
|
|
const MEMBER_ROLES = window.textsecure.protobuf.Member.Role;
|
|
|
|
return member.role === MEMBER_ROLES.ADMINISTRATOR;
|
|
}
|
|
|
|
getMemberships(): Array<GroupV2Membership> {
|
|
if (!this.isGroupV2()) {
|
|
return [];
|
|
}
|
|
|
|
const members = this.get('membersV2') || [];
|
|
return members
|
|
.map(member => {
|
|
const conversationModel = window.ConversationController.get(
|
|
member.conversationId
|
|
);
|
|
if (!conversationModel || conversationModel.isUnregistered()) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
isAdmin:
|
|
member.role ===
|
|
window.textsecure.protobuf.Member.Role.ADMINISTRATOR,
|
|
metadata: member,
|
|
member: conversationModel.format(),
|
|
};
|
|
})
|
|
.filter(
|
|
(membership): membership is GroupV2Membership => membership !== null
|
|
);
|
|
}
|
|
|
|
getGroupLink(): string | undefined {
|
|
if (!this.isGroupV2()) {
|
|
return undefined;
|
|
}
|
|
|
|
if (!this.get('groupInviteLinkPassword')) {
|
|
return undefined;
|
|
}
|
|
|
|
return window.Signal.Groups.buildGroupLink(this);
|
|
}
|
|
|
|
getPendingMemberships(): Array<GroupV2PendingMembership> {
|
|
if (!this.isGroupV2()) {
|
|
return [];
|
|
}
|
|
|
|
const members = this.get('pendingMembersV2') || [];
|
|
return members
|
|
.map(member => {
|
|
const conversationModel = window.ConversationController.get(
|
|
member.conversationId
|
|
);
|
|
if (!conversationModel || conversationModel.isUnregistered()) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
metadata: member,
|
|
member: conversationModel.format(),
|
|
};
|
|
})
|
|
.filter(
|
|
(membership): membership is GroupV2PendingMembership =>
|
|
membership !== null
|
|
);
|
|
}
|
|
|
|
getPendingApprovalMemberships(): Array<GroupV2RequestingMembership> {
|
|
if (!this.isGroupV2()) {
|
|
return [];
|
|
}
|
|
|
|
const members = this.get('pendingAdminApprovalV2') || [];
|
|
return members
|
|
.map(member => {
|
|
const conversationModel = window.ConversationController.get(
|
|
member.conversationId
|
|
);
|
|
if (!conversationModel || conversationModel.isUnregistered()) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
metadata: member,
|
|
member: conversationModel.format(),
|
|
};
|
|
})
|
|
.filter(
|
|
(membership): membership is GroupV2RequestingMembership =>
|
|
membership !== null
|
|
);
|
|
}
|
|
|
|
getMembers(
|
|
options: { includePendingMembers?: boolean } = {}
|
|
): Array<ConversationModel> {
|
|
if (this.isPrivate()) {
|
|
return [this];
|
|
}
|
|
|
|
if (this.get('membersV2')) {
|
|
const { includePendingMembers } = options;
|
|
const members: Array<{ conversationId: string }> = includePendingMembers
|
|
? [
|
|
...(this.get('membersV2') || []),
|
|
...(this.get('pendingMembersV2') || []),
|
|
]
|
|
: this.get('membersV2') || [];
|
|
|
|
return window._.compact(
|
|
members.map(member => {
|
|
const c = window.ConversationController.get(member.conversationId);
|
|
|
|
// In groups we won't sent to contacts we believe are unregistered
|
|
if (c && c.isUnregistered()) {
|
|
return null;
|
|
}
|
|
|
|
return c;
|
|
})
|
|
);
|
|
}
|
|
|
|
if (this.get('members')) {
|
|
return window._.compact(
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.get('members')!.map(id => {
|
|
const c = window.ConversationController.get(id);
|
|
|
|
// In groups we won't send to contacts we believe are unregistered
|
|
if (c && c.isUnregistered()) {
|
|
return null;
|
|
}
|
|
|
|
return c;
|
|
})
|
|
);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
getMemberIds(): Array<string> {
|
|
const members = this.getMembers();
|
|
return members.map(member => member.id);
|
|
}
|
|
|
|
getRecipients({
|
|
includePendingMembers,
|
|
extraConversationsForSend,
|
|
}: {
|
|
includePendingMembers?: boolean;
|
|
extraConversationsForSend?: Array<string>;
|
|
} = {}): Array<string> {
|
|
const members = this.getMembers({ includePendingMembers });
|
|
|
|
// There are cases where we need to send to someone we just removed from the group, to
|
|
// let them know that we removed them. In that case, we need to send to more than
|
|
// are currently in the group.
|
|
const extraConversations = extraConversationsForSend
|
|
? extraConversationsForSend
|
|
.map(id => window.ConversationController.get(id))
|
|
.filter(isNotNil)
|
|
: [];
|
|
|
|
const unique = extraConversations.length
|
|
? window._.unique([...members, ...extraConversations])
|
|
: members;
|
|
|
|
// Eliminate ourself
|
|
return window._.compact(
|
|
unique.map(member => (member.isMe() ? null : member.getSendTarget()))
|
|
);
|
|
}
|
|
|
|
async getQuoteAttachment(
|
|
attachments: Array<WhatIsThis>,
|
|
preview: Array<WhatIsThis>,
|
|
sticker: WhatIsThis
|
|
): Promise<WhatIsThis> {
|
|
if (attachments && attachments.length) {
|
|
return Promise.all(
|
|
attachments
|
|
.filter(
|
|
attachment => attachment && !attachment.pending && !attachment.error
|
|
)
|
|
.slice(0, 1)
|
|
.map(async attachment => {
|
|
const { fileName, thumbnail, contentType } = attachment;
|
|
|
|
return {
|
|
contentType,
|
|
// Our protos library complains about this field being undefined, so we
|
|
// force it to null
|
|
fileName: fileName || null,
|
|
thumbnail: thumbnail
|
|
? {
|
|
...(await loadAttachmentData(thumbnail)),
|
|
objectUrl: getAbsoluteAttachmentPath(thumbnail.path),
|
|
}
|
|
: null,
|
|
};
|
|
})
|
|
);
|
|
}
|
|
|
|
if (preview && preview.length) {
|
|
return Promise.all(
|
|
preview
|
|
.filter(item => item && item.image)
|
|
.slice(0, 1)
|
|
.map(async attachment => {
|
|
const { image } = attachment;
|
|
const { contentType } = image;
|
|
|
|
return {
|
|
contentType,
|
|
// Our protos library complains about this field being undefined, so we
|
|
// force it to null
|
|
fileName: null,
|
|
thumbnail: image
|
|
? {
|
|
...(await loadAttachmentData(image)),
|
|
objectUrl: getAbsoluteAttachmentPath(image.path),
|
|
}
|
|
: null,
|
|
};
|
|
})
|
|
);
|
|
}
|
|
|
|
if (sticker && sticker.data && sticker.data.path) {
|
|
const { path, contentType } = sticker.data;
|
|
|
|
return [
|
|
{
|
|
contentType,
|
|
// Our protos library complains about this field being undefined, so we
|
|
// force it to null
|
|
fileName: null,
|
|
thumbnail: {
|
|
...(await loadAttachmentData(sticker.data)),
|
|
objectUrl: getAbsoluteAttachmentPath(path),
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
async makeQuote(
|
|
quotedMessage: typeof window.Whisper.MessageType
|
|
): Promise<WhatIsThis> {
|
|
const { getName } = Contact;
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const contact = quotedMessage.getContact()!;
|
|
const attachments = quotedMessage.get('attachments');
|
|
const preview = quotedMessage.get('preview');
|
|
const sticker = quotedMessage.get('sticker');
|
|
|
|
const body = quotedMessage.get('body');
|
|
const embeddedContact = quotedMessage.get('contact');
|
|
const embeddedContactName =
|
|
embeddedContact && embeddedContact.length > 0
|
|
? getName(embeddedContact[0])
|
|
: '';
|
|
|
|
return {
|
|
authorUuid: contact.get('uuid'),
|
|
bodyRanges: quotedMessage.get('bodyRanges'),
|
|
id: quotedMessage.get('sent_at'),
|
|
text: body || embeddedContactName,
|
|
attachments: quotedMessage.isTapToView()
|
|
? [{ contentType: 'image/jpeg', fileName: null }]
|
|
: await this.getQuoteAttachment(attachments, preview, sticker),
|
|
};
|
|
}
|
|
|
|
async sendStickerMessage(packId: string, stickerId: number): Promise<void> {
|
|
const packData = window.Signal.Stickers.getStickerPack(packId);
|
|
const stickerData = window.Signal.Stickers.getSticker(packId, stickerId);
|
|
if (!stickerData || !packData) {
|
|
window.log.warn(
|
|
`Attempted to send nonexistent (${packId}, ${stickerId}) sticker!`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const { key } = packData;
|
|
const { path, width, height } = stickerData;
|
|
const arrayBuffer = await readStickerData(path);
|
|
|
|
// We need this content type to be an image so we can display an `<img>` instead of a
|
|
// `<video>` or an error, but it's not critical that we get the full type correct.
|
|
// In other words, it's probably fine if we say that a GIF is `image/png`, but it's
|
|
// but it's bad if we say it's `video/mp4` or `text/plain`. We do our best to sniff
|
|
// the MIME type here, but it's okay if we have to use a possibly-incorrect
|
|
// fallback.
|
|
let contentType: MIMEType;
|
|
const sniffedMimeType = sniffImageMimeType(arrayBuffer);
|
|
if (sniffedMimeType) {
|
|
contentType = sniffedMimeType;
|
|
} else {
|
|
window.log.warn(
|
|
'Unable to sniff sticker MIME type; falling back to WebP'
|
|
);
|
|
contentType = IMAGE_WEBP;
|
|
}
|
|
|
|
const sticker = {
|
|
packId,
|
|
stickerId,
|
|
packKey: key,
|
|
data: {
|
|
size: arrayBuffer.byteLength,
|
|
data: arrayBuffer,
|
|
contentType,
|
|
width,
|
|
height,
|
|
},
|
|
};
|
|
|
|
this.sendMessage(null, [], null, [], sticker);
|
|
window.reduxActions.stickers.useSticker(packId, stickerId);
|
|
}
|
|
|
|
async sendDeleteForEveryoneMessage(targetTimestamp: number): Promise<void> {
|
|
const timestamp = Date.now();
|
|
|
|
if (timestamp - targetTimestamp > THREE_HOURS) {
|
|
throw new Error('Cannot send DOE for a message older than three hours');
|
|
}
|
|
|
|
const deleteModel = window.Whisper.Deletes.add({
|
|
targetSentTimestamp: targetTimestamp,
|
|
fromId: window.ConversationController.getOurConversationId(),
|
|
});
|
|
|
|
window.Whisper.Deletes.onDelete(deleteModel);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const destination = this.getSendTarget()!;
|
|
const recipients = this.getRecipients();
|
|
|
|
let profileKey: ArrayBuffer | undefined;
|
|
if (this.get('profileSharing')) {
|
|
profileKey = window.storage.get('profileKey');
|
|
}
|
|
|
|
return this.queueJob(async () => {
|
|
window.log.info(
|
|
'Sending deleteForEveryone to conversation',
|
|
this.idForLogging(),
|
|
'with timestamp',
|
|
timestamp
|
|
);
|
|
|
|
const attributes = ({
|
|
id: window.getGuid(),
|
|
type: 'outgoing',
|
|
conversationId: this.get('id'),
|
|
sent_at: timestamp,
|
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
|
received_at_ms: timestamp,
|
|
recipients,
|
|
deletedForEveryoneTimestamp: targetTimestamp,
|
|
// TODO: DESKTOP-722
|
|
} as unknown) as typeof window.Whisper.MessageAttributesType;
|
|
|
|
if (this.isPrivate()) {
|
|
attributes.destination = destination;
|
|
}
|
|
|
|
// We are only creating this model so we can use its sync message
|
|
// sending functionality. It will not be saved to the database.
|
|
const message = new window.Whisper.Message(attributes);
|
|
|
|
// We're offline!
|
|
if (!window.textsecure.messaging) {
|
|
throw new Error('Cannot send DOE while offline!');
|
|
}
|
|
|
|
const options = await this.getSendOptions();
|
|
|
|
const promise = (() => {
|
|
if (this.isPrivate()) {
|
|
return window.textsecure.messaging.sendMessageToIdentifier(
|
|
destination,
|
|
undefined, // body
|
|
[], // attachments
|
|
undefined, // quote
|
|
[], // preview
|
|
undefined, // sticker
|
|
undefined, // reaction
|
|
targetTimestamp,
|
|
timestamp,
|
|
undefined, // expireTimer
|
|
profileKey,
|
|
options
|
|
);
|
|
}
|
|
|
|
return window.textsecure.messaging.sendMessageToGroup(
|
|
{
|
|
groupV1: this.getGroupV1Info(),
|
|
groupV2: this.getGroupV2Info(),
|
|
deletedForEveryoneTimestamp: targetTimestamp,
|
|
timestamp,
|
|
profileKey,
|
|
},
|
|
options
|
|
);
|
|
})();
|
|
|
|
// This is to ensure that the functions in send() and sendSyncMessage() don't save
|
|
// anything to the database.
|
|
message.doNotSave = true;
|
|
|
|
return message.send(this.wrapSend(promise));
|
|
}).catch(error => {
|
|
window.log.error(
|
|
'Error sending deleteForEveryone',
|
|
deleteModel,
|
|
targetTimestamp,
|
|
error
|
|
);
|
|
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
async sendReactionMessage(
|
|
reaction: { emoji: string; remove: boolean },
|
|
target: {
|
|
targetAuthorUuid: string;
|
|
targetTimestamp: number;
|
|
}
|
|
): Promise<WhatIsThis> {
|
|
const timestamp = Date.now();
|
|
const outgoingReaction = { ...reaction, ...target };
|
|
const expireTimer = this.get('expireTimer');
|
|
|
|
const reactionModel = window.Whisper.Reactions.add({
|
|
...outgoingReaction,
|
|
fromId: window.ConversationController.getOurConversationId(),
|
|
timestamp,
|
|
fromSync: true,
|
|
});
|
|
window.Whisper.Reactions.onReaction(reactionModel);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const destination = this.getSendTarget()!;
|
|
const recipients = this.getRecipients();
|
|
|
|
let profileKey: ArrayBuffer | undefined;
|
|
if (this.get('profileSharing')) {
|
|
profileKey = window.storage.get('profileKey');
|
|
}
|
|
|
|
return this.queueJob(async () => {
|
|
window.log.info(
|
|
'Sending reaction to conversation',
|
|
this.idForLogging(),
|
|
'with timestamp',
|
|
timestamp
|
|
);
|
|
|
|
const attributes = ({
|
|
id: window.getGuid(),
|
|
type: 'outgoing',
|
|
conversationId: this.get('id'),
|
|
sent_at: timestamp,
|
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
|
received_at_ms: timestamp,
|
|
recipients,
|
|
reaction: outgoingReaction,
|
|
// TODO: DESKTOP-722
|
|
} as unknown) as typeof window.Whisper.MessageAttributesType;
|
|
|
|
if (this.isPrivate()) {
|
|
attributes.destination = destination;
|
|
}
|
|
|
|
// We are only creating this model so we can use its sync message
|
|
// sending functionality. It will not be saved to the database.
|
|
const message = new window.Whisper.Message(attributes);
|
|
|
|
// This is to ensure that the functions in send() and sendSyncMessage() don't save
|
|
// anything to the database.
|
|
message.doNotSave = true;
|
|
|
|
// We're offline!
|
|
if (!window.textsecure.messaging) {
|
|
throw new Error('Cannot send reaction while offline!');
|
|
}
|
|
|
|
// Special-case the self-send case - we send only a sync message
|
|
if (this.isMe()) {
|
|
const dataMessage = await window.textsecure.messaging.getMessageProto(
|
|
destination,
|
|
undefined, // body
|
|
[], // attachments
|
|
undefined, // quote
|
|
[], // preview
|
|
undefined, // sticker
|
|
outgoingReaction,
|
|
undefined, // deletedForEveryoneTimestamp
|
|
timestamp,
|
|
expireTimer,
|
|
profileKey
|
|
);
|
|
return message.sendSyncMessageOnly(dataMessage);
|
|
}
|
|
|
|
const options = await this.getSendOptions();
|
|
|
|
const promise = (() => {
|
|
if (this.isPrivate()) {
|
|
return window.textsecure.messaging.sendMessageToIdentifier(
|
|
destination,
|
|
undefined, // body
|
|
[], // attachments
|
|
undefined, // quote
|
|
[], // preview
|
|
undefined, // sticker
|
|
outgoingReaction,
|
|
undefined, // deletedForEveryoneTimestamp
|
|
timestamp,
|
|
expireTimer,
|
|
profileKey,
|
|
options
|
|
);
|
|
}
|
|
|
|
return window.textsecure.messaging.sendMessageToGroup(
|
|
{
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
groupV1: this.getGroupV1Info()!,
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
groupV2: this.getGroupV2Info()!,
|
|
reaction: outgoingReaction,
|
|
timestamp,
|
|
expireTimer,
|
|
profileKey,
|
|
},
|
|
options
|
|
);
|
|
})();
|
|
|
|
return message.send(this.wrapSend(promise));
|
|
}).catch(error => {
|
|
window.log.error('Error sending reaction', reaction, target, error);
|
|
|
|
const reverseReaction = reactionModel.clone();
|
|
reverseReaction.set('remove', !reverseReaction.get('remove'));
|
|
window.Whisper.Reactions.onReaction(reverseReaction);
|
|
|
|
throw error;
|
|
});
|
|
}
|
|
|
|
async sendProfileKeyUpdate(): Promise<void> {
|
|
const id = this.get('id');
|
|
const recipients = this.getRecipients();
|
|
if (!this.get('profileSharing')) {
|
|
window.log.error(
|
|
'Attempted to send profileKeyUpdate to conversation without profileSharing enabled',
|
|
id,
|
|
recipients
|
|
);
|
|
return;
|
|
}
|
|
window.log.info('Sending profileKeyUpdate to conversation', id, recipients);
|
|
const profileKey = window.storage.get('profileKey');
|
|
await window.textsecure.messaging.sendProfileKeyUpdate(
|
|
profileKey,
|
|
recipients,
|
|
await this.getSendOptions(),
|
|
this.get('groupId')
|
|
);
|
|
}
|
|
|
|
sendMessage(
|
|
body: string | null,
|
|
attachments: Array<WhatIsThis>,
|
|
quote: WhatIsThis,
|
|
preview: WhatIsThis,
|
|
sticker?: WhatIsThis,
|
|
mentions?: BodyRangesType
|
|
): void {
|
|
this.clearTypingTimers();
|
|
|
|
const { clearUnreadMetrics } = window.reduxActions.conversations;
|
|
clearUnreadMetrics(this.id);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const destination = this.getSendTarget()!;
|
|
const expireTimer = this.get('expireTimer');
|
|
const recipients = this.getRecipients();
|
|
|
|
let profileKey: ArrayBuffer | undefined;
|
|
if (this.get('profileSharing')) {
|
|
profileKey = window.storage.get('profileKey');
|
|
}
|
|
|
|
this.queueJob(async () => {
|
|
const now = Date.now();
|
|
|
|
window.log.info(
|
|
'Sending message to conversation',
|
|
this.idForLogging(),
|
|
'with timestamp',
|
|
now
|
|
);
|
|
|
|
// Here we move attachments to disk
|
|
const messageWithSchema = await upgradeMessageSchema({
|
|
type: 'outgoing',
|
|
body,
|
|
conversationId: this.id,
|
|
quote,
|
|
preview,
|
|
attachments,
|
|
sent_at: now,
|
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
|
received_at_ms: now,
|
|
expireTimer,
|
|
recipients,
|
|
sticker,
|
|
bodyRanges: mentions,
|
|
});
|
|
|
|
if (this.isPrivate()) {
|
|
messageWithSchema.destination = destination;
|
|
}
|
|
const attributes: MessageModel = {
|
|
...messageWithSchema,
|
|
id: window.getGuid(),
|
|
};
|
|
|
|
const model = this.addSingleMessage(attributes);
|
|
if (sticker) {
|
|
await addStickerPackReference(model.id, sticker.packId);
|
|
}
|
|
const message = window.MessageController.register(model.id, model);
|
|
await window.Signal.Data.saveMessage(message.attributes, {
|
|
forceSave: true,
|
|
Message: window.Whisper.Message,
|
|
});
|
|
|
|
this.set({
|
|
lastMessage: model.getNotificationText(),
|
|
lastMessageStatus: 'sending',
|
|
active_at: now,
|
|
timestamp: now,
|
|
isArchived: false,
|
|
draft: null,
|
|
draftTimestamp: null,
|
|
});
|
|
this.incrementSentMessageCount();
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
|
|
// We're offline!
|
|
if (!window.textsecure.messaging) {
|
|
const errors = [
|
|
...(this.contactCollection && this.contactCollection.length
|
|
? this.contactCollection
|
|
: [this]),
|
|
].map(contact => {
|
|
const error = new Error('Network is not available') as CustomError;
|
|
error.name = 'SendMessageNetworkError';
|
|
error.identifier = contact.get('id');
|
|
return error;
|
|
});
|
|
await message.saveErrors(errors);
|
|
return null;
|
|
}
|
|
|
|
const attachmentsWithData = await Promise.all(
|
|
messageWithSchema.attachments.map(loadAttachmentData)
|
|
);
|
|
|
|
const {
|
|
body: messageBody,
|
|
attachments: finalAttachments,
|
|
} = window.Whisper.Message.getLongMessageAttachment({
|
|
body,
|
|
attachments: attachmentsWithData,
|
|
now,
|
|
});
|
|
|
|
// Special-case the self-send case - we send only a sync message
|
|
if (this.isMe()) {
|
|
const dataMessage = await window.textsecure.messaging.getMessageProto(
|
|
destination,
|
|
messageBody,
|
|
finalAttachments,
|
|
quote,
|
|
preview,
|
|
sticker,
|
|
null, // reaction
|
|
undefined, // deletedForEveryoneTimestamp
|
|
now,
|
|
expireTimer,
|
|
profileKey
|
|
);
|
|
return message.sendSyncMessageOnly(dataMessage);
|
|
}
|
|
|
|
const conversationType = this.get('type');
|
|
const options = await this.getSendOptions();
|
|
|
|
let promise;
|
|
if (conversationType === Message.GROUP) {
|
|
promise = window.textsecure.messaging.sendMessageToGroup(
|
|
{
|
|
attachments: finalAttachments,
|
|
expireTimer,
|
|
groupV1: this.getGroupV1Info(),
|
|
groupV2: this.getGroupV2Info(),
|
|
messageText: messageBody,
|
|
preview,
|
|
profileKey,
|
|
quote,
|
|
sticker,
|
|
timestamp: now,
|
|
mentions,
|
|
},
|
|
options
|
|
);
|
|
} else {
|
|
promise = window.textsecure.messaging.sendMessageToIdentifier(
|
|
destination,
|
|
messageBody,
|
|
finalAttachments,
|
|
quote,
|
|
preview,
|
|
sticker,
|
|
null, // reaction
|
|
undefined, // deletedForEveryoneTimestamp
|
|
now,
|
|
expireTimer,
|
|
profileKey,
|
|
options
|
|
);
|
|
}
|
|
|
|
return message.send(this.wrapSend(promise));
|
|
});
|
|
}
|
|
|
|
async wrapSend(
|
|
promise: Promise<CallbackResultType | void | null>
|
|
): Promise<CallbackResultType | void | null> {
|
|
return promise.then(
|
|
async result => {
|
|
// success
|
|
if (result) {
|
|
await this.handleMessageSendResult(
|
|
result.failoverIdentifiers,
|
|
result.unidentifiedDeliveries
|
|
);
|
|
}
|
|
return result;
|
|
},
|
|
async result => {
|
|
// failure
|
|
if (result) {
|
|
await this.handleMessageSendResult(
|
|
result.failoverIdentifiers,
|
|
result.unidentifiedDeliveries
|
|
);
|
|
}
|
|
throw result;
|
|
}
|
|
);
|
|
}
|
|
|
|
async handleMessageSendResult(
|
|
failoverIdentifiers: Array<string> | undefined,
|
|
unidentifiedDeliveries: Array<string> | undefined
|
|
): Promise<void> {
|
|
await Promise.all(
|
|
(failoverIdentifiers || []).map(async identifier => {
|
|
const conversation = window.ConversationController.get(identifier);
|
|
|
|
if (
|
|
conversation &&
|
|
conversation.get('sealedSender') !== SEALED_SENDER.DISABLED
|
|
) {
|
|
window.log.info(
|
|
`Setting sealedSender to DISABLED for conversation ${conversation.idForLogging()}`
|
|
);
|
|
conversation.set({
|
|
sealedSender: SEALED_SENDER.DISABLED,
|
|
});
|
|
window.Signal.Data.updateConversation(conversation.attributes);
|
|
}
|
|
})
|
|
);
|
|
|
|
await Promise.all(
|
|
(unidentifiedDeliveries || []).map(async identifier => {
|
|
const conversation = window.ConversationController.get(identifier);
|
|
|
|
if (
|
|
conversation &&
|
|
conversation.get('sealedSender') === SEALED_SENDER.UNKNOWN
|
|
) {
|
|
if (conversation.get('accessKey')) {
|
|
window.log.info(
|
|
`Setting sealedSender to ENABLED for conversation ${conversation.idForLogging()}`
|
|
);
|
|
conversation.set({
|
|
sealedSender: SEALED_SENDER.ENABLED,
|
|
});
|
|
} else {
|
|
window.log.info(
|
|
`Setting sealedSender to UNRESTRICTED for conversation ${conversation.idForLogging()}`
|
|
);
|
|
conversation.set({
|
|
sealedSender: SEALED_SENDER.UNRESTRICTED,
|
|
});
|
|
}
|
|
window.Signal.Data.updateConversation(conversation.attributes);
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
async getSendOptions(options = {}): Promise<SendOptionsType> {
|
|
const sendMetadata = await this.getSendMetadata(options);
|
|
|
|
return {
|
|
sendMetadata,
|
|
};
|
|
}
|
|
|
|
async getSendMetadata(
|
|
options: { syncMessage?: string; disableMeCheck?: boolean } = {}
|
|
): Promise<SendMetadataType | undefined> {
|
|
const { syncMessage, disableMeCheck } = options;
|
|
|
|
// START: this code has an Expiration date of ~2018/11/21
|
|
// We don't want to enable unidentified delivery for send unless it is
|
|
// also enabled for our own account.
|
|
const myId = window.ConversationController.getOurConversationId();
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const me = window.ConversationController.get(myId)!;
|
|
if (!disableMeCheck && me.get('sealedSender') === SEALED_SENDER.DISABLED) {
|
|
return undefined;
|
|
}
|
|
// END
|
|
|
|
if (!this.isPrivate()) {
|
|
assert(
|
|
this.contactCollection,
|
|
'getSendMetadata: expected contactCollection to be defined'
|
|
);
|
|
const result: SendMetadataType = {};
|
|
await Promise.all(
|
|
this.contactCollection.map(async conversation => {
|
|
const sendMetadata =
|
|
(await conversation.getSendMetadata(options)) || {};
|
|
Object.assign(result, sendMetadata);
|
|
})
|
|
);
|
|
return result;
|
|
}
|
|
|
|
const accessKey = this.get('accessKey');
|
|
const sealedSender = this.get('sealedSender');
|
|
|
|
// We never send sync messages as sealed sender
|
|
if (syncMessage && this.isMe()) {
|
|
return undefined;
|
|
}
|
|
|
|
const e164 = this.get('e164');
|
|
const uuid = this.get('uuid');
|
|
|
|
const senderCertificate = await this.getSenderCertificateForDirectConversation();
|
|
|
|
// If we've never fetched user's profile, we default to what we have
|
|
if (sealedSender === SEALED_SENDER.UNKNOWN) {
|
|
const info = {
|
|
accessKey: accessKey || arrayBufferToBase64(getRandomBytes(16)),
|
|
senderCertificate,
|
|
};
|
|
return {
|
|
...(e164 ? { [e164]: info } : {}),
|
|
...(uuid ? { [uuid]: info } : {}),
|
|
};
|
|
}
|
|
|
|
if (sealedSender === SEALED_SENDER.DISABLED) {
|
|
return undefined;
|
|
}
|
|
|
|
const info = {
|
|
accessKey:
|
|
accessKey && sealedSender === SEALED_SENDER.ENABLED
|
|
? accessKey
|
|
: arrayBufferToBase64(getRandomBytes(16)),
|
|
senderCertificate,
|
|
};
|
|
|
|
return {
|
|
...(e164 ? { [e164]: info } : {}),
|
|
...(uuid ? { [uuid]: info } : {}),
|
|
};
|
|
}
|
|
|
|
private getSenderCertificateForDirectConversation(): Promise<
|
|
undefined | SerializedCertificateType
|
|
> {
|
|
if (!this.isPrivate()) {
|
|
throw new Error(
|
|
'getSenderCertificateForDirectConversation should only be called for direct conversations'
|
|
);
|
|
}
|
|
|
|
const phoneNumberSharingMode = parsePhoneNumberSharingMode(
|
|
window.storage.get('phoneNumberSharingMode')
|
|
);
|
|
|
|
let certificateMode: SenderCertificateMode;
|
|
switch (phoneNumberSharingMode) {
|
|
case PhoneNumberSharingMode.Everybody:
|
|
certificateMode = SenderCertificateMode.WithE164;
|
|
break;
|
|
case PhoneNumberSharingMode.ContactsOnly: {
|
|
const isInSystemContacts = Boolean(this.get('name'));
|
|
certificateMode = isInSystemContacts
|
|
? SenderCertificateMode.WithE164
|
|
: SenderCertificateMode.WithoutE164;
|
|
break;
|
|
}
|
|
case PhoneNumberSharingMode.Nobody:
|
|
certificateMode = SenderCertificateMode.WithoutE164;
|
|
break;
|
|
default:
|
|
throw missingCaseError(phoneNumberSharingMode);
|
|
}
|
|
|
|
return senderCertificateService.get(certificateMode);
|
|
}
|
|
|
|
// Is this someone who is a contact, or are we sharing our profile with them?
|
|
// Or is the person who added us to this group a contact or are we sharing profile
|
|
// with them?
|
|
isFromOrAddedByTrustedContact(): boolean {
|
|
if (this.isPrivate()) {
|
|
return Boolean(this.get('name')) || this.get('profileSharing');
|
|
}
|
|
|
|
const addedBy = this.get('addedBy');
|
|
if (!addedBy) {
|
|
return false;
|
|
}
|
|
|
|
const conv = window.ConversationController.get(addedBy);
|
|
if (!conv) {
|
|
return false;
|
|
}
|
|
|
|
return Boolean(
|
|
conv.isMe() || conv.get('name') || conv.get('profileSharing')
|
|
);
|
|
}
|
|
|
|
async updateLastMessage(): Promise<void> {
|
|
if (!this.id) {
|
|
return;
|
|
}
|
|
|
|
const ourConversationId = window.ConversationController.getOurConversationId();
|
|
if (!ourConversationId) {
|
|
throw new Error('updateLastMessage: Failed to fetch ourConversationId');
|
|
}
|
|
|
|
const conversationId = this.id;
|
|
let [previewMessage, activityMessage] = await Promise.all([
|
|
window.Signal.Data.getLastConversationPreview({
|
|
conversationId,
|
|
ourConversationId,
|
|
Message: window.Whisper.Message,
|
|
}),
|
|
window.Signal.Data.getLastConversationActivity({
|
|
conversationId,
|
|
ourConversationId,
|
|
Message: window.Whisper.Message,
|
|
}),
|
|
]);
|
|
|
|
// Register the message with MessageController so that if it already exists
|
|
// in memory we use that data instead of the data from the db which may
|
|
// be out of date.
|
|
if (previewMessage) {
|
|
previewMessage = window.MessageController.register(
|
|
previewMessage.id,
|
|
previewMessage
|
|
);
|
|
}
|
|
|
|
if (activityMessage) {
|
|
activityMessage = window.MessageController.register(
|
|
activityMessage.id,
|
|
activityMessage
|
|
);
|
|
}
|
|
|
|
if (
|
|
this.hasDraft() &&
|
|
this.get('draftTimestamp') &&
|
|
(!previewMessage ||
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
previewMessage.get('sent_at') < this.get('draftTimestamp')!)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const currentTimestamp = this.get('timestamp') || null;
|
|
const timestamp = activityMessage
|
|
? activityMessage.get('sent_at') ||
|
|
activityMessage.get('received_at') ||
|
|
currentTimestamp
|
|
: currentTimestamp;
|
|
|
|
this.set({
|
|
lastMessage:
|
|
(previewMessage ? previewMessage.getNotificationText() : '') || '',
|
|
lastMessageStatus:
|
|
(previewMessage ? previewMessage.getMessagePropStatus() : null) || null,
|
|
timestamp,
|
|
lastMessageDeletedForEveryone: previewMessage
|
|
? previewMessage.get('deletedForEveryone')
|
|
: false,
|
|
});
|
|
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
}
|
|
|
|
setArchived(isArchived: boolean): void {
|
|
const before = this.get('isArchived');
|
|
|
|
this.set({ isArchived });
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
|
|
const after = this.get('isArchived');
|
|
|
|
if (Boolean(before) !== Boolean(after)) {
|
|
if (after) {
|
|
// we're capturing a storage sync below so
|
|
// we don't need to capture it twice
|
|
this.unpin({ stopStorageSync: true });
|
|
}
|
|
this.captureChange('isArchived');
|
|
}
|
|
}
|
|
|
|
setMarkedUnread(markedUnread: boolean): void {
|
|
const previousMarkedUnread = this.get('markedUnread');
|
|
|
|
this.set({ markedUnread });
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
|
|
if (Boolean(previousMarkedUnread) !== Boolean(markedUnread)) {
|
|
this.captureChange('markedUnread');
|
|
}
|
|
|
|
window.Whisper.events.trigger('updateUnreadCount');
|
|
}
|
|
|
|
async refreshGroupLink(): Promise<void> {
|
|
if (!this.isGroupV2()) {
|
|
return;
|
|
}
|
|
|
|
const groupInviteLinkPassword = arrayBufferToBase64(
|
|
window.Signal.Groups.generateGroupInviteLinkPassword()
|
|
);
|
|
|
|
window.log.info('refreshGroupLink for conversation', this.idForLogging());
|
|
|
|
await this.modifyGroupV2({
|
|
name: 'updateInviteLinkPassword',
|
|
createGroupChange: async () =>
|
|
window.Signal.Groups.buildInviteLinkPasswordChange(
|
|
this.attributes,
|
|
groupInviteLinkPassword
|
|
),
|
|
});
|
|
|
|
this.set({ groupInviteLinkPassword });
|
|
}
|
|
|
|
async toggleGroupLink(value: boolean): Promise<void> {
|
|
if (!this.isGroupV2()) {
|
|
return;
|
|
}
|
|
|
|
const shouldCreateNewGroupLink =
|
|
value && !this.get('groupInviteLinkPassword');
|
|
const groupInviteLinkPassword =
|
|
this.get('groupInviteLinkPassword') ||
|
|
arrayBufferToBase64(
|
|
window.Signal.Groups.generateGroupInviteLinkPassword()
|
|
);
|
|
|
|
window.log.info(
|
|
'toggleGroupLink for conversation',
|
|
this.idForLogging(),
|
|
value
|
|
);
|
|
|
|
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
|
const addFromInviteLink = value
|
|
? ACCESS_ENUM.ANY
|
|
: ACCESS_ENUM.UNSATISFIABLE;
|
|
|
|
if (shouldCreateNewGroupLink) {
|
|
await this.modifyGroupV2({
|
|
name: 'updateNewGroupLink',
|
|
createGroupChange: async () =>
|
|
window.Signal.Groups.buildNewGroupLinkChange(
|
|
this.attributes,
|
|
groupInviteLinkPassword,
|
|
addFromInviteLink
|
|
),
|
|
});
|
|
} else {
|
|
await this.modifyGroupV2({
|
|
name: 'updateAccessControlAddFromInviteLink',
|
|
createGroupChange: async () =>
|
|
window.Signal.Groups.buildAccessControlAddFromInviteLinkChange(
|
|
this.attributes,
|
|
addFromInviteLink
|
|
),
|
|
});
|
|
}
|
|
|
|
this.set({
|
|
accessControl: {
|
|
addFromInviteLink,
|
|
attributes: this.get('accessControl')?.attributes || ACCESS_ENUM.MEMBER,
|
|
members: this.get('accessControl')?.members || ACCESS_ENUM.MEMBER,
|
|
},
|
|
});
|
|
|
|
if (shouldCreateNewGroupLink) {
|
|
this.set({ groupInviteLinkPassword });
|
|
}
|
|
}
|
|
|
|
async updateAccessControlAddFromInviteLink(value: boolean): Promise<void> {
|
|
if (!this.isGroupV2()) {
|
|
return;
|
|
}
|
|
|
|
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
|
|
|
const addFromInviteLink = value
|
|
? ACCESS_ENUM.ADMINISTRATOR
|
|
: ACCESS_ENUM.ANY;
|
|
|
|
await this.modifyGroupV2({
|
|
name: 'updateAccessControlAddFromInviteLink',
|
|
createGroupChange: async () =>
|
|
window.Signal.Groups.buildAccessControlAddFromInviteLinkChange(
|
|
this.attributes,
|
|
addFromInviteLink
|
|
),
|
|
});
|
|
|
|
this.set({
|
|
accessControl: {
|
|
addFromInviteLink,
|
|
attributes: this.get('accessControl')?.attributes || ACCESS_ENUM.MEMBER,
|
|
members: this.get('accessControl')?.members || ACCESS_ENUM.MEMBER,
|
|
},
|
|
});
|
|
}
|
|
|
|
async updateAccessControlAttributes(value: number): Promise<void> {
|
|
if (!this.isGroupV2()) {
|
|
return;
|
|
}
|
|
|
|
await this.modifyGroupV2({
|
|
name: 'updateAccessControlAttributes',
|
|
createGroupChange: async () =>
|
|
window.Signal.Groups.buildAccessControlAttributesChange(
|
|
this.attributes,
|
|
value
|
|
),
|
|
});
|
|
|
|
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
|
this.set({
|
|
accessControl: {
|
|
addFromInviteLink:
|
|
this.get('accessControl')?.addFromInviteLink || ACCESS_ENUM.MEMBER,
|
|
attributes: value,
|
|
members: this.get('accessControl')?.members || ACCESS_ENUM.MEMBER,
|
|
},
|
|
});
|
|
}
|
|
|
|
async updateAccessControlMembers(value: number): Promise<void> {
|
|
if (!this.isGroupV2()) {
|
|
return;
|
|
}
|
|
|
|
await this.modifyGroupV2({
|
|
name: 'updateAccessControlMembers',
|
|
createGroupChange: async () =>
|
|
window.Signal.Groups.buildAccessControlMembersChange(
|
|
this.attributes,
|
|
value
|
|
),
|
|
});
|
|
|
|
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;
|
|
this.set({
|
|
accessControl: {
|
|
addFromInviteLink:
|
|
this.get('accessControl')?.addFromInviteLink || ACCESS_ENUM.MEMBER,
|
|
attributes: this.get('accessControl')?.attributes || ACCESS_ENUM.MEMBER,
|
|
members: value,
|
|
},
|
|
});
|
|
}
|
|
|
|
async updateExpirationTimer(
|
|
providedExpireTimer: number | undefined,
|
|
providedSource: unknown,
|
|
receivedAt: number,
|
|
options: { fromSync?: unknown; fromGroupUpdate?: unknown } = {}
|
|
): Promise<boolean | null | MessageModel | void> {
|
|
if (this.isGroupV2()) {
|
|
if (providedSource || receivedAt) {
|
|
throw new Error(
|
|
'updateExpirationTimer: GroupV2 timers are not updated this way'
|
|
);
|
|
}
|
|
await this.modifyGroupV2({
|
|
name: 'updateExpirationTimer',
|
|
createGroupChange: () =>
|
|
this.updateExpirationTimerInGroupV2(providedExpireTimer),
|
|
});
|
|
return false;
|
|
}
|
|
|
|
let expireTimer: number | undefined = providedExpireTimer;
|
|
let source = providedSource;
|
|
if (this.get('left')) {
|
|
return false;
|
|
}
|
|
|
|
window._.defaults(options, { fromSync: false, fromGroupUpdate: false });
|
|
|
|
if (!expireTimer) {
|
|
expireTimer = undefined;
|
|
}
|
|
if (
|
|
this.get('expireTimer') === expireTimer ||
|
|
(!expireTimer && !this.get('expireTimer'))
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
window.log.info("Update conversation 'expireTimer'", {
|
|
id: this.idForLogging(),
|
|
expireTimer,
|
|
source,
|
|
});
|
|
|
|
source = source || window.ConversationController.getOurConversationId();
|
|
|
|
// When we add a disappearing messages notification to the conversation, we want it
|
|
// to be above the message that initiated that change, hence the subtraction.
|
|
const timestamp = (receivedAt || Date.now()) - 1;
|
|
|
|
this.set({ expireTimer });
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
|
|
const model = new window.Whisper.Message(({
|
|
// Even though this isn't reflected to the user, we want to place the last seen
|
|
// indicator above it. We set it to 'unread' to trigger that placement.
|
|
unread: 1,
|
|
conversationId: this.id,
|
|
// No type; 'incoming' messages are specially treated by conversation.markRead()
|
|
sent_at: timestamp,
|
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
|
received_at_ms: timestamp,
|
|
flags:
|
|
window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
|
|
expirationTimerUpdate: {
|
|
expireTimer,
|
|
source,
|
|
fromSync: options.fromSync,
|
|
fromGroupUpdate: options.fromGroupUpdate,
|
|
},
|
|
// TODO: DESKTOP-722
|
|
} as unknown) as MessageAttributesType);
|
|
|
|
if (this.isPrivate()) {
|
|
model.set({ destination: this.getSendTarget() });
|
|
}
|
|
if (model.isOutgoing()) {
|
|
model.set({ recipients: this.getRecipients() });
|
|
}
|
|
const id = await window.Signal.Data.saveMessage(model.attributes, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
|
|
model.set({ id });
|
|
|
|
const message = window.MessageController.register(id, model);
|
|
this.addSingleMessage(message);
|
|
|
|
// if change was made remotely, don't send it to the number/group
|
|
if (receivedAt) {
|
|
return message;
|
|
}
|
|
|
|
let profileKey;
|
|
if (this.get('profileSharing')) {
|
|
profileKey = window.storage.get('profileKey');
|
|
}
|
|
const sendOptions = await this.getSendOptions();
|
|
let promise;
|
|
|
|
if (this.isMe()) {
|
|
const flags =
|
|
window.textsecure.protobuf.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
|
|
const dataMessage = await window.textsecure.messaging.getMessageProto(
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.getSendTarget()!,
|
|
undefined, // body
|
|
[], // attachments
|
|
undefined, // quote
|
|
[], // preview
|
|
undefined, // sticker
|
|
undefined, // reaction
|
|
undefined, // deletedForEveryoneTimestamp
|
|
message.get('sent_at'),
|
|
expireTimer,
|
|
profileKey,
|
|
flags
|
|
);
|
|
return message.sendSyncMessageOnly(dataMessage);
|
|
}
|
|
|
|
if (this.get('type') === 'private') {
|
|
promise = window.textsecure.messaging.sendExpirationTimerUpdateToIdentifier(
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.getSendTarget()!,
|
|
expireTimer,
|
|
message.get('sent_at'),
|
|
profileKey,
|
|
sendOptions
|
|
);
|
|
} else {
|
|
promise = window.textsecure.messaging.sendExpirationTimerUpdateToGroup(
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.get('groupId')!,
|
|
this.getRecipients(),
|
|
expireTimer,
|
|
message.get('sent_at'),
|
|
profileKey,
|
|
sendOptions
|
|
);
|
|
}
|
|
|
|
await message.send(this.wrapSend(promise));
|
|
|
|
return message;
|
|
}
|
|
|
|
async addMessageHistoryDisclaimer(): Promise<MessageModel> {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const lastMessage = this.messageCollection!.last();
|
|
if (lastMessage && lastMessage.get('type') === 'message-history-unsynced') {
|
|
// We do not need another message history disclaimer
|
|
return lastMessage;
|
|
}
|
|
|
|
const timestamp = Date.now();
|
|
|
|
const model = new window.Whisper.Message(({
|
|
type: 'message-history-unsynced',
|
|
// Even though this isn't reflected to the user, we want to place the last seen
|
|
// indicator above it. We set it to 'unread' to trigger that placement.
|
|
unread: 1,
|
|
conversationId: this.id,
|
|
// No type; 'incoming' messages are specially treated by conversation.markRead()
|
|
sent_at: timestamp,
|
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
|
received_at_ms: timestamp,
|
|
// TODO: DESKTOP-722
|
|
} as unknown) as MessageAttributesType);
|
|
|
|
if (this.isPrivate()) {
|
|
model.set({ destination: this.id });
|
|
}
|
|
if (model.isOutgoing()) {
|
|
model.set({ recipients: this.getRecipients() });
|
|
}
|
|
const id = await window.Signal.Data.saveMessage(model.attributes, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
|
|
model.set({ id });
|
|
|
|
const message = window.MessageController.register(id, model);
|
|
this.addSingleMessage(message);
|
|
|
|
return message;
|
|
}
|
|
|
|
isSearchable(): boolean {
|
|
return !this.get('left');
|
|
}
|
|
|
|
async endSession(): Promise<void> {
|
|
if (this.isPrivate()) {
|
|
const now = Date.now();
|
|
const model = new window.Whisper.Message(({
|
|
conversationId: this.id,
|
|
type: 'outgoing',
|
|
sent_at: now,
|
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
|
received_at_ms: now,
|
|
destination: this.get('e164'),
|
|
destinationUuid: this.get('uuid'),
|
|
recipients: this.getRecipients(),
|
|
flags: window.textsecure.protobuf.DataMessage.Flags.END_SESSION,
|
|
// TODO: DESKTOP-722
|
|
} as unknown) as MessageAttributesType);
|
|
|
|
const id = await window.Signal.Data.saveMessage(model.attributes, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
model.set({ id });
|
|
|
|
const message = window.MessageController.register(model.id, model);
|
|
this.addSingleMessage(message);
|
|
|
|
const options = await this.getSendOptions();
|
|
message.send(
|
|
this.wrapSend(
|
|
// TODO: DESKTOP-724
|
|
// resetSession returns `Array<void>` which is incompatible with the
|
|
// expected promise return values. `[]` is truthy and wrapSend assumes
|
|
// it's a valid callback result type
|
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
// @ts-ignore
|
|
window.textsecure.messaging.resetSession(
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.get('uuid')!,
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.get('e164')!,
|
|
now,
|
|
options
|
|
)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
async leaveGroup(): Promise<void> {
|
|
const now = Date.now();
|
|
if (this.get('type') === 'group') {
|
|
const groupId = this.get('groupId');
|
|
|
|
if (!groupId) {
|
|
throw new Error(`leaveGroup/${this.idForLogging()}: No groupId!`);
|
|
}
|
|
|
|
const groupIdentifiers = this.getRecipients();
|
|
this.set({ left: true });
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
|
|
const model = new window.Whisper.Message(({
|
|
group_update: { left: 'You' },
|
|
conversationId: this.id,
|
|
type: 'outgoing',
|
|
sent_at: now,
|
|
received_at: window.Signal.Util.incrementMessageCounter(),
|
|
received_at_ms: now,
|
|
// TODO: DESKTOP-722
|
|
} as unknown) as MessageAttributesType);
|
|
|
|
const id = await window.Signal.Data.saveMessage(model.attributes, {
|
|
Message: window.Whisper.Message,
|
|
});
|
|
model.set({ id });
|
|
|
|
const message = window.MessageController.register(model.id, model);
|
|
this.addSingleMessage(message);
|
|
|
|
const options = await this.getSendOptions();
|
|
message.send(
|
|
this.wrapSend(
|
|
window.textsecure.messaging.leaveGroup(
|
|
groupId,
|
|
groupIdentifiers,
|
|
options
|
|
)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
|
|
async markRead(
|
|
newestUnreadDate: number,
|
|
providedOptions: { readAt?: number; sendReadReceipts: boolean }
|
|
): Promise<WhatIsThis> {
|
|
const options = providedOptions || {};
|
|
window._.defaults(options, { sendReadReceipts: true });
|
|
|
|
const conversationId = this.id;
|
|
window.Whisper.Notifications.removeBy({ conversationId });
|
|
|
|
let unreadMessages:
|
|
| MessageModelCollectionType
|
|
| Array<MessageModel> = await this.getUnread();
|
|
const oldUnread = unreadMessages.filter(
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
message => message.get('received_at')! <= newestUnreadDate
|
|
);
|
|
|
|
let read = await Promise.all(
|
|
window._.map(oldUnread, async providedM => {
|
|
const m = window.MessageController.register(providedM.id, providedM);
|
|
|
|
// Note that this will update the message in the database
|
|
await m.markRead(options.readAt);
|
|
|
|
return {
|
|
senderE164: m.get('source'),
|
|
senderUuid: m.get('sourceUuid'),
|
|
senderId: window.ConversationController.ensureContactIds({
|
|
e164: m.get('source'),
|
|
uuid: m.get('sourceUuid'),
|
|
}),
|
|
timestamp: m.get('sent_at'),
|
|
hasErrors: m.hasErrors(),
|
|
};
|
|
})
|
|
);
|
|
|
|
// Some messages we're marking read are local notifications with no sender
|
|
read = window._.filter(read, m => Boolean(m.senderId));
|
|
unreadMessages = unreadMessages.filter(m => Boolean(m.isIncoming()));
|
|
|
|
const unreadCount = unreadMessages.length - read.length;
|
|
this.set({ unreadCount });
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
|
|
// If a message has errors, we don't want to send anything out about it.
|
|
// read syncs - let's wait for a client that really understands the message
|
|
// to mark it read. we'll mark our local error read locally, though.
|
|
// read receipts - here we can run into infinite loops, where each time the
|
|
// conversation is viewed, another error message shows up for the contact
|
|
read = read.filter(item => !item.hasErrors);
|
|
|
|
if (read.length && options.sendReadReceipts) {
|
|
window.log.info(`Sending ${read.length} read syncs`);
|
|
// Because syncReadMessages sends to our other devices, and sendReadReceipts goes
|
|
// to a contact, we need accessKeys for both.
|
|
const {
|
|
sendOptions,
|
|
} = await window.ConversationController.prepareForSend(
|
|
window.ConversationController.getOurConversationId(),
|
|
{ syncMessage: true }
|
|
);
|
|
await this.wrapSend(
|
|
window.textsecure.messaging.syncReadMessages(read, sendOptions)
|
|
);
|
|
await this.sendReadReceiptsFor(read);
|
|
}
|
|
}
|
|
|
|
async sendReadReceiptsFor(items: Array<unknown>): Promise<void> {
|
|
// Only send read receipts for accepted conversations
|
|
if (window.storage.get('read-receipt-setting') && this.getAccepted()) {
|
|
window.log.info(`Sending ${items.length} read receipts`);
|
|
const convoSendOptions = await this.getSendOptions();
|
|
const receiptsBySender = window._.groupBy(items, 'senderId');
|
|
|
|
await Promise.all(
|
|
window._.map(receiptsBySender, async (receipts, senderId) => {
|
|
const timestamps = window._.map(receipts, 'timestamp');
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const c = window.ConversationController.get(senderId)!;
|
|
await this.wrapSend(
|
|
window.textsecure.messaging.sendReadReceipts(
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
c.get('e164')!,
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
c.get('uuid')!,
|
|
timestamps,
|
|
convoSendOptions
|
|
)
|
|
);
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
// This is an expensive operation we use to populate the message request hero row. It
|
|
// shows groups the current user has in common with this potential new contact.
|
|
async updateSharedGroups(): Promise<void> {
|
|
if (!this.isPrivate()) {
|
|
return;
|
|
}
|
|
if (this.isMe()) {
|
|
return;
|
|
}
|
|
|
|
const ourGroups = await window.ConversationController.getAllGroupsInvolvingId(
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
window.ConversationController.getOurConversationId()!
|
|
);
|
|
const theirGroups = await window.ConversationController.getAllGroupsInvolvingId(
|
|
this.id
|
|
);
|
|
|
|
const sharedGroups = window._.intersection(ourGroups, theirGroups);
|
|
const sharedGroupNames = sharedGroups.map(conversation =>
|
|
conversation.getTitle()
|
|
);
|
|
|
|
this.set({ sharedGroupNames });
|
|
}
|
|
|
|
onChangeProfileKey(): void {
|
|
if (this.isPrivate()) {
|
|
this.getProfiles();
|
|
}
|
|
}
|
|
|
|
getProfiles(): Promise<Array<void>> {
|
|
// request all conversation members' keys
|
|
const conversations = (this.getMembers() as unknown) as Array<
|
|
ConversationModel
|
|
>;
|
|
return Promise.all(
|
|
window._.map(conversations, conversation => {
|
|
this.getProfile(conversation.get('uuid'), conversation.get('e164'));
|
|
})
|
|
);
|
|
}
|
|
|
|
async getProfile(
|
|
providedUuid?: string,
|
|
providedE164?: string
|
|
): Promise<void> {
|
|
if (!window.textsecure.messaging) {
|
|
throw new Error(
|
|
'Conversation.getProfile: window.textsecure.messaging not available'
|
|
);
|
|
}
|
|
|
|
const id = window.ConversationController.ensureContactIds({
|
|
uuid: providedUuid,
|
|
e164: providedE164,
|
|
});
|
|
const c = window.ConversationController.get(id);
|
|
if (!c) {
|
|
window.log.error(
|
|
'getProfile: failed to find conversation; doing nothing'
|
|
);
|
|
return;
|
|
}
|
|
|
|
const {
|
|
generateProfileKeyCredentialRequest,
|
|
getClientZkProfileOperations,
|
|
handleProfileKeyCredential,
|
|
} = Util.zkgroup;
|
|
|
|
const clientZkProfileCipher = getClientZkProfileOperations(
|
|
window.getServerPublicParams()
|
|
);
|
|
|
|
let profile;
|
|
|
|
try {
|
|
await Promise.all([
|
|
c.deriveAccessKeyIfNeeded(),
|
|
c.deriveProfileKeyVersionIfNeeded(),
|
|
]);
|
|
|
|
const profileKey = c.get('profileKey');
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const uuid = c.get('uuid')!;
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const identifier = c.getSendTarget()!;
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const profileKeyVersionHex = c.get('profileKeyVersion')!;
|
|
const existingProfileKeyCredential = c.get('profileKeyCredential');
|
|
|
|
let profileKeyCredentialRequestHex: undefined | string;
|
|
let profileCredentialRequestContext:
|
|
| undefined
|
|
| ProfileKeyCredentialRequestContext;
|
|
|
|
if (
|
|
profileKey &&
|
|
uuid &&
|
|
profileKeyVersionHex &&
|
|
!existingProfileKeyCredential
|
|
) {
|
|
window.log.info('Generating request...');
|
|
({
|
|
requestHex: profileKeyCredentialRequestHex,
|
|
context: profileCredentialRequestContext,
|
|
} = generateProfileKeyCredentialRequest(
|
|
clientZkProfileCipher,
|
|
uuid,
|
|
profileKey
|
|
));
|
|
}
|
|
|
|
const sendMetadata =
|
|
(await c.getSendMetadata({ disableMeCheck: true })) || {};
|
|
const getInfo =
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
sendMetadata[c.get('uuid')!] || sendMetadata[c.get('e164')!] || {};
|
|
|
|
if (getInfo.accessKey) {
|
|
try {
|
|
profile = await window.textsecure.messaging.getProfile(identifier, {
|
|
accessKey: getInfo.accessKey,
|
|
profileKeyVersion: profileKeyVersionHex,
|
|
profileKeyCredentialRequest: profileKeyCredentialRequestHex,
|
|
});
|
|
} catch (error) {
|
|
if (error.code === 401 || error.code === 403) {
|
|
window.log.info(
|
|
`Setting sealedSender to DISABLED for conversation ${c.idForLogging()}`
|
|
);
|
|
c.set({ sealedSender: SEALED_SENDER.DISABLED });
|
|
profile = await window.textsecure.messaging.getProfile(identifier, {
|
|
profileKeyVersion: profileKeyVersionHex,
|
|
profileKeyCredentialRequest: profileKeyCredentialRequestHex,
|
|
});
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
} else {
|
|
profile = await window.textsecure.messaging.getProfile(identifier, {
|
|
profileKeyVersion: profileKeyVersionHex,
|
|
profileKeyCredentialRequest: profileKeyCredentialRequestHex,
|
|
});
|
|
}
|
|
|
|
const identityKey = base64ToArrayBuffer(profile.identityKey);
|
|
const changed = await window.textsecure.storage.protocol.saveIdentity(
|
|
`${identifier}.1`,
|
|
identityKey,
|
|
false
|
|
);
|
|
if (changed) {
|
|
// save identity will close all sessions except for .1, so we
|
|
// must close that one manually.
|
|
const address = new window.libsignal.SignalProtocolAddress(
|
|
identifier,
|
|
1
|
|
);
|
|
window.log.info('closing session for', address.toString());
|
|
const sessionCipher = new window.libsignal.SessionCipher(
|
|
window.textsecure.storage.protocol,
|
|
address
|
|
);
|
|
await sessionCipher.closeOpenSessionForDevice();
|
|
}
|
|
|
|
const accessKey = c.get('accessKey');
|
|
if (
|
|
profile.unrestrictedUnidentifiedAccess &&
|
|
profile.unidentifiedAccess
|
|
) {
|
|
window.log.info(
|
|
`Setting sealedSender to UNRESTRICTED for conversation ${c.idForLogging()}`
|
|
);
|
|
c.set({
|
|
sealedSender: SEALED_SENDER.UNRESTRICTED,
|
|
});
|
|
} else if (accessKey && profile.unidentifiedAccess) {
|
|
const haveCorrectKey = await verifyAccessKey(
|
|
base64ToArrayBuffer(accessKey),
|
|
base64ToArrayBuffer(profile.unidentifiedAccess)
|
|
);
|
|
|
|
if (haveCorrectKey) {
|
|
window.log.info(
|
|
`Setting sealedSender to ENABLED for conversation ${c.idForLogging()}`
|
|
);
|
|
c.set({
|
|
sealedSender: SEALED_SENDER.ENABLED,
|
|
});
|
|
} else {
|
|
window.log.info(
|
|
`Setting sealedSender to DISABLED for conversation ${c.idForLogging()}`
|
|
);
|
|
c.set({
|
|
sealedSender: SEALED_SENDER.DISABLED,
|
|
});
|
|
}
|
|
} else {
|
|
window.log.info(
|
|
`Setting sealedSender to DISABLED for conversation ${c.idForLogging()}`
|
|
);
|
|
c.set({
|
|
sealedSender: SEALED_SENDER.DISABLED,
|
|
});
|
|
}
|
|
|
|
if (profile.about) {
|
|
const key = c.get('profileKey');
|
|
if (key) {
|
|
const keyBuffer = base64ToArrayBuffer(key);
|
|
const decrypted = await window.textsecure.crypto.decryptProfile(
|
|
base64ToArrayBuffer(profile.about),
|
|
keyBuffer
|
|
);
|
|
c.set('about', stringFromBytes(trimForDisplay(decrypted)));
|
|
}
|
|
} else {
|
|
c.unset('about');
|
|
}
|
|
|
|
if (profile.aboutEmoji) {
|
|
const key = c.get('profileKey');
|
|
if (key) {
|
|
const keyBuffer = base64ToArrayBuffer(key);
|
|
const decrypted = await window.textsecure.crypto.decryptProfile(
|
|
base64ToArrayBuffer(profile.aboutEmoji),
|
|
keyBuffer
|
|
);
|
|
c.set('aboutEmoji', stringFromBytes(trimForDisplay(decrypted)));
|
|
}
|
|
} else {
|
|
c.unset('aboutEmoji');
|
|
}
|
|
|
|
if (profile.capabilities) {
|
|
c.set({ capabilities: profile.capabilities });
|
|
} else {
|
|
c.unset('capabilities');
|
|
}
|
|
|
|
if (profileCredentialRequestContext) {
|
|
if (profile.credential) {
|
|
const profileKeyCredential = handleProfileKeyCredential(
|
|
clientZkProfileCipher,
|
|
profileCredentialRequestContext,
|
|
profile.credential
|
|
);
|
|
c.set({ profileKeyCredential });
|
|
} else {
|
|
c.unset('profileKeyCredential');
|
|
}
|
|
}
|
|
} catch (error) {
|
|
switch (error?.code) {
|
|
case 403:
|
|
throw error;
|
|
case 404:
|
|
window.log.warn(
|
|
`getProfile failure: failed to find a profile for ${c.idForLogging()}`,
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
c.setUnregistered();
|
|
return;
|
|
default:
|
|
window.log.warn(
|
|
'getProfile failure:',
|
|
c.idForLogging(),
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await c.setEncryptedProfileName(profile.name);
|
|
} catch (error) {
|
|
window.log.warn(
|
|
'getProfile decryption failure:',
|
|
c.idForLogging(),
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
await c.set({
|
|
profileName: undefined,
|
|
profileFamilyName: undefined,
|
|
});
|
|
}
|
|
|
|
try {
|
|
await c.setProfileAvatar(profile.avatar);
|
|
} catch (error) {
|
|
if (error.code === 403 || error.code === 404) {
|
|
window.log.info(
|
|
`Clearing profile avatar for conversation ${c.idForLogging()}`
|
|
);
|
|
c.set({
|
|
profileAvatar: null,
|
|
});
|
|
}
|
|
}
|
|
|
|
c.set('profileLastFetchedAt', Date.now());
|
|
|
|
window.Signal.Data.updateConversation(c.attributes);
|
|
}
|
|
|
|
async setEncryptedProfileName(encryptedName: string): Promise<void> {
|
|
if (!encryptedName) {
|
|
return;
|
|
}
|
|
// isn't this already an ArrayBuffer?
|
|
const key = (this.get('profileKey') as unknown) as string;
|
|
if (!key) {
|
|
return;
|
|
}
|
|
|
|
// decode
|
|
const keyBuffer = base64ToArrayBuffer(key);
|
|
|
|
// decrypt
|
|
const { given, family } = await window.textsecure.crypto.decryptProfileName(
|
|
encryptedName,
|
|
keyBuffer
|
|
);
|
|
|
|
// encode
|
|
const profileName = given ? stringFromBytes(given) : undefined;
|
|
const profileFamilyName = family ? stringFromBytes(family) : undefined;
|
|
|
|
// set then check for changes
|
|
const oldName = this.getProfileName();
|
|
const hadPreviousName = Boolean(oldName);
|
|
this.set({ profileName, profileFamilyName });
|
|
|
|
const newName = this.getProfileName();
|
|
|
|
// Note that we compare the combined names to ensure that we don't present the exact
|
|
// same before/after string, even if someone is moving from just first name to
|
|
// first/last name in their profile data.
|
|
const nameChanged = oldName !== newName;
|
|
|
|
if (!this.isMe() && hadPreviousName && nameChanged) {
|
|
const change = {
|
|
type: 'name',
|
|
oldName,
|
|
newName,
|
|
};
|
|
|
|
await this.addProfileChange(change);
|
|
}
|
|
}
|
|
|
|
async setProfileAvatar(avatarPath: string): Promise<void> {
|
|
if (!avatarPath) {
|
|
return;
|
|
}
|
|
|
|
if (this.isMe()) {
|
|
window.storage.put('avatarUrl', avatarPath);
|
|
}
|
|
|
|
const avatar = await window.textsecure.messaging.getAvatar(avatarPath);
|
|
// isn't this already an ArrayBuffer?
|
|
const key = (this.get('profileKey') as unknown) as string;
|
|
if (!key) {
|
|
return;
|
|
}
|
|
const keyBuffer = base64ToArrayBuffer(key);
|
|
|
|
// decrypt
|
|
const decrypted = await window.textsecure.crypto.decryptProfile(
|
|
avatar,
|
|
keyBuffer
|
|
);
|
|
|
|
// update the conversation avatar only if hash differs
|
|
if (decrypted) {
|
|
const newAttributes = await window.Signal.Types.Conversation.maybeUpdateProfileAvatar(
|
|
this.attributes,
|
|
decrypted,
|
|
{
|
|
writeNewAttachmentData,
|
|
deleteAttachmentData,
|
|
doesAttachmentExist,
|
|
}
|
|
);
|
|
this.set(newAttributes);
|
|
}
|
|
}
|
|
|
|
async setProfileKey(
|
|
profileKey: string,
|
|
{ viaStorageServiceSync = false } = {}
|
|
): Promise<void> {
|
|
// profileKey is a string so we can compare it directly
|
|
if (this.get('profileKey') !== profileKey) {
|
|
window.log.info(
|
|
`Setting sealedSender to UNKNOWN for conversation ${this.idForLogging()}`
|
|
);
|
|
this.set({
|
|
about: undefined,
|
|
aboutEmoji: undefined,
|
|
profileAvatar: undefined,
|
|
profileKey,
|
|
profileKeyVersion: undefined,
|
|
profileKeyCredential: null,
|
|
accessKey: null,
|
|
sealedSender: SEALED_SENDER.UNKNOWN,
|
|
});
|
|
|
|
if (!viaStorageServiceSync) {
|
|
this.captureChange('profileKey');
|
|
}
|
|
|
|
await Promise.all([
|
|
this.deriveAccessKeyIfNeeded(),
|
|
this.deriveProfileKeyVersionIfNeeded(),
|
|
]);
|
|
|
|
window.Signal.Data.updateConversation(this.attributes, {
|
|
Conversation: window.Whisper.Conversation,
|
|
});
|
|
}
|
|
}
|
|
|
|
async deriveAccessKeyIfNeeded(): Promise<void> {
|
|
// isn't this already an array buffer?
|
|
const profileKey = (this.get('profileKey') as unknown) as string;
|
|
if (!profileKey) {
|
|
return;
|
|
}
|
|
if (this.get('accessKey')) {
|
|
return;
|
|
}
|
|
|
|
const profileKeyBuffer = base64ToArrayBuffer(profileKey);
|
|
const accessKeyBuffer = await deriveAccessKey(profileKeyBuffer);
|
|
const accessKey = arrayBufferToBase64(accessKeyBuffer);
|
|
this.set({ accessKey });
|
|
}
|
|
|
|
async deriveProfileKeyVersionIfNeeded(): Promise<void> {
|
|
const profileKey = this.get('profileKey');
|
|
if (!profileKey) {
|
|
return;
|
|
}
|
|
|
|
const uuid = this.get('uuid');
|
|
if (!uuid || this.get('profileKeyVersion')) {
|
|
return;
|
|
}
|
|
|
|
const profileKeyVersion = Util.zkgroup.deriveProfileKeyVersion(
|
|
profileKey,
|
|
uuid
|
|
);
|
|
|
|
this.set({ profileKeyVersion });
|
|
}
|
|
|
|
hasMember(identifier: string): boolean {
|
|
const id = window.ConversationController.getConversationId(identifier);
|
|
const memberIds = this.getMemberIds();
|
|
|
|
return window._.contains(memberIds, id);
|
|
}
|
|
|
|
fetchContacts(): void {
|
|
const members = this.getMembers();
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.contactCollection!.reset(members);
|
|
}
|
|
|
|
async destroyMessages(): Promise<void> {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.messageCollection!.reset([]);
|
|
|
|
this.set({
|
|
lastMessage: null,
|
|
timestamp: null,
|
|
active_at: null,
|
|
});
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
|
|
await window.Signal.Data.removeAllMessagesInConversation(this.id, {
|
|
logId: this.idForLogging(),
|
|
MessageCollection: window.Whisper.MessageCollection,
|
|
});
|
|
}
|
|
|
|
getTitle(): string {
|
|
if (this.isPrivate()) {
|
|
return (
|
|
this.get('name') ||
|
|
this.getProfileName() ||
|
|
this.getNumber() ||
|
|
window.i18n('unknownContact')
|
|
);
|
|
}
|
|
return this.get('name') || window.i18n('unknownGroup');
|
|
}
|
|
|
|
getProfileName(): string | undefined {
|
|
if (this.isPrivate()) {
|
|
return Util.combineNames(
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
this.get('profileName')!,
|
|
this.get('profileFamilyName')
|
|
);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
getNumber(): string {
|
|
if (!this.isPrivate()) {
|
|
return '';
|
|
}
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
const number = this.get('e164')!;
|
|
try {
|
|
const parsedNumber = window.libphonenumber.parse(number);
|
|
const regionCode = window.libphonenumber.getRegionCodeForNumber(
|
|
parsedNumber
|
|
);
|
|
if (regionCode === window.storage.get('regionCode')) {
|
|
return window.libphonenumber.format(
|
|
parsedNumber,
|
|
window.libphonenumber.PhoneNumberFormat.NATIONAL
|
|
);
|
|
}
|
|
return window.libphonenumber.format(
|
|
parsedNumber,
|
|
window.libphonenumber.PhoneNumberFormat.INTERNATIONAL
|
|
);
|
|
} catch (e) {
|
|
return number;
|
|
}
|
|
}
|
|
|
|
getInitials(name: string): string | null {
|
|
if (!name) {
|
|
return null;
|
|
}
|
|
|
|
const cleaned = name.replace(/[^A-Za-z\s]+/g, '').replace(/\s+/g, ' ');
|
|
const parts = cleaned.split(' ');
|
|
const initials = parts.map(part => part.trim()[0]);
|
|
if (!initials.length) {
|
|
return null;
|
|
}
|
|
|
|
return initials.slice(0, 2).join('');
|
|
}
|
|
|
|
isPrivate(): boolean {
|
|
return this.get('type') === 'private';
|
|
}
|
|
|
|
getColor(): ColorType {
|
|
if (!this.isPrivate()) {
|
|
return 'signal-blue';
|
|
}
|
|
|
|
return migrateColor(this.get('color'));
|
|
}
|
|
|
|
getAvatarPath(): string | undefined {
|
|
const avatar = this.isMe()
|
|
? this.get('profileAvatar') || this.get('avatar')
|
|
: this.get('avatar') || this.get('profileAvatar');
|
|
|
|
if (!avatar || !avatar.path) {
|
|
return undefined;
|
|
}
|
|
|
|
return getAbsoluteAttachmentPath(avatar.path);
|
|
}
|
|
|
|
private canChangeTimer(): boolean {
|
|
if (this.isPrivate()) {
|
|
return true;
|
|
}
|
|
|
|
if (this.isGroupV1AndDisabled()) {
|
|
return false;
|
|
}
|
|
|
|
if (!this.isGroupV2()) {
|
|
return true;
|
|
}
|
|
|
|
const accessControlEnum =
|
|
window.textsecure.protobuf.AccessControl.AccessRequired;
|
|
const accessControl = this.get('accessControl');
|
|
const canAnyoneChangeTimer =
|
|
accessControl &&
|
|
(accessControl.attributes === accessControlEnum.ANY ||
|
|
accessControl.attributes === accessControlEnum.MEMBER);
|
|
if (canAnyoneChangeTimer) {
|
|
return true;
|
|
}
|
|
|
|
return this.areWeAdmin();
|
|
}
|
|
|
|
canEditGroupInfo(): boolean {
|
|
if (!this.isGroupV2()) {
|
|
return false;
|
|
}
|
|
|
|
if (this.get('left')) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
this.areWeAdmin() ||
|
|
this.get('accessControl')?.attributes ===
|
|
window.textsecure.protobuf.AccessControl.AccessRequired.MEMBER
|
|
);
|
|
}
|
|
|
|
areWeAdmin(): boolean {
|
|
if (!this.isGroupV2()) {
|
|
return false;
|
|
}
|
|
|
|
const memberEnum = window.textsecure.protobuf.Member.Role;
|
|
const members = this.get('membersV2') || [];
|
|
const myId = window.ConversationController.getOurConversationId();
|
|
const me = members.find(item => item.conversationId === myId);
|
|
if (!me) {
|
|
return false;
|
|
}
|
|
|
|
return me.role === memberEnum.ADMINISTRATOR;
|
|
}
|
|
|
|
// Set of items to captureChanges on:
|
|
// [-] uuid
|
|
// [-] e164
|
|
// [X] profileKey
|
|
// [-] identityKey
|
|
// [X] verified!
|
|
// [-] profileName
|
|
// [-] profileFamilyName
|
|
// [X] blocked
|
|
// [X] whitelisted
|
|
// [X] archived
|
|
// [X] markedUnread
|
|
captureChange(logMessage: string): void {
|
|
if (!window.Signal.RemoteConfig.isEnabled('desktop.storageWrite3')) {
|
|
window.log.info(
|
|
'conversation.captureChange: Returning early; desktop.storageWrite3 is falsey'
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
window.log.info(
|
|
'storageService[captureChange]',
|
|
logMessage,
|
|
this.idForLogging()
|
|
);
|
|
this.set({ needsStorageServiceSync: true });
|
|
|
|
this.queueJob(() => {
|
|
Services.storageServiceUploadJob();
|
|
});
|
|
}
|
|
|
|
setMuteExpiration(
|
|
muteExpiresAt = 0,
|
|
{ viaStorageServiceSync = false } = {}
|
|
): void {
|
|
const prevExpiration = this.get('muteExpiresAt');
|
|
|
|
if (prevExpiration === muteExpiresAt) {
|
|
return;
|
|
}
|
|
|
|
// we use a timeoutId here so that we can reference the mute that was
|
|
// potentially set in the ConversationController. Specifically for a
|
|
// scenario where a conversation is already muted and we boot up the app,
|
|
// a timeout will be already set. But if we change the mute to a later
|
|
// date a new timeout would need to be set and the old one cleared. With
|
|
// this ID we can reference the existing timeout.
|
|
const timeoutId = this.getMuteTimeoutId();
|
|
window.Signal.Services.removeTimeout(timeoutId);
|
|
|
|
if (muteExpiresAt && muteExpiresAt < Number.MAX_SAFE_INTEGER) {
|
|
window.Signal.Services.onTimeout(
|
|
muteExpiresAt,
|
|
() => {
|
|
this.setMuteExpiration(0);
|
|
},
|
|
timeoutId
|
|
);
|
|
}
|
|
|
|
this.set({ muteExpiresAt });
|
|
if (!viaStorageServiceSync) {
|
|
this.captureChange('mutedUntilTimestamp');
|
|
}
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
}
|
|
|
|
isMuted(): boolean {
|
|
return isMuted(this.get('muteExpiresAt'));
|
|
}
|
|
|
|
getMuteTimeoutId(): string {
|
|
return `mute(${this.get('id')})`;
|
|
}
|
|
|
|
async notify(message: WhatIsThis, reaction?: WhatIsThis): Promise<void> {
|
|
// As a performance optimization don't perform any work if notifications are
|
|
// disabled.
|
|
if (!window.Whisper.Notifications.isEnabled) {
|
|
return;
|
|
}
|
|
|
|
if (this.isMuted()) {
|
|
return;
|
|
}
|
|
|
|
if (!message.isIncoming() && !reaction) {
|
|
return;
|
|
}
|
|
|
|
const conversationId = this.id;
|
|
|
|
const sender = reaction
|
|
? window.ConversationController.get(reaction.get('fromId'))
|
|
: message.getContact();
|
|
const senderName = sender
|
|
? sender.getTitle()
|
|
: window.i18n('unknownContact');
|
|
const senderTitle = this.isPrivate()
|
|
? senderName
|
|
: window.i18n('notificationSenderInGroup', {
|
|
sender: senderName,
|
|
group: this.getTitle(),
|
|
});
|
|
|
|
let notificationIconUrl;
|
|
const avatar = this.get('avatar') || this.get('profileAvatar');
|
|
if (avatar && avatar.path) {
|
|
notificationIconUrl = getAbsoluteAttachmentPath(avatar.path);
|
|
} else if (this.isPrivate()) {
|
|
notificationIconUrl = await this.getIdenticon();
|
|
} else {
|
|
// Not technically needed, but helps us be explicit: we don't show an icon for a
|
|
// group that doesn't have an icon.
|
|
notificationIconUrl = undefined;
|
|
}
|
|
|
|
const messageJSON = message.toJSON();
|
|
const messageId = message.id;
|
|
const isExpiringMessage = Message.hasExpiration(messageJSON);
|
|
|
|
window.Whisper.Notifications.add({
|
|
senderTitle,
|
|
conversationId,
|
|
notificationIconUrl,
|
|
isExpiringMessage,
|
|
message: message.getNotificationText(),
|
|
messageId,
|
|
reaction: reaction ? reaction.toJSON() : null,
|
|
});
|
|
}
|
|
|
|
private async getIdenticon(): Promise<string> {
|
|
const color = this.getColor();
|
|
const name = this.get('name');
|
|
|
|
const content = (name && this.getInitials(name)) || '#';
|
|
|
|
const cached = this.cachedIdenticon;
|
|
if (cached && cached.content === content && cached.color === color) {
|
|
return cached.url;
|
|
}
|
|
|
|
const fresh = await new window.Whisper.IdenticonSVGView({
|
|
color,
|
|
content,
|
|
}).getDataUrl();
|
|
|
|
this.cachedIdenticon = { content, color, url: fresh };
|
|
|
|
return fresh;
|
|
}
|
|
|
|
notifyTyping(options: {
|
|
isTyping: boolean;
|
|
senderId: string;
|
|
fromMe: boolean;
|
|
senderDevice: string;
|
|
}): void {
|
|
const { isTyping, senderId, fromMe, senderDevice } = options;
|
|
|
|
// We don't do anything with typing messages from our other devices
|
|
if (fromMe) {
|
|
return;
|
|
}
|
|
|
|
const typingToken = `${senderId}.${senderDevice}`;
|
|
|
|
this.contactTypingTimers = this.contactTypingTimers || {};
|
|
const record = this.contactTypingTimers[typingToken];
|
|
|
|
if (record) {
|
|
clearTimeout(record.timer);
|
|
}
|
|
|
|
if (isTyping) {
|
|
this.contactTypingTimers[typingToken] = this.contactTypingTimers[
|
|
typingToken
|
|
] || {
|
|
timestamp: Date.now(),
|
|
senderId,
|
|
senderDevice,
|
|
};
|
|
|
|
this.contactTypingTimers[typingToken].timer = setTimeout(
|
|
this.clearContactTypingTimer.bind(this, typingToken),
|
|
15 * 1000
|
|
);
|
|
if (!record) {
|
|
// User was not previously typing before. State change!
|
|
this.trigger('change', this);
|
|
}
|
|
} else {
|
|
delete this.contactTypingTimers[typingToken];
|
|
if (record) {
|
|
// User was previously typing, and is no longer. State change!
|
|
this.trigger('change', this);
|
|
}
|
|
}
|
|
}
|
|
|
|
clearContactTypingTimer(typingToken: string): void {
|
|
this.contactTypingTimers = this.contactTypingTimers || {};
|
|
const record = this.contactTypingTimers[typingToken];
|
|
|
|
if (record) {
|
|
clearTimeout(record.timer);
|
|
delete this.contactTypingTimers[typingToken];
|
|
|
|
// User was previously typing, but timed out or we received message. State change!
|
|
this.trigger('change', this);
|
|
}
|
|
}
|
|
|
|
getName(): string | undefined {
|
|
// eslint-disable-next-line no-useless-return
|
|
return;
|
|
}
|
|
|
|
pin(): void {
|
|
if (this.get('isPinned')) {
|
|
return;
|
|
}
|
|
|
|
window.log.info('pinning', this.idForLogging());
|
|
const pinnedConversationIds = new Set(
|
|
window.storage.get<Array<string>>('pinnedConversationIds', [])
|
|
);
|
|
|
|
pinnedConversationIds.add(this.id);
|
|
|
|
this.writePinnedConversations([...pinnedConversationIds]);
|
|
|
|
this.set('isPinned', true);
|
|
|
|
if (this.get('isArchived')) {
|
|
this.set({ isArchived: false });
|
|
}
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
}
|
|
|
|
unpin({ stopStorageSync = false } = {}): void {
|
|
if (!this.get('isPinned')) {
|
|
return;
|
|
}
|
|
|
|
window.log.info('un-pinning', this.idForLogging());
|
|
|
|
const pinnedConversationIds = new Set(
|
|
window.storage.get<Array<string>>('pinnedConversationIds', [])
|
|
);
|
|
|
|
pinnedConversationIds.delete(this.id);
|
|
|
|
if (!stopStorageSync) {
|
|
this.writePinnedConversations([...pinnedConversationIds]);
|
|
}
|
|
|
|
this.set('isPinned', false);
|
|
window.Signal.Data.updateConversation(this.attributes);
|
|
}
|
|
|
|
writePinnedConversations(pinnedConversationIds: Array<string>): void {
|
|
window.storage.put('pinnedConversationIds', pinnedConversationIds);
|
|
|
|
const myId = window.ConversationController.getOurConversationId();
|
|
const me = window.ConversationController.get(myId);
|
|
|
|
if (me) {
|
|
me.captureChange('pin');
|
|
}
|
|
}
|
|
}
|
|
|
|
window.Whisper.Conversation = ConversationModel;
|
|
|
|
window.Whisper.ConversationCollection = window.Backbone.Collection.extend({
|
|
model: window.Whisper.Conversation,
|
|
|
|
/**
|
|
* window.Backbone defines a `_byId` field. Here we set up additional `_byE164`,
|
|
* `_byUuid`, and `_byGroupId` fields so we can track conversations by more
|
|
* than just their id.
|
|
*/
|
|
initialize() {
|
|
this.eraseLookups();
|
|
this.on(
|
|
'idUpdated',
|
|
(model: WhatIsThis, idProp: string, oldValue: WhatIsThis) => {
|
|
if (oldValue) {
|
|
if (idProp === 'e164') {
|
|
delete this._byE164[oldValue];
|
|
}
|
|
if (idProp === 'uuid') {
|
|
delete this._byUuid[oldValue];
|
|
}
|
|
if (idProp === 'groupId') {
|
|
delete this._byGroupId[oldValue];
|
|
}
|
|
}
|
|
if (model.get('e164')) {
|
|
this._byE164[model.get('e164')] = model;
|
|
}
|
|
if (model.get('uuid')) {
|
|
this._byUuid[model.get('uuid')] = model;
|
|
}
|
|
if (model.get('groupId')) {
|
|
this._byGroupId[model.get('groupId')] = model;
|
|
}
|
|
}
|
|
);
|
|
},
|
|
|
|
reset(...args: Array<WhatIsThis>) {
|
|
window.Backbone.Collection.prototype.reset.apply(this, args as WhatIsThis);
|
|
this.resetLookups();
|
|
},
|
|
|
|
resetLookups() {
|
|
this.eraseLookups();
|
|
this.generateLookups(this.models);
|
|
},
|
|
|
|
generateLookups(models: Array<WhatIsThis>) {
|
|
models.forEach(model => {
|
|
const e164 = model.get('e164');
|
|
if (e164) {
|
|
const existing = this._byE164[e164];
|
|
|
|
// Prefer the contact with both e164 and uuid
|
|
if (!existing || (existing && !existing.get('uuid'))) {
|
|
this._byE164[e164] = model;
|
|
}
|
|
}
|
|
|
|
const uuid = model.get('uuid');
|
|
if (uuid) {
|
|
const existing = this._byUuid[uuid];
|
|
|
|
// Prefer the contact with both e164 and uuid
|
|
if (!existing || (existing && !existing.get('e164'))) {
|
|
this._byUuid[uuid] = model;
|
|
}
|
|
}
|
|
|
|
const groupId = model.get('groupId');
|
|
if (groupId) {
|
|
this._byGroupId[groupId] = model;
|
|
}
|
|
});
|
|
},
|
|
|
|
eraseLookups() {
|
|
this._byE164 = Object.create(null);
|
|
this._byUuid = Object.create(null);
|
|
this._byGroupId = Object.create(null);
|
|
},
|
|
|
|
add(data: WhatIsThis | Array<WhatIsThis>) {
|
|
let hydratedData;
|
|
|
|
// First, we need to ensure that the data we're working with is Conversation models
|
|
if (Array.isArray(data)) {
|
|
hydratedData = [];
|
|
for (let i = 0, max = data.length; i < max; i += 1) {
|
|
const item = data[i];
|
|
|
|
// We create a new model if it's not already a model
|
|
if (!item.get) {
|
|
hydratedData.push(new window.Whisper.Conversation(item));
|
|
} else {
|
|
hydratedData.push(item);
|
|
}
|
|
}
|
|
} else if (!data.get) {
|
|
hydratedData = new window.Whisper.Conversation(data);
|
|
} else {
|
|
hydratedData = data;
|
|
}
|
|
|
|
// Next, we update our lookups first to prevent infinite loops on the 'add' event
|
|
this.generateLookups(
|
|
Array.isArray(hydratedData) ? hydratedData : [hydratedData]
|
|
);
|
|
|
|
// Lastly, we fire off the add events related to this change
|
|
window.Backbone.Collection.prototype.add.call(this, hydratedData);
|
|
|
|
return hydratedData;
|
|
},
|
|
|
|
/**
|
|
* window.Backbone collections have a `_byId` field that `get` defers to. Here, we
|
|
* override `get` to first access our custom `_byE164`, `_byUuid`, and
|
|
* `_byGroupId` functions, followed by falling back to the original
|
|
* window.Backbone implementation.
|
|
*/
|
|
get(id: string) {
|
|
return (
|
|
this._byE164[id] ||
|
|
this._byE164[`+${id}`] ||
|
|
this._byUuid[id] ||
|
|
this._byGroupId[id] ||
|
|
window.Backbone.Collection.prototype.get.call(this, id)
|
|
);
|
|
},
|
|
|
|
comparator(m: WhatIsThis) {
|
|
return -m.get('timestamp');
|
|
},
|
|
});
|
|
|
|
window.Whisper.Conversation.COLORS = COLORS.concat(['grey', 'default']).join(
|
|
' '
|
|
);
|
|
|
|
// This is a wrapper model used to display group members in the member list view, within
|
|
// the world of backbone, but layering another bit of group-specific data top of base
|
|
// conversation data.
|
|
window.Whisper.GroupMemberConversation = window.Backbone.Model.extend({
|
|
initialize(attributes: { conversation: boolean; isAdmin: boolean }) {
|
|
const { conversation, isAdmin } = attributes;
|
|
|
|
if (!conversation) {
|
|
throw new Error(
|
|
'GroupMemberConversation.initialize: conversation required!'
|
|
);
|
|
}
|
|
if (!window._.isBoolean(isAdmin)) {
|
|
throw new Error('GroupMemberConversation.initialize: isAdmin required!');
|
|
}
|
|
|
|
// If our underlying conversation changes, we change too
|
|
this.listenTo(conversation, 'change', () => {
|
|
this.trigger('change', this);
|
|
});
|
|
|
|
this.conversation = conversation;
|
|
this.isAdmin = isAdmin;
|
|
},
|
|
|
|
format() {
|
|
return {
|
|
...this.conversation.format(),
|
|
isAdmin: this.isAdmin,
|
|
};
|
|
},
|
|
|
|
get(...params: Array<string>) {
|
|
return this.conversation.get(...params);
|
|
},
|
|
|
|
getTitle() {
|
|
return this.conversation.getTitle();
|
|
},
|
|
|
|
isMe() {
|
|
return this.conversation.isMe();
|
|
},
|
|
});
|
|
|
|
type SortableByTitle = {
|
|
getTitle: () => string;
|
|
};
|
|
|
|
const sortConversationTitles = (
|
|
left: SortableByTitle,
|
|
right: SortableByTitle,
|
|
collator: Intl.Collator
|
|
) => {
|
|
return collator.compare(left.getTitle(), right.getTitle());
|
|
};
|
|
|
|
// We need a custom collection here to get the sorting we need
|
|
window.Whisper.GroupConversationCollection = window.Backbone.Collection.extend({
|
|
model: window.Whisper.GroupMemberConversation,
|
|
|
|
initialize() {
|
|
this.collator = new Intl.Collator(undefined, { sensitivity: 'base' });
|
|
},
|
|
|
|
comparator(left: WhatIsThis, right: WhatIsThis) {
|
|
if (left.isAdmin && !right.isAdmin) {
|
|
return -1;
|
|
}
|
|
if (!left.isAdmin && right.isAdmin) {
|
|
return 1;
|
|
}
|
|
|
|
return sortConversationTitles(left, right, this.collator);
|
|
},
|
|
});
|