2022-05-23 22:21:14 +00:00
|
|
|
// Copyright 2020-2022 Signal Messenger, LLC
|
2020-10-30 20:34:04 +00:00
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
2022-08-09 23:46:01 +00:00
|
|
|
import { debounce, pick, uniq, without } from 'lodash';
|
2021-01-29 22:16:48 +00:00
|
|
|
import PQueue from 'p-queue';
|
|
|
|
|
2021-10-26 19:15:33 +00:00
|
|
|
import type {
|
2020-07-10 18:28:49 +00:00
|
|
|
ConversationModelCollectionType,
|
2021-09-10 02:38:11 +00:00
|
|
|
ConversationAttributesType,
|
2020-09-24 20:57:54 +00:00
|
|
|
ConversationAttributesTypeType,
|
2020-07-10 18:28:49 +00:00
|
|
|
} from './model-types.d';
|
2021-10-26 19:15:33 +00:00
|
|
|
import type { ConversationModel } from './models/conversations';
|
2022-08-09 23:46:01 +00:00
|
|
|
import type { MessageModel } from './models/messages';
|
|
|
|
import type { UUIDStringType } from './types/UUID';
|
|
|
|
|
|
|
|
import dataInterface from './sql/Client';
|
|
|
|
import * as log from './logging/log';
|
|
|
|
import * as Errors from './types/errors';
|
2021-12-10 22:51:54 +00:00
|
|
|
import { getContactId } from './messages/helpers';
|
2020-11-20 17:30:45 +00:00
|
|
|
import { maybeDeriveGroupV2Id } from './groups';
|
2022-09-15 19:17:15 +00:00
|
|
|
import { assertDev, strictAssert } from './util/assert';
|
2021-06-07 16:39:13 +00:00
|
|
|
import { isGroupV1, isGroupV2 } from './util/whatTypeOfConversation';
|
2022-05-23 22:21:14 +00:00
|
|
|
import { getConversationUnreadCountForAppBadge } from './util/getConversationUnreadCountForAppBadge';
|
2022-08-09 21:39:00 +00:00
|
|
|
import { UUID, isValidUuid, UUIDKind } from './types/UUID';
|
2021-09-10 02:38:11 +00:00
|
|
|
import { Address } from './types/Address';
|
|
|
|
import { QualifiedAddress } from './types/QualifiedAddress';
|
2022-01-25 17:44:45 +00:00
|
|
|
import { sleep } from './util/sleep';
|
2022-01-26 23:39:25 +00:00
|
|
|
import { isNotNil } from './util/isNotNil';
|
2022-06-27 16:46:43 +00:00
|
|
|
import { MINUTE, SECOND } from './util/durations';
|
2022-08-18 20:44:53 +00:00
|
|
|
import { getUuidsForE164s } from './util/getUuidsForE164s';
|
2022-11-09 21:11:45 +00:00
|
|
|
import { SIGNAL_ACI, SIGNAL_AVATAR_PATH } from './types/SignalConversation';
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
type ConvoMatchType =
|
|
|
|
| {
|
|
|
|
key: 'uuid' | 'pni';
|
|
|
|
value: UUIDStringType | undefined;
|
|
|
|
match: ConversationModel | undefined;
|
|
|
|
}
|
|
|
|
| {
|
|
|
|
key: 'e164';
|
|
|
|
value: string | undefined;
|
|
|
|
match: ConversationModel | undefined;
|
|
|
|
};
|
|
|
|
|
|
|
|
const { hasOwnProperty } = Object.prototype;
|
|
|
|
|
|
|
|
function applyChangeToConversation(
|
|
|
|
conversation: ConversationModel,
|
|
|
|
suggestedChange: Partial<
|
|
|
|
Pick<ConversationAttributesType, 'uuid' | 'e164' | 'pni'>
|
|
|
|
>
|
|
|
|
) {
|
|
|
|
const change = { ...suggestedChange };
|
|
|
|
|
|
|
|
// Clear PNI if changing e164 without associated PNI
|
|
|
|
if (hasOwnProperty.call(change, 'e164') && !change.pni) {
|
|
|
|
change.pni = undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
// If we have a PNI but not an ACI, then the PNI will go in the UUID field
|
|
|
|
// Tricky: We need a special check here, because the PNI can be in the uuid slot
|
|
|
|
if (
|
|
|
|
change.pni &&
|
|
|
|
!change.uuid &&
|
|
|
|
(!conversation.get('uuid') ||
|
|
|
|
conversation.get('uuid') === conversation.get('pni'))
|
|
|
|
) {
|
|
|
|
change.uuid = change.pni;
|
|
|
|
}
|
|
|
|
|
|
|
|
// If we're clearing a PNI, but we didn't have an ACI - we need to clear UUID field
|
|
|
|
if (
|
|
|
|
!change.uuid &&
|
|
|
|
hasOwnProperty.call(change, 'pni') &&
|
|
|
|
!change.pni &&
|
|
|
|
conversation.get('uuid') === conversation.get('pni')
|
|
|
|
) {
|
|
|
|
change.uuid = undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (hasOwnProperty.call(change, 'uuid')) {
|
|
|
|
conversation.updateUuid(change.uuid);
|
|
|
|
}
|
|
|
|
if (hasOwnProperty.call(change, 'e164')) {
|
|
|
|
conversation.updateE164(change.e164);
|
|
|
|
}
|
|
|
|
if (hasOwnProperty.call(change, 'pni')) {
|
|
|
|
conversation.updatePni(change.pni);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Note: we don't do a conversation.set here, because change is limited to these fields
|
|
|
|
}
|
|
|
|
|
2022-08-09 23:46:01 +00:00
|
|
|
async function safeCombineConversations({
|
2022-08-09 21:39:00 +00:00
|
|
|
logId,
|
|
|
|
oldConversation,
|
|
|
|
newConversation,
|
|
|
|
}: {
|
|
|
|
logId: string;
|
|
|
|
oldConversation: ConversationModel;
|
|
|
|
newConversation: ConversationModel;
|
|
|
|
}) {
|
|
|
|
try {
|
|
|
|
await window.ConversationController.combineConversations(
|
|
|
|
newConversation,
|
|
|
|
oldConversation
|
|
|
|
);
|
|
|
|
} catch (error) {
|
|
|
|
log.warn(
|
|
|
|
`${logId}: error combining contacts: ${Errors.toLogFormat(error)}`
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-09-11 19:37:01 +00:00
|
|
|
const MAX_MESSAGE_BODY_LENGTH = 64 * 1024;
|
|
|
|
|
2020-07-10 18:28:49 +00:00
|
|
|
const {
|
|
|
|
getAllConversations,
|
2021-10-26 22:59:08 +00:00
|
|
|
getAllGroupsInvolvingUuid,
|
2020-07-10 18:28:49 +00:00
|
|
|
getMessagesBySentAt,
|
|
|
|
migrateConversationMessages,
|
|
|
|
removeConversation,
|
|
|
|
saveConversation,
|
|
|
|
updateConversation,
|
|
|
|
} = dataInterface;
|
|
|
|
|
|
|
|
// We have to run this in background.js, after all backbone models and collections on
|
|
|
|
// Whisper.* have been created. Once those are in typescript we can use more reasonable
|
|
|
|
// require statements for referencing these things, giving us more flexibility here.
|
2020-09-11 19:37:01 +00:00
|
|
|
export function start(): void {
|
2020-07-10 18:28:49 +00:00
|
|
|
const conversations = new window.Whisper.ConversationCollection();
|
|
|
|
|
|
|
|
window.ConversationController = new ConversationController(conversations);
|
2022-11-09 02:38:19 +00:00
|
|
|
window.getConversations = () => conversations;
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
export class ConversationController {
|
2021-11-04 21:11:47 +00:00
|
|
|
private _initialFetchComplete = false;
|
2020-09-11 19:37:01 +00:00
|
|
|
|
2021-11-04 21:11:47 +00:00
|
|
|
private _initialPromise: undefined | Promise<void>;
|
2020-09-11 19:37:01 +00:00
|
|
|
|
2022-01-20 00:40:29 +00:00
|
|
|
private _conversationOpenStart = new Map<string, number>();
|
|
|
|
|
2022-06-01 01:26:57 +00:00
|
|
|
private _hasQueueEmptied = false;
|
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
private _combineConversationsQueue = new PQueue({ concurrency: 1 });
|
|
|
|
|
2022-11-09 02:38:19 +00:00
|
|
|
private _signalConversationId: undefined | string;
|
|
|
|
|
2022-06-01 01:26:57 +00:00
|
|
|
constructor(private _conversations: ConversationModelCollectionType) {
|
|
|
|
const debouncedUpdateUnreadCount = debounce(
|
|
|
|
this.updateUnreadCount.bind(this),
|
|
|
|
SECOND,
|
|
|
|
{
|
|
|
|
leading: true,
|
|
|
|
maxWait: SECOND,
|
|
|
|
trailing: true,
|
|
|
|
}
|
|
|
|
);
|
|
|
|
|
|
|
|
// A few things can cause us to update the app-level unread count
|
|
|
|
window.Whisper.events.on('updateUnreadCount', debouncedUpdateUnreadCount);
|
|
|
|
this._conversations.on(
|
|
|
|
'add remove change:active_at change:unreadCount change:markedUnread change:isArchived change:muteExpiresAt',
|
|
|
|
debouncedUpdateUnreadCount
|
|
|
|
);
|
|
|
|
|
|
|
|
// If the conversation is muted we set a timeout so when the mute expires
|
|
|
|
// we can reset the mute state on the model. If the mute has already expired
|
|
|
|
// then we reset the state right away.
|
|
|
|
this._conversations.on('add', (model: ConversationModel): void => {
|
|
|
|
model.startMuteTimer();
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
updateUnreadCount(): void {
|
|
|
|
if (!this._hasQueueEmptied) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const canCountMutedConversations =
|
|
|
|
window.storage.get('badge-count-muted-conversations') || false;
|
|
|
|
|
|
|
|
const newUnreadCount = this._conversations.reduce(
|
|
|
|
(result: number, conversation: ConversationModel) =>
|
|
|
|
result +
|
|
|
|
getConversationUnreadCountForAppBadge(
|
|
|
|
conversation.attributes,
|
|
|
|
canCountMutedConversations
|
|
|
|
),
|
|
|
|
0
|
|
|
|
);
|
|
|
|
window.storage.put('unreadCount', newUnreadCount);
|
|
|
|
|
|
|
|
if (newUnreadCount > 0) {
|
|
|
|
window.setBadgeCount(newUnreadCount);
|
|
|
|
window.document.title = `${window.getTitle()} (${newUnreadCount})`;
|
|
|
|
} else {
|
|
|
|
window.setBadgeCount(0);
|
|
|
|
window.document.title = window.getTitle();
|
|
|
|
}
|
|
|
|
window.updateTrayIcon(newUnreadCount);
|
|
|
|
}
|
|
|
|
|
|
|
|
onEmpty(): void {
|
|
|
|
this._hasQueueEmptied = true;
|
|
|
|
this.updateUnreadCount();
|
|
|
|
}
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2020-09-24 20:57:54 +00:00
|
|
|
get(id?: string | null): ConversationModel | undefined {
|
2020-07-10 18:28:49 +00:00
|
|
|
if (!this._initialFetchComplete) {
|
|
|
|
throw new Error(
|
|
|
|
'ConversationController.get() needs complete initial fetch'
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
// This function takes null just fine. Backbone typings are too restrictive.
|
|
|
|
return this._conversations.get(id as string);
|
|
|
|
}
|
2020-09-11 19:37:01 +00:00
|
|
|
|
2021-03-18 17:09:27 +00:00
|
|
|
getAll(): Array<ConversationModel> {
|
|
|
|
return this._conversations.models;
|
|
|
|
}
|
|
|
|
|
2020-09-11 19:37:01 +00:00
|
|
|
dangerouslyCreateAndAdd(
|
2021-09-10 02:38:11 +00:00
|
|
|
attributes: Partial<ConversationAttributesType>
|
2020-09-24 20:57:54 +00:00
|
|
|
): ConversationModel {
|
2020-07-10 18:28:49 +00:00
|
|
|
return this._conversations.add(attributes);
|
|
|
|
}
|
2020-09-11 19:37:01 +00:00
|
|
|
|
2021-01-29 22:16:48 +00:00
|
|
|
dangerouslyRemoveById(id: string): void {
|
|
|
|
this._conversations.remove(id);
|
|
|
|
this._conversations.resetLookups();
|
|
|
|
}
|
|
|
|
|
2020-07-10 18:28:49 +00:00
|
|
|
getOrCreate(
|
2020-09-24 20:57:54 +00:00
|
|
|
identifier: string | null,
|
|
|
|
type: ConversationAttributesTypeType,
|
2020-07-10 18:28:49 +00:00
|
|
|
additionalInitialProps = {}
|
2020-09-24 20:57:54 +00:00
|
|
|
): ConversationModel {
|
2020-07-10 18:28:49 +00:00
|
|
|
if (typeof identifier !== 'string') {
|
|
|
|
throw new TypeError("'id' must be a string");
|
|
|
|
}
|
|
|
|
|
|
|
|
if (type !== 'private' && type !== 'group') {
|
|
|
|
throw new TypeError(
|
|
|
|
`'type' must be 'private' or 'group'; got: '${type}'`
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!this._initialFetchComplete) {
|
|
|
|
throw new Error(
|
|
|
|
'ConversationController.get() needs complete initial fetch'
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
let conversation = this._conversations.get(identifier);
|
|
|
|
if (conversation) {
|
|
|
|
return conversation;
|
|
|
|
}
|
|
|
|
|
2021-10-26 22:59:08 +00:00
|
|
|
const id = UUID.generate().toString();
|
2020-07-10 18:28:49 +00:00
|
|
|
|
|
|
|
if (type === 'group') {
|
|
|
|
conversation = this._conversations.add({
|
|
|
|
id,
|
2022-08-09 21:39:00 +00:00
|
|
|
uuid: undefined,
|
|
|
|
e164: undefined,
|
2020-07-10 18:28:49 +00:00
|
|
|
groupId: identifier,
|
|
|
|
type,
|
|
|
|
version: 2,
|
|
|
|
...additionalInitialProps,
|
|
|
|
});
|
2021-10-26 22:59:08 +00:00
|
|
|
} else if (isValidUuid(identifier)) {
|
2020-07-10 18:28:49 +00:00
|
|
|
conversation = this._conversations.add({
|
|
|
|
id,
|
|
|
|
uuid: identifier,
|
2022-08-09 21:39:00 +00:00
|
|
|
e164: undefined,
|
|
|
|
groupId: undefined,
|
2020-07-10 18:28:49 +00:00
|
|
|
type,
|
|
|
|
version: 2,
|
|
|
|
...additionalInitialProps,
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
conversation = this._conversations.add({
|
|
|
|
id,
|
2022-08-09 21:39:00 +00:00
|
|
|
uuid: undefined,
|
2020-07-10 18:28:49 +00:00
|
|
|
e164: identifier,
|
2022-08-09 21:39:00 +00:00
|
|
|
groupId: undefined,
|
2020-07-10 18:28:49 +00:00
|
|
|
type,
|
|
|
|
version: 2,
|
|
|
|
...additionalInitialProps,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
const create = async () => {
|
|
|
|
if (!conversation.isValid()) {
|
|
|
|
const validationError = conversation.validationError || {};
|
2021-09-17 18:27:53 +00:00
|
|
|
log.error(
|
2020-07-10 18:28:49 +00:00
|
|
|
'Contact is not valid. Not saving, but adding to collection:',
|
|
|
|
conversation.idForLogging(),
|
2022-11-22 18:43:43 +00:00
|
|
|
Errors.toLogFormat(validationError)
|
2020-07-10 18:28:49 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
return conversation;
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
2021-06-07 16:39:13 +00:00
|
|
|
if (isGroupV1(conversation.attributes)) {
|
2021-09-24 00:49:05 +00:00
|
|
|
maybeDeriveGroupV2Id(conversation);
|
2020-11-20 17:30:45 +00:00
|
|
|
}
|
2020-07-10 18:28:49 +00:00
|
|
|
await saveConversation(conversation.attributes);
|
|
|
|
} catch (error) {
|
2021-09-17 18:27:53 +00:00
|
|
|
log.error(
|
2020-07-10 18:28:49 +00:00
|
|
|
'Conversation save failed! ',
|
|
|
|
identifier,
|
|
|
|
type,
|
|
|
|
'Error:',
|
2022-11-22 18:43:43 +00:00
|
|
|
Errors.toLogFormat(error)
|
2020-07-10 18:28:49 +00:00
|
|
|
);
|
|
|
|
throw error;
|
|
|
|
}
|
|
|
|
|
|
|
|
return conversation;
|
|
|
|
};
|
|
|
|
|
|
|
|
conversation.initialPromise = create();
|
|
|
|
|
|
|
|
return conversation;
|
|
|
|
}
|
2020-09-11 19:37:01 +00:00
|
|
|
|
2020-07-10 18:28:49 +00:00
|
|
|
async getOrCreateAndWait(
|
2020-09-24 20:57:54 +00:00
|
|
|
id: string | null,
|
|
|
|
type: ConversationAttributesTypeType,
|
2020-07-10 18:28:49 +00:00
|
|
|
additionalInitialProps = {}
|
2020-09-24 20:57:54 +00:00
|
|
|
): Promise<ConversationModel> {
|
2021-11-04 21:11:47 +00:00
|
|
|
await this.load();
|
2020-09-11 19:37:01 +00:00
|
|
|
const conversation = this.getOrCreate(id, type, additionalInitialProps);
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2020-09-11 19:37:01 +00:00
|
|
|
if (conversation) {
|
|
|
|
await conversation.initialPromise;
|
|
|
|
return conversation;
|
|
|
|
}
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2020-09-11 19:37:01 +00:00
|
|
|
throw new Error('getOrCreateAndWait: did not get conversation');
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
2020-09-11 19:37:01 +00:00
|
|
|
|
2020-09-24 20:57:54 +00:00
|
|
|
getConversationId(address: string | null): string | null {
|
2020-07-10 18:28:49 +00:00
|
|
|
if (!address) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
const [id] = window.textsecure.utils.unencodeNumber(address);
|
|
|
|
const conv = this.get(id);
|
|
|
|
|
|
|
|
if (conv) {
|
|
|
|
return conv.get('id');
|
|
|
|
}
|
|
|
|
|
|
|
|
return null;
|
|
|
|
}
|
2020-09-11 19:37:01 +00:00
|
|
|
|
2020-09-09 02:25:05 +00:00
|
|
|
getOurConversationId(): string | undefined {
|
2020-07-10 18:28:49 +00:00
|
|
|
const e164 = window.textsecure.storage.user.getNumber();
|
2022-08-09 21:39:00 +00:00
|
|
|
const aci = window.textsecure.storage.user
|
|
|
|
.getUuid(UUIDKind.ACI)
|
|
|
|
?.toString();
|
|
|
|
const pni = window.textsecure.storage.user
|
|
|
|
.getUuid(UUIDKind.PNI)
|
|
|
|
?.toString();
|
|
|
|
|
|
|
|
if (!e164 && !aci && !pni) {
|
|
|
|
return undefined;
|
|
|
|
}
|
|
|
|
|
|
|
|
const conversation = this.maybeMergeContacts({
|
|
|
|
aci,
|
2022-01-20 22:44:25 +00:00
|
|
|
e164,
|
2022-08-09 21:39:00 +00:00
|
|
|
pni,
|
2022-01-20 22:44:25 +00:00
|
|
|
reason: 'getOurConversationId',
|
|
|
|
});
|
2022-08-09 21:39:00 +00:00
|
|
|
|
|
|
|
return conversation?.id;
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
2020-09-11 19:37:01 +00:00
|
|
|
|
2021-01-29 22:16:48 +00:00
|
|
|
getOurConversationIdOrThrow(): string {
|
|
|
|
const conversationId = this.getOurConversationId();
|
|
|
|
if (!conversationId) {
|
|
|
|
throw new Error(
|
|
|
|
'getOurConversationIdOrThrow: Failed to fetch ourConversationId'
|
|
|
|
);
|
|
|
|
}
|
|
|
|
return conversationId;
|
|
|
|
}
|
|
|
|
|
2021-07-30 16:43:16 +00:00
|
|
|
getOurConversation(): ConversationModel | undefined {
|
|
|
|
const conversationId = this.getOurConversationId();
|
|
|
|
return conversationId ? this.get(conversationId) : undefined;
|
|
|
|
}
|
|
|
|
|
2021-07-15 23:48:09 +00:00
|
|
|
getOurConversationOrThrow(): ConversationModel {
|
2021-07-30 16:43:16 +00:00
|
|
|
const conversation = this.getOurConversation();
|
2021-07-15 23:48:09 +00:00
|
|
|
if (!conversation) {
|
|
|
|
throw new Error(
|
|
|
|
'getOurConversationOrThrow: Failed to fetch our own conversation'
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
return conversation;
|
|
|
|
}
|
|
|
|
|
2022-11-09 21:11:45 +00:00
|
|
|
async getOrCreateSignalConversation(): Promise<ConversationModel> {
|
|
|
|
const conversation = await this.getOrCreateAndWait(SIGNAL_ACI, 'private', {
|
2022-11-09 02:38:19 +00:00
|
|
|
muteExpiresAt: Number.MAX_SAFE_INTEGER,
|
|
|
|
profileAvatar: { path: SIGNAL_AVATAR_PATH },
|
|
|
|
profileName: 'Signal',
|
|
|
|
profileSharing: true,
|
|
|
|
});
|
|
|
|
|
2022-11-17 17:35:27 +00:00
|
|
|
if (conversation.get('profileAvatar')?.path !== SIGNAL_AVATAR_PATH) {
|
|
|
|
conversation.set({
|
|
|
|
profileAvatar: { hash: SIGNAL_AVATAR_PATH, path: SIGNAL_AVATAR_PATH },
|
|
|
|
});
|
|
|
|
updateConversation(conversation.attributes);
|
|
|
|
}
|
|
|
|
|
2022-11-09 02:38:19 +00:00
|
|
|
this._signalConversationId = conversation.id;
|
|
|
|
|
|
|
|
return conversation;
|
|
|
|
}
|
|
|
|
|
|
|
|
isSignalConversation(uuidOrId: string): boolean {
|
|
|
|
if (uuidOrId === SIGNAL_ACI) {
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
2022-11-09 21:11:45 +00:00
|
|
|
return this._signalConversationId === uuidOrId;
|
2022-11-09 02:38:19 +00:00
|
|
|
}
|
|
|
|
|
2021-07-15 23:48:09 +00:00
|
|
|
areWePrimaryDevice(): boolean {
|
|
|
|
const ourDeviceId = window.textsecure.storage.user.getDeviceId();
|
|
|
|
|
|
|
|
return ourDeviceId === 1;
|
|
|
|
}
|
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
// Note: If you don't know what kind of UUID it is, put it in the 'aci' param.
|
|
|
|
maybeMergeContacts({
|
|
|
|
aci: providedAci,
|
2020-07-10 18:28:49 +00:00
|
|
|
e164,
|
2022-08-09 21:39:00 +00:00
|
|
|
pni: providedPni,
|
2022-01-20 22:44:25 +00:00
|
|
|
reason,
|
2022-08-09 23:46:01 +00:00
|
|
|
mergeOldAndNew = safeCombineConversations,
|
2022-08-09 21:39:00 +00:00
|
|
|
}: {
|
|
|
|
aci?: string;
|
|
|
|
e164?: string;
|
|
|
|
pni?: string;
|
|
|
|
reason: string;
|
|
|
|
recursionCount?: number;
|
|
|
|
mergeOldAndNew?: (options: {
|
|
|
|
logId: string;
|
|
|
|
oldConversation: ConversationModel;
|
|
|
|
newConversation: ConversationModel;
|
|
|
|
}) => Promise<void>;
|
|
|
|
}): ConversationModel | undefined {
|
|
|
|
const dataProvided = [];
|
|
|
|
if (providedAci) {
|
|
|
|
dataProvided.push('aci');
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
2022-08-09 21:39:00 +00:00
|
|
|
if (e164) {
|
|
|
|
dataProvided.push('e164');
|
|
|
|
}
|
|
|
|
if (providedPni) {
|
|
|
|
dataProvided.push('pni');
|
|
|
|
}
|
|
|
|
const logId = `maybeMergeContacts/${reason}/${dataProvided.join('+')}`;
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
const aci = providedAci ? UUID.cast(providedAci) : undefined;
|
|
|
|
const pni = providedPni ? UUID.cast(providedPni) : undefined;
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
if (!aci && !e164 && !pni) {
|
|
|
|
throw new Error(
|
|
|
|
`${logId}: Need to provide at least one of: aci, e164, pni`
|
2022-01-20 22:44:25 +00:00
|
|
|
);
|
2022-08-09 21:39:00 +00:00
|
|
|
}
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
const matches: Array<ConvoMatchType> = [
|
|
|
|
{
|
|
|
|
key: 'uuid',
|
|
|
|
value: aci,
|
|
|
|
match: window.ConversationController.get(aci),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
key: 'e164',
|
|
|
|
value: e164,
|
|
|
|
match: window.ConversationController.get(e164),
|
|
|
|
},
|
|
|
|
{ key: 'pni', value: pni, match: window.ConversationController.get(pni) },
|
|
|
|
];
|
|
|
|
let unusedMatches: Array<ConvoMatchType> = [];
|
|
|
|
|
|
|
|
let targetConversation: ConversationModel | undefined;
|
|
|
|
let matchCount = 0;
|
|
|
|
matches.forEach(item => {
|
|
|
|
const { key, value, match } = item;
|
|
|
|
|
|
|
|
if (!value) {
|
|
|
|
return;
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
if (!match) {
|
|
|
|
if (targetConversation) {
|
2022-01-20 22:44:25 +00:00
|
|
|
log.info(
|
2022-08-09 21:39:00 +00:00
|
|
|
`${logId}: No match for ${key}, applying to target conversation`
|
2022-01-20 22:44:25 +00:00
|
|
|
);
|
2022-08-09 21:39:00 +00:00
|
|
|
// Note: This line might erase a known e164 or PNI
|
|
|
|
applyChangeToConversation(targetConversation, {
|
|
|
|
[key]: value,
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
unusedMatches.push(item);
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
2022-08-09 21:39:00 +00:00
|
|
|
return;
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
matchCount += 1;
|
|
|
|
unusedMatches.forEach(unused => {
|
|
|
|
strictAssert(unused.value, 'An unused value should always be truthy');
|
|
|
|
|
|
|
|
// Example: If we find that our PNI match has no ACI, then it will be our target.
|
|
|
|
// Tricky: PNI can end up in UUID slot, so we need to special-case it
|
|
|
|
if (
|
|
|
|
!targetConversation &&
|
|
|
|
(!match.get(unused.key) ||
|
|
|
|
(unused.key === 'uuid' && match.get(unused.key) === pni))
|
|
|
|
) {
|
|
|
|
log.info(
|
|
|
|
`${logId}: Match on ${key} does not have ${unused.key}, ` +
|
|
|
|
`so it will be our target conversation - ${match.idForLogging()}`
|
|
|
|
);
|
|
|
|
targetConversation = match;
|
|
|
|
}
|
|
|
|
|
|
|
|
// If PNI match already has an ACI, then we need to create a new one
|
|
|
|
if (!targetConversation) {
|
|
|
|
targetConversation = this.getOrCreate(unused.value, 'private');
|
|
|
|
log.info(
|
|
|
|
`${logId}: Match on ${key} already had ${unused.key}, ` +
|
|
|
|
`so created new target conversation - ${targetConversation.idForLogging()}`
|
|
|
|
);
|
|
|
|
}
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-01-20 22:44:25 +00:00
|
|
|
log.info(
|
2022-08-09 21:39:00 +00:00
|
|
|
`${logId}: Applying new value for ${unused.key} to target conversation`
|
2022-01-20 22:44:25 +00:00
|
|
|
);
|
2022-08-09 21:39:00 +00:00
|
|
|
applyChangeToConversation(targetConversation, {
|
|
|
|
[unused.key]: unused.value,
|
|
|
|
});
|
|
|
|
});
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
unusedMatches = [];
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
if (targetConversation && targetConversation !== match) {
|
|
|
|
// Clear the value on the current match, since it belongs on targetConversation!
|
|
|
|
// Note: we need to do the remove first, because it will clear the lookup!
|
2022-01-20 22:44:25 +00:00
|
|
|
log.info(
|
2022-08-09 21:39:00 +00:00
|
|
|
`${logId}: Clearing ${key} on match, and adding it to target conversation`
|
2022-01-20 22:44:25 +00:00
|
|
|
);
|
2022-08-09 21:39:00 +00:00
|
|
|
const change: Pick<
|
|
|
|
Partial<ConversationAttributesType>,
|
|
|
|
'uuid' | 'e164' | 'pni'
|
|
|
|
> = {
|
|
|
|
[key]: undefined,
|
|
|
|
};
|
|
|
|
// When the PNI is being used in the uuid field alone, we need to clear it
|
|
|
|
if (key === 'pni' && match.get('uuid') === pni) {
|
|
|
|
change.uuid = undefined;
|
|
|
|
}
|
|
|
|
applyChangeToConversation(match, change);
|
|
|
|
|
|
|
|
applyChangeToConversation(targetConversation, {
|
|
|
|
[key]: value,
|
|
|
|
});
|
|
|
|
|
|
|
|
// Note: The PNI check here is just to be bulletproof; if we know a UUID is a PNI,
|
|
|
|
// then that should be put in the UUID field as well!
|
|
|
|
if (!match.get('uuid') && !match.get('e164') && !match.get('pni')) {
|
|
|
|
log.warn(
|
|
|
|
`${logId}: Removing old conversation which matched on ${key}. ` +
|
|
|
|
'Merging with target conversation.'
|
|
|
|
);
|
|
|
|
mergeOldAndNew({
|
|
|
|
logId,
|
|
|
|
oldConversation: match,
|
|
|
|
newConversation: targetConversation,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
} else if (targetConversation && !targetConversation?.get(key)) {
|
|
|
|
// This is mostly for the situation where PNI was erased when updating e164
|
2022-08-09 23:46:01 +00:00
|
|
|
log.debug(`${logId}: Re-adding ${key} on target conversation`);
|
2022-08-09 21:39:00 +00:00
|
|
|
applyChangeToConversation(targetConversation, {
|
|
|
|
[key]: value,
|
|
|
|
});
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
if (!targetConversation) {
|
|
|
|
// log.debug(
|
|
|
|
// `${logId}: Match on ${key} is target conversation - ${match.idForLogging()}`
|
|
|
|
// );
|
|
|
|
targetConversation = match;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
if (targetConversation) {
|
|
|
|
return targetConversation;
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
strictAssert(
|
|
|
|
matchCount === 0,
|
|
|
|
`${logId}: should be no matches if no targetConversation`
|
|
|
|
);
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
log.info(`${logId}: Creating a new conversation with all inputs`);
|
2022-08-17 23:12:38 +00:00
|
|
|
|
|
|
|
// This is not our precedence for lookup, but it ensures that the PNI gets into the
|
|
|
|
// uuid slot if we have no ACI.
|
|
|
|
const identifier = aci || pni || e164;
|
|
|
|
strictAssert(identifier, `${logId}: identifier must be truthy!`);
|
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
return this.getOrCreate(identifier, 'private', { e164, pni });
|
|
|
|
}
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
/**
|
|
|
|
* Given a UUID and/or an E164, returns a string representing the local
|
|
|
|
* database id of the given contact. Will create a new conversation if none exists;
|
|
|
|
* otherwise will return whatever is found.
|
|
|
|
*/
|
|
|
|
lookupOrCreate({
|
|
|
|
e164,
|
|
|
|
uuid,
|
|
|
|
}: {
|
|
|
|
e164?: string | null;
|
|
|
|
uuid?: string | null;
|
|
|
|
}): ConversationModel | undefined {
|
|
|
|
const normalizedUuid = uuid ? uuid.toLowerCase() : undefined;
|
|
|
|
const identifier = normalizedUuid || e164;
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
if ((!e164 && !uuid) || !identifier) {
|
|
|
|
log.warn('lookupOrCreate: Called with neither e164 nor uuid!');
|
|
|
|
return undefined;
|
|
|
|
}
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
const convoE164 = this.get(e164);
|
|
|
|
const convoUuid = this.get(normalizedUuid);
|
|
|
|
|
|
|
|
// 1. Handle no match at all
|
|
|
|
if (!convoE164 && !convoUuid) {
|
|
|
|
log.info('lookupOrCreate: Creating new contact, no matches found');
|
|
|
|
const newConvo = this.getOrCreate(identifier, 'private');
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
// `identifier` would resolve to uuid if we had both, so fix up e164
|
|
|
|
if (normalizedUuid && e164) {
|
|
|
|
newConvo.updateE164(e164);
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
return newConvo;
|
|
|
|
}
|
|
|
|
|
|
|
|
// 2. Handle match on only UUID
|
|
|
|
if (!convoE164 && convoUuid) {
|
|
|
|
return convoUuid;
|
|
|
|
}
|
|
|
|
|
|
|
|
// 3. Handle match on only E164
|
|
|
|
if (convoE164 && !convoUuid) {
|
|
|
|
return convoE164;
|
|
|
|
}
|
|
|
|
|
|
|
|
// For some reason, TypeScript doesn't believe that we can trust that these two values
|
|
|
|
// are truthy by this point. So we'll throw if that isn't the case.
|
|
|
|
if (!convoE164 || !convoUuid) {
|
|
|
|
throw new Error(
|
|
|
|
'lookupOrCreate: convoE164 or convoUuid are falsey but should both be true!'
|
2020-07-10 18:28:49 +00:00
|
|
|
);
|
2022-08-09 21:39:00 +00:00
|
|
|
}
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
// 4. If the two lookups agree, return that conversation
|
|
|
|
if (convoE164 === convoUuid) {
|
|
|
|
return convoUuid;
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
// 5. If the two lookups disagree, log and return the UUID match
|
|
|
|
log.warn(
|
|
|
|
`lookupOrCreate: Found a split contact - UUID ${normalizedUuid} and E164 ${e164}. Returning UUID match.`
|
|
|
|
);
|
|
|
|
return convoUuid;
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
2020-09-11 19:37:01 +00:00
|
|
|
|
2022-09-13 16:16:01 +00:00
|
|
|
checkForConflicts(): Promise<void> {
|
|
|
|
return this._combineConversationsQueue.add(() =>
|
|
|
|
this.doCheckForConflicts()
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Note: `doCombineConversations` is used within this function since both
|
|
|
|
// run on `_combineConversationsQueue` queue and we don't want deadlocks.
|
|
|
|
private async doCheckForConflicts(): Promise<void> {
|
2021-09-17 18:27:53 +00:00
|
|
|
log.info('checkForConflicts: starting...');
|
2020-07-10 18:28:49 +00:00
|
|
|
const byUuid = Object.create(null);
|
|
|
|
const byE164 = Object.create(null);
|
2021-03-15 21:44:59 +00:00
|
|
|
const byGroupV2Id = Object.create(null);
|
|
|
|
// We also want to find duplicate GV1 IDs. You might expect to see a "byGroupV1Id" map
|
|
|
|
// here. Instead, we check for duplicates on the derived GV2 ID.
|
|
|
|
|
|
|
|
const { models } = this._conversations;
|
2020-07-10 18:28:49 +00:00
|
|
|
|
|
|
|
// We iterate from the oldest conversations to the newest. This allows us, in a
|
|
|
|
// conflict case, to keep the one with activity the most recently.
|
2021-03-15 21:44:59 +00:00
|
|
|
for (let i = models.length - 1; i >= 0; i -= 1) {
|
2020-07-10 18:28:49 +00:00
|
|
|
const conversation = models[i];
|
2022-09-15 19:17:15 +00:00
|
|
|
assertDev(
|
2021-03-15 21:44:59 +00:00
|
|
|
conversation,
|
|
|
|
'Expected conversation to be found in array during iteration'
|
|
|
|
);
|
|
|
|
|
2020-07-10 18:28:49 +00:00
|
|
|
const uuid = conversation.get('uuid');
|
2022-08-09 21:39:00 +00:00
|
|
|
const pni = conversation.get('pni');
|
2020-07-10 18:28:49 +00:00
|
|
|
const e164 = conversation.get('e164');
|
|
|
|
|
|
|
|
if (uuid) {
|
|
|
|
const existing = byUuid[uuid];
|
|
|
|
if (!existing) {
|
|
|
|
byUuid[uuid] = conversation;
|
|
|
|
} else {
|
2021-09-17 18:27:53 +00:00
|
|
|
log.warn(`checkForConflicts: Found conflict with uuid ${uuid}`);
|
2020-07-10 18:28:49 +00:00
|
|
|
|
|
|
|
// Keep the newer one if it has an e164, otherwise keep existing
|
|
|
|
if (conversation.get('e164')) {
|
|
|
|
// Keep new one
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
2022-09-13 16:16:01 +00:00
|
|
|
await this.doCombineConversations(conversation, existing);
|
2020-07-10 18:28:49 +00:00
|
|
|
byUuid[uuid] = conversation;
|
|
|
|
} else {
|
|
|
|
// Keep existing - note that this applies if neither had an e164
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
2022-09-13 16:16:01 +00:00
|
|
|
await this.doCombineConversations(existing, conversation);
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
if (pni) {
|
2022-08-10 18:39:04 +00:00
|
|
|
const existing = byUuid[pni];
|
2022-08-09 21:39:00 +00:00
|
|
|
if (!existing) {
|
2022-08-10 18:39:04 +00:00
|
|
|
byUuid[pni] = conversation;
|
2022-10-18 23:42:45 +00:00
|
|
|
} else if (existing === conversation) {
|
|
|
|
// Conversation has both uuid and pni set to the same value. This
|
|
|
|
// happens when starting a conversation by E164.
|
|
|
|
assertDev(
|
|
|
|
pni === uuid,
|
|
|
|
'checkForConflicts: expected PNI to be equal to UUID'
|
|
|
|
);
|
2022-08-09 21:39:00 +00:00
|
|
|
} else {
|
|
|
|
log.warn(`checkForConflicts: Found conflict with pni ${pni}`);
|
|
|
|
|
2022-08-10 18:39:04 +00:00
|
|
|
// Keep the newer one if it has additional data, otherwise keep existing
|
|
|
|
if (conversation.get('e164') || conversation.get('pni')) {
|
2022-08-09 21:39:00 +00:00
|
|
|
// Keep new one
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
2022-09-13 16:16:01 +00:00
|
|
|
await this.doCombineConversations(conversation, existing);
|
2022-08-10 18:39:04 +00:00
|
|
|
byUuid[pni] = conversation;
|
2022-08-09 21:39:00 +00:00
|
|
|
} else {
|
|
|
|
// Keep existing - note that this applies if neither had an e164
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
2022-09-13 16:16:01 +00:00
|
|
|
await this.doCombineConversations(existing, conversation);
|
2022-08-09 21:39:00 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2020-07-10 18:28:49 +00:00
|
|
|
if (e164) {
|
|
|
|
const existing = byE164[e164];
|
|
|
|
if (!existing) {
|
|
|
|
byE164[e164] = conversation;
|
|
|
|
} else {
|
|
|
|
// If we have two contacts with the same e164 but different truthy UUIDs, then
|
|
|
|
// we'll delete the e164 on the older one
|
|
|
|
if (
|
|
|
|
conversation.get('uuid') &&
|
|
|
|
existing.get('uuid') &&
|
|
|
|
conversation.get('uuid') !== existing.get('uuid')
|
|
|
|
) {
|
2021-09-17 18:27:53 +00:00
|
|
|
log.warn(
|
2020-07-10 18:28:49 +00:00
|
|
|
`checkForConflicts: Found two matches on e164 ${e164} with different truthy UUIDs. Dropping e164 on older.`
|
|
|
|
);
|
|
|
|
|
|
|
|
existing.set({ e164: undefined });
|
|
|
|
updateConversation(existing.attributes);
|
|
|
|
|
|
|
|
byE164[e164] = conversation;
|
|
|
|
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
2021-09-17 18:27:53 +00:00
|
|
|
log.warn(`checkForConflicts: Found conflict with e164 ${e164}`);
|
2020-07-10 18:28:49 +00:00
|
|
|
|
|
|
|
// Keep the newer one if it has a UUID, otherwise keep existing
|
|
|
|
if (conversation.get('uuid')) {
|
|
|
|
// Keep new one
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
2022-09-13 16:16:01 +00:00
|
|
|
await this.doCombineConversations(conversation, existing);
|
2020-07-10 18:28:49 +00:00
|
|
|
byE164[e164] = conversation;
|
|
|
|
} else {
|
|
|
|
// Keep existing - note that this applies if neither had a UUID
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
2022-09-13 16:16:01 +00:00
|
|
|
await this.doCombineConversations(existing, conversation);
|
2021-03-15 21:44:59 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
let groupV2Id: undefined | string;
|
2021-06-07 16:39:13 +00:00
|
|
|
if (isGroupV1(conversation.attributes)) {
|
2021-09-24 00:49:05 +00:00
|
|
|
maybeDeriveGroupV2Id(conversation);
|
2021-03-15 21:44:59 +00:00
|
|
|
groupV2Id = conversation.get('derivedGroupV2Id');
|
2022-09-15 19:17:15 +00:00
|
|
|
assertDev(
|
2021-03-15 21:44:59 +00:00
|
|
|
groupV2Id,
|
|
|
|
'checkForConflicts: expected the group V2 ID to have been derived, but it was falsy'
|
|
|
|
);
|
2021-06-07 16:39:13 +00:00
|
|
|
} else if (isGroupV2(conversation.attributes)) {
|
2021-03-15 21:44:59 +00:00
|
|
|
groupV2Id = conversation.get('groupId');
|
|
|
|
}
|
|
|
|
|
|
|
|
if (groupV2Id) {
|
|
|
|
const existing = byGroupV2Id[groupV2Id];
|
|
|
|
if (!existing) {
|
|
|
|
byGroupV2Id[groupV2Id] = conversation;
|
|
|
|
} else {
|
2021-06-07 16:39:13 +00:00
|
|
|
const logParenthetical = isGroupV1(conversation.attributes)
|
2021-03-15 21:44:59 +00:00
|
|
|
? ' (derived from a GV1 group ID)'
|
|
|
|
: '';
|
2021-09-17 18:27:53 +00:00
|
|
|
log.warn(
|
2021-03-15 21:44:59 +00:00
|
|
|
`checkForConflicts: Found conflict with group V2 ID ${groupV2Id}${logParenthetical}`
|
|
|
|
);
|
|
|
|
|
|
|
|
// Prefer the GV2 group.
|
2021-06-07 16:39:13 +00:00
|
|
|
if (
|
|
|
|
isGroupV2(conversation.attributes) &&
|
|
|
|
!isGroupV2(existing.attributes)
|
|
|
|
) {
|
2021-03-15 21:44:59 +00:00
|
|
|
// eslint-disable-next-line no-await-in-loop
|
2022-09-13 16:16:01 +00:00
|
|
|
await this.doCombineConversations(conversation, existing);
|
2021-03-15 21:44:59 +00:00
|
|
|
byGroupV2Id[groupV2Id] = conversation;
|
|
|
|
} else {
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
2022-09-13 16:16:01 +00:00
|
|
|
await this.doCombineConversations(existing, conversation);
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-09-17 18:27:53 +00:00
|
|
|
log.info('checkForConflicts: complete!');
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
2020-09-11 19:37:01 +00:00
|
|
|
|
2021-03-15 21:44:59 +00:00
|
|
|
async combineConversations(
|
2020-09-24 20:57:54 +00:00
|
|
|
current: ConversationModel,
|
|
|
|
obsolete: ConversationModel
|
2020-09-11 19:37:01 +00:00
|
|
|
): Promise<void> {
|
2022-09-13 16:16:01 +00:00
|
|
|
return this._combineConversationsQueue.add(() =>
|
|
|
|
this.doCombineConversations(current, obsolete)
|
|
|
|
);
|
|
|
|
}
|
2022-08-09 23:46:01 +00:00
|
|
|
|
2022-09-13 16:16:01 +00:00
|
|
|
private async doCombineConversations(
|
|
|
|
current: ConversationModel,
|
|
|
|
obsolete: ConversationModel
|
|
|
|
): Promise<void> {
|
|
|
|
const logId = `combineConversations/${obsolete.id}->${current.id}`;
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-09-13 16:16:01 +00:00
|
|
|
const conversationType = current.get('type');
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-09-13 16:16:01 +00:00
|
|
|
if (!this.get(obsolete.id)) {
|
|
|
|
log.warn(`${logId}: Already combined obsolete conversation`);
|
|
|
|
}
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-09-13 16:16:01 +00:00
|
|
|
if (obsolete.get('type') !== conversationType) {
|
2022-09-15 19:17:15 +00:00
|
|
|
assertDev(
|
2022-09-13 16:16:01 +00:00
|
|
|
false,
|
|
|
|
`${logId}: cannot combine a private and group conversation. Doing nothing`
|
2022-08-09 23:46:01 +00:00
|
|
|
);
|
2022-09-13 16:16:01 +00:00
|
|
|
return;
|
|
|
|
}
|
2022-08-09 23:46:01 +00:00
|
|
|
|
2022-09-13 16:16:01 +00:00
|
|
|
const dataToCopy: Partial<ConversationAttributesType> = pick(
|
|
|
|
obsolete.attributes,
|
|
|
|
[
|
|
|
|
'conversationColor',
|
|
|
|
'customColor',
|
|
|
|
'customColorId',
|
|
|
|
'draftAttachments',
|
|
|
|
'draftBodyRanges',
|
|
|
|
'draftTimestamp',
|
|
|
|
'messageCount',
|
|
|
|
'messageRequestResponseType',
|
|
|
|
'quotedMessageId',
|
|
|
|
'sentMessageCount',
|
|
|
|
]
|
|
|
|
);
|
2022-08-09 23:46:01 +00:00
|
|
|
|
2022-09-13 16:16:01 +00:00
|
|
|
const keys = Object.keys(dataToCopy) as Array<
|
|
|
|
keyof ConversationAttributesType
|
|
|
|
>;
|
|
|
|
keys.forEach(key => {
|
|
|
|
if (current.get(key) === undefined) {
|
|
|
|
current.set(key, dataToCopy[key]);
|
|
|
|
|
|
|
|
// To ensure that any files on disk don't get deleted out from under us
|
|
|
|
if (key === 'draftAttachments') {
|
|
|
|
obsolete.set(key, undefined);
|
2022-08-09 23:46:01 +00:00
|
|
|
}
|
2022-09-13 16:16:01 +00:00
|
|
|
}
|
|
|
|
});
|
2022-08-09 23:46:01 +00:00
|
|
|
|
2022-09-13 16:16:01 +00:00
|
|
|
if (obsolete.get('isPinned')) {
|
|
|
|
obsolete.unpin();
|
2022-08-10 18:39:04 +00:00
|
|
|
|
2022-09-13 16:16:01 +00:00
|
|
|
if (!current.get('isPinned')) {
|
|
|
|
current.pin();
|
2022-08-10 18:39:04 +00:00
|
|
|
}
|
2022-09-13 16:16:01 +00:00
|
|
|
}
|
2022-08-10 18:39:04 +00:00
|
|
|
|
2022-09-13 16:16:01 +00:00
|
|
|
const obsoleteId = obsolete.get('id');
|
|
|
|
const obsoleteUuid = obsolete.getUuid();
|
|
|
|
const currentId = current.get('id');
|
|
|
|
log.warn(
|
|
|
|
`${logId}: Combining two conversations -`,
|
|
|
|
`old: ${obsolete.idForLogging()} -> new: ${current.idForLogging()}`
|
|
|
|
);
|
2022-08-09 21:39:00 +00:00
|
|
|
|
2022-09-13 16:16:01 +00:00
|
|
|
if (conversationType === 'private' && obsoleteUuid) {
|
|
|
|
if (!current.get('profileKey') && obsolete.get('profileKey')) {
|
|
|
|
log.warn(`${logId}: Copying profile key from old to new contact`);
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-09-13 16:16:01 +00:00
|
|
|
const profileKey = obsolete.get('profileKey');
|
2022-08-09 21:39:00 +00:00
|
|
|
|
2022-09-13 16:16:01 +00:00
|
|
|
if (profileKey) {
|
|
|
|
await current.setProfileKey(profileKey);
|
2022-08-09 21:39:00 +00:00
|
|
|
}
|
2022-09-13 16:16:01 +00:00
|
|
|
}
|
2021-09-10 02:38:11 +00:00
|
|
|
|
2022-09-13 16:16:01 +00:00
|
|
|
log.warn(`${logId}: Delete all sessions tied to old conversationId`);
|
|
|
|
const ourACI = window.textsecure.storage.user.getUuid(UUIDKind.ACI);
|
|
|
|
const ourPNI = window.textsecure.storage.user.getUuid(UUIDKind.PNI);
|
|
|
|
await Promise.all(
|
|
|
|
[ourACI, ourPNI].map(async ourUuid => {
|
|
|
|
if (!ourUuid) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
const deviceIds =
|
|
|
|
await window.textsecure.storage.protocol.getDeviceIds({
|
|
|
|
ourUuid,
|
|
|
|
identifier: obsoleteUuid.toString(),
|
|
|
|
});
|
|
|
|
await Promise.all(
|
|
|
|
deviceIds.map(async deviceId => {
|
|
|
|
const addr = new QualifiedAddress(
|
2022-08-09 23:46:01 +00:00
|
|
|
ourUuid,
|
2022-09-13 16:16:01 +00:00
|
|
|
new Address(obsoleteUuid, deviceId)
|
|
|
|
);
|
|
|
|
await window.textsecure.storage.protocol.removeSession(addr);
|
|
|
|
})
|
2022-08-09 21:39:00 +00:00
|
|
|
);
|
2022-09-13 16:16:01 +00:00
|
|
|
})
|
|
|
|
);
|
2022-08-09 21:39:00 +00:00
|
|
|
|
2022-09-13 16:16:01 +00:00
|
|
|
log.warn(
|
|
|
|
`${logId}: Delete all identity information tied to old conversationId`
|
|
|
|
);
|
|
|
|
|
|
|
|
if (obsoleteUuid) {
|
|
|
|
await window.textsecure.storage.protocol.removeIdentityKey(
|
|
|
|
obsoleteUuid
|
2022-08-09 21:39:00 +00:00
|
|
|
);
|
|
|
|
}
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-08-09 21:39:00 +00:00
|
|
|
log.warn(
|
2022-09-13 16:16:01 +00:00
|
|
|
`${logId}: Ensure that all V1 groups have new conversationId instead of old`
|
2022-08-09 23:46:01 +00:00
|
|
|
);
|
2022-09-13 16:16:01 +00:00
|
|
|
const groups = await this.getAllGroupsInvolvingUuid(obsoleteUuid);
|
|
|
|
groups.forEach(group => {
|
|
|
|
const members = group.get('members');
|
|
|
|
const withoutObsolete = without(members, obsoleteId);
|
|
|
|
const currentAdded = uniq([...withoutObsolete, currentId]);
|
|
|
|
|
|
|
|
group.set({
|
|
|
|
members: currentAdded,
|
|
|
|
});
|
|
|
|
updateConversation(group.attributes);
|
2022-08-09 23:46:01 +00:00
|
|
|
});
|
2022-09-13 16:16:01 +00:00
|
|
|
}
|
2022-08-09 23:46:01 +00:00
|
|
|
|
2022-09-13 16:16:01 +00:00
|
|
|
// Note: we explicitly don't want to update V2 groups
|
|
|
|
|
|
|
|
log.warn(`${logId}: Delete the obsolete conversation from the database`);
|
|
|
|
await removeConversation(obsoleteId);
|
|
|
|
|
|
|
|
log.warn(`${logId}: Update cached messages in MessageController`);
|
|
|
|
window.MessageController.update((message: MessageModel) => {
|
|
|
|
if (message.get('conversationId') === obsoleteId) {
|
|
|
|
message.set({ conversationId: currentId });
|
|
|
|
}
|
|
|
|
});
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2022-09-13 16:16:01 +00:00
|
|
|
log.warn(`${logId}: Update messages table`);
|
|
|
|
await migrateConversationMessages(obsoleteId, currentId);
|
2022-08-10 18:39:04 +00:00
|
|
|
|
2022-09-13 16:16:01 +00:00
|
|
|
log.warn(`${logId}: Emit refreshConversation event to close old/open new`);
|
|
|
|
window.Whisper.events.trigger('refreshConversation', {
|
|
|
|
newId: currentId,
|
|
|
|
oldId: obsoleteId,
|
2020-07-10 18:28:49 +00:00
|
|
|
});
|
2022-09-13 16:16:01 +00:00
|
|
|
|
|
|
|
log.warn(
|
|
|
|
`${logId}: Eliminate old conversation from ConversationController lookups`
|
|
|
|
);
|
|
|
|
this._conversations.remove(obsolete);
|
|
|
|
this._conversations.resetLookups();
|
|
|
|
|
|
|
|
current.captureChange('combineConversations');
|
|
|
|
|
|
|
|
log.warn(`${logId}: Complete!`);
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
2020-09-11 19:37:01 +00:00
|
|
|
|
2020-07-10 18:28:49 +00:00
|
|
|
/**
|
|
|
|
* Given a groupId and optional additional initialization properties,
|
|
|
|
* ensures the existence of a group conversation and returns a string
|
|
|
|
* representing the local database ID of the group conversation.
|
|
|
|
*/
|
2020-09-09 02:25:05 +00:00
|
|
|
ensureGroup(groupId: string, additionalInitProps = {}): string {
|
2020-07-10 18:28:49 +00:00
|
|
|
return this.getOrCreate(groupId, 'group', additionalInitProps).get('id');
|
|
|
|
}
|
2020-09-11 19:37:01 +00:00
|
|
|
|
2020-07-10 18:28:49 +00:00
|
|
|
/**
|
|
|
|
* Given certain metadata about a message (an identifier of who wrote the
|
|
|
|
* message and the sent_at timestamp of the message) returns the
|
|
|
|
* conversation the message belongs to OR null if a conversation isn't
|
|
|
|
* found.
|
|
|
|
*/
|
2020-09-16 15:44:24 +00:00
|
|
|
async getConversationForTargetMessage(
|
2020-07-10 18:28:49 +00:00
|
|
|
targetFromId: string,
|
|
|
|
targetTimestamp: number
|
2021-06-17 17:15:10 +00:00
|
|
|
): Promise<ConversationModel | null | undefined> {
|
2021-12-10 22:51:54 +00:00
|
|
|
const messages = await getMessagesBySentAt(targetTimestamp);
|
|
|
|
const targetMessage = messages.find(m => getContactId(m) === targetFromId);
|
2020-07-10 18:28:49 +00:00
|
|
|
|
|
|
|
if (targetMessage) {
|
2021-12-10 22:51:54 +00:00
|
|
|
return this.get(targetMessage.conversationId);
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
return null;
|
|
|
|
}
|
2020-09-11 19:37:01 +00:00
|
|
|
|
2021-10-26 22:59:08 +00:00
|
|
|
async getAllGroupsInvolvingUuid(
|
|
|
|
uuid: UUID
|
2020-09-24 20:57:54 +00:00
|
|
|
): Promise<Array<ConversationModel>> {
|
2021-12-10 22:51:54 +00:00
|
|
|
const groups = await getAllGroupsInvolvingUuid(uuid.toString());
|
2020-10-28 21:54:33 +00:00
|
|
|
return groups.map(group => {
|
|
|
|
const existing = this.get(group.id);
|
|
|
|
if (existing) {
|
|
|
|
return existing;
|
|
|
|
}
|
|
|
|
|
|
|
|
return this._conversations.add(group);
|
|
|
|
});
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
2020-09-11 19:37:01 +00:00
|
|
|
|
2020-11-20 17:30:45 +00:00
|
|
|
getByDerivedGroupV2Id(groupId: string): ConversationModel | undefined {
|
|
|
|
return this._conversations.find(
|
|
|
|
item => item.get('derivedGroupV2Id') === groupId
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-09-11 19:37:01 +00:00
|
|
|
reset(): void {
|
2021-11-04 21:11:47 +00:00
|
|
|
delete this._initialPromise;
|
2020-07-10 18:28:49 +00:00
|
|
|
this._initialFetchComplete = false;
|
|
|
|
this._conversations.reset([]);
|
|
|
|
}
|
2020-09-11 19:37:01 +00:00
|
|
|
|
2021-11-04 21:11:47 +00:00
|
|
|
load(): Promise<void> {
|
|
|
|
this._initialPromise ||= this.doLoad();
|
|
|
|
return this._initialPromise;
|
2020-08-11 23:15:06 +00:00
|
|
|
}
|
2020-09-11 19:37:01 +00:00
|
|
|
|
2022-01-25 17:44:45 +00:00
|
|
|
// A number of things outside conversation.attributes affect conversation re-rendering.
|
|
|
|
// If it's scoped to a given conversation, it's easy to trigger('change'). There are
|
|
|
|
// important values in storage and the storage service which change rendering pretty
|
|
|
|
// radically, so this function is necessary to force regeneration of props.
|
2022-01-26 23:39:25 +00:00
|
|
|
async forceRerender(identifiers?: Array<string>): Promise<void> {
|
2022-01-25 17:44:45 +00:00
|
|
|
let count = 0;
|
2022-01-26 23:39:25 +00:00
|
|
|
const conversations = identifiers
|
|
|
|
? identifiers.map(identifier => this.get(identifier)).filter(isNotNil)
|
|
|
|
: this._conversations.models.slice();
|
2022-01-25 17:44:45 +00:00
|
|
|
log.info(
|
|
|
|
`forceRerender: Starting to loop through ${conversations.length} conversations`
|
|
|
|
);
|
|
|
|
|
|
|
|
for (let i = 0, max = conversations.length; i < max; i += 1) {
|
|
|
|
const conversation = conversations[i];
|
|
|
|
|
|
|
|
if (conversation.cachedProps) {
|
|
|
|
conversation.oldCachedProps = conversation.cachedProps;
|
|
|
|
conversation.cachedProps = null;
|
|
|
|
|
|
|
|
conversation.trigger('props-change', conversation, false);
|
|
|
|
count += 1;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (count % 10 === 0) {
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
|
|
await sleep(300);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
log.info(`forceRerender: Updated ${count} conversations`);
|
|
|
|
}
|
|
|
|
|
2022-01-20 00:40:29 +00:00
|
|
|
onConvoOpenStart(conversationId: string): void {
|
|
|
|
this._conversationOpenStart.set(conversationId, Date.now());
|
|
|
|
}
|
|
|
|
|
|
|
|
onConvoMessageMount(conversationId: string): void {
|
|
|
|
const loadStart = this._conversationOpenStart.get(conversationId);
|
|
|
|
if (loadStart === undefined) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
this._conversationOpenStart.delete(conversationId);
|
|
|
|
this.get(conversationId)?.onOpenComplete(loadStart);
|
|
|
|
}
|
|
|
|
|
2022-02-03 01:21:02 +00:00
|
|
|
repairPinnedConversations(): void {
|
|
|
|
const pinnedIds = window.storage.get('pinnedConversationIds', []);
|
|
|
|
|
|
|
|
for (const id of pinnedIds) {
|
|
|
|
const convo = this.get(id);
|
|
|
|
|
|
|
|
if (!convo || convo.get('isPinned')) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
log.warn(
|
|
|
|
`ConversationController: Repairing ${convo.idForLogging()}'s isPinned`
|
|
|
|
);
|
|
|
|
convo.set('isPinned', true);
|
|
|
|
|
|
|
|
window.Signal.Data.updateConversation(convo.attributes);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-08-15 21:53:33 +00:00
|
|
|
// For testing
|
|
|
|
async _forgetE164(e164: string): Promise<void> {
|
|
|
|
const { server } = window.textsecure;
|
|
|
|
strictAssert(server, 'Server must be initialized');
|
2022-08-18 20:44:53 +00:00
|
|
|
const uuidMap = await getUuidsForE164s(server, [e164]);
|
|
|
|
|
|
|
|
const pni = uuidMap.get(e164)?.pni;
|
2022-08-15 21:53:33 +00:00
|
|
|
|
|
|
|
log.info(`ConversationController: forgetting e164=${e164} pni=${pni}`);
|
|
|
|
|
|
|
|
const convos = [this.get(e164), this.get(pni)];
|
|
|
|
|
|
|
|
for (const convo of convos) {
|
|
|
|
if (!convo) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
|
|
await removeConversation(convo.id);
|
|
|
|
this._conversations.remove(convo);
|
|
|
|
this._conversations.resetLookups();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-11-04 21:11:47 +00:00
|
|
|
private async doLoad(): Promise<void> {
|
2021-09-17 18:27:53 +00:00
|
|
|
log.info('ConversationController: starting initial fetch');
|
2020-07-10 18:28:49 +00:00
|
|
|
|
|
|
|
if (this._conversations.length) {
|
|
|
|
throw new Error('ConversationController: Already loaded!');
|
|
|
|
}
|
|
|
|
|
2021-11-04 21:11:47 +00:00
|
|
|
try {
|
2021-12-10 22:51:54 +00:00
|
|
|
const collection = await getAllConversations();
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2021-11-04 21:11:47 +00:00
|
|
|
// Get rid of temporary conversations
|
|
|
|
const temporaryConversations = collection.filter(conversation =>
|
2021-12-10 22:51:54 +00:00
|
|
|
Boolean(conversation.isTemporary)
|
2021-11-04 21:11:47 +00:00
|
|
|
);
|
2021-01-29 22:16:48 +00:00
|
|
|
|
2021-11-04 21:11:47 +00:00
|
|
|
if (temporaryConversations.length) {
|
|
|
|
log.warn(
|
|
|
|
`ConversationController: Removing ${temporaryConversations.length} temporary conversations`
|
2021-01-29 22:16:48 +00:00
|
|
|
);
|
2021-11-04 21:11:47 +00:00
|
|
|
}
|
2021-11-23 22:01:03 +00:00
|
|
|
const queue = new PQueue({
|
|
|
|
concurrency: 3,
|
2022-06-27 16:46:43 +00:00
|
|
|
timeout: MINUTE * 30,
|
2021-11-23 22:01:03 +00:00
|
|
|
throwOnTimeout: true,
|
|
|
|
});
|
2021-11-04 21:11:47 +00:00
|
|
|
queue.addAll(
|
|
|
|
temporaryConversations.map(item => async () => {
|
2021-12-10 22:51:54 +00:00
|
|
|
await removeConversation(item.id);
|
2021-11-04 21:11:47 +00:00
|
|
|
})
|
|
|
|
);
|
|
|
|
await queue.onIdle();
|
2021-01-29 22:16:48 +00:00
|
|
|
|
2021-11-04 21:11:47 +00:00
|
|
|
// Hydrate the final set of conversations
|
|
|
|
this._conversations.add(
|
2021-12-10 22:51:54 +00:00
|
|
|
collection.filter(conversation => !conversation.isTemporary)
|
2021-11-04 21:11:47 +00:00
|
|
|
);
|
|
|
|
|
|
|
|
this._initialFetchComplete = true;
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2021-11-04 21:11:47 +00:00
|
|
|
await Promise.all(
|
|
|
|
this._conversations.map(async conversation => {
|
|
|
|
try {
|
|
|
|
// Hydrate contactCollection, now that initial fetch is complete
|
|
|
|
conversation.fetchContacts();
|
|
|
|
|
|
|
|
const isChanged = maybeDeriveGroupV2Id(conversation);
|
|
|
|
if (isChanged) {
|
|
|
|
updateConversation(conversation.attributes);
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
|
|
|
|
2021-11-04 21:11:47 +00:00
|
|
|
// In case a too-large draft was saved to the database
|
|
|
|
const draft = conversation.get('draft');
|
|
|
|
if (draft && draft.length > MAX_MESSAGE_BODY_LENGTH) {
|
|
|
|
conversation.set({
|
|
|
|
draft: draft.slice(0, MAX_MESSAGE_BODY_LENGTH),
|
|
|
|
});
|
|
|
|
updateConversation(conversation.attributes);
|
|
|
|
}
|
2020-07-10 18:28:49 +00:00
|
|
|
|
2021-11-04 21:11:47 +00:00
|
|
|
// Clean up the conversations that have UUID as their e164.
|
|
|
|
const e164 = conversation.get('e164');
|
|
|
|
const uuid = conversation.get('uuid');
|
|
|
|
if (isValidUuid(e164) && uuid) {
|
|
|
|
conversation.set({ e164: undefined });
|
|
|
|
updateConversation(conversation.attributes);
|
|
|
|
|
|
|
|
log.info(`Cleaning up conversation(${uuid}) with invalid e164`);
|
|
|
|
}
|
|
|
|
} catch (error) {
|
|
|
|
log.error(
|
|
|
|
'ConversationController.load/map: Failed to prepare a conversation',
|
2022-11-22 18:43:43 +00:00
|
|
|
Errors.toLogFormat(error)
|
2021-11-04 21:11:47 +00:00
|
|
|
);
|
|
|
|
}
|
|
|
|
})
|
|
|
|
);
|
|
|
|
log.info('ConversationController: done with initial fetch');
|
|
|
|
} catch (error) {
|
|
|
|
log.error(
|
|
|
|
'ConversationController: initial fetch failed',
|
2022-11-22 18:43:43 +00:00
|
|
|
Errors.toLogFormat(error)
|
2021-11-04 21:11:47 +00:00
|
|
|
);
|
|
|
|
throw error;
|
|
|
|
}
|
2020-07-10 18:28:49 +00:00
|
|
|
}
|
|
|
|
}
|