diff --git a/preload.js b/preload.js index 7313e18cb4fd..c3a1bf1f6406 100644 --- a/preload.js +++ b/preload.js @@ -378,14 +378,12 @@ try { }); const { imageToBlurHash } = require('./ts/util/imageToBlurHash'); - const { isValidGuid } = require('./ts/util/isValidGuid'); const { ActiveWindowService } = require('./ts/services/ActiveWindowService'); window.imageToBlurHash = imageToBlurHash; window.emojiData = require('emoji-datasource'); window.libphonenumber = require('google-libphonenumber').PhoneNumberUtil.getInstance(); window.libphonenumber.PhoneNumberFormat = require('google-libphonenumber').PhoneNumberFormat; - window.getGuid = require('uuid/v4'); const activeWindowService = new ActiveWindowService(); activeWindowService.initialize(window.document, ipc); @@ -401,8 +399,6 @@ try { reducedMotionSetting: Boolean(config.reducedMotionSetting), }; - window.isValidGuid = isValidGuid; - window.React = require('react'); window.ReactDOM = require('react-dom'); window.moment = require('moment'); diff --git a/sticker-creator/preload.js b/sticker-creator/preload.js index 4b383b385c01..f3a380300b1b 100644 --- a/sticker-creator/preload.js +++ b/sticker-creator/preload.js @@ -34,7 +34,6 @@ const MAX_ANIMATED_STICKER_BYTE_LENGTH = 300 * 1024; window.ROOT_PATH = window.location.href.startsWith('file') ? '../../' : '/'; window.getEnvironment = getEnvironment; window.getVersion = () => config.version; -window.getGuid = require('uuid/v4'); window.PQueue = require('p-queue').default; window.Backbone = require('backbone'); diff --git a/test/setup-test-node.js b/test/setup-test-node.js index 7cb119a6490b..f5c0113619b8 100644 --- a/test/setup-test-node.js +++ b/test/setup-test-node.js @@ -8,7 +8,6 @@ const chaiAsPromised = require('chai-as-promised'); const { Crypto } = require('../ts/context/Crypto'); const { setEnvironment, Environment } = require('../ts/environment'); -const { isValidGuid } = require('../ts/util/isValidGuid'); chai.use(chaiAsPromised); @@ -31,7 +30,6 @@ global.window = { get: key => storageMap.get(key), put: async (key, value) => storageMap.set(key, value), }, - isValidGuid, }; // For ducks/network.getEmptyState() diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index da4a3906d11f..a72d15c4c92c 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -13,10 +13,9 @@ import type { import type { ConversationModel } from './models/conversations'; import { maybeDeriveGroupV2Id } from './groups'; import { assert } from './util/assert'; -import { isValidGuid } from './util/isValidGuid'; import { map, reduce } from './util/iterables'; import { isGroupV1, isGroupV2 } from './util/whatTypeOfConversation'; -import { UUID } from './types/UUID'; +import { UUID, isValidUuid } from './types/UUID'; import { Address } from './types/Address'; import { QualifiedAddress } from './types/QualifiedAddress'; import * as log from './logging/log'; @@ -25,7 +24,7 @@ const MAX_MESSAGE_BODY_LENGTH = 64 * 1024; const { getAllConversations, - getAllGroupsInvolvingId, + getAllGroupsInvolvingUuid, getMessagesBySentAt, migrateConversationMessages, removeConversation, @@ -181,7 +180,7 @@ export class ConversationController { return conversation; } - const id = window.getGuid(); + const id = UUID.generate().toString(); if (type === 'group') { conversation = this._conversations.add({ @@ -193,7 +192,7 @@ export class ConversationController { version: 2, ...additionalInitialProps, }); - } else if (window.isValidGuid(identifier)) { + } else if (isValidUuid(identifier)) { conversation = this._conversations.add({ id, uuid: identifier, @@ -617,7 +616,7 @@ export class ConversationController { } const obsoleteId = obsolete.get('id'); - const obsoleteUuid = obsolete.get('uuid'); + const obsoleteUuid = obsolete.getUuid(); const currentId = current.get('id'); log.warn('combineConversations: Combining two conversations', { obsolete: obsoleteId, @@ -643,13 +642,13 @@ export class ConversationController { const ourUuid = window.textsecure.storage.user.getCheckedUuid(); const deviceIds = await window.textsecure.storage.protocol.getDeviceIds({ ourUuid, - identifier: obsoleteUuid, + identifier: obsoleteUuid.toString(), }); await Promise.all( deviceIds.map(async deviceId => { const addr = new QualifiedAddress( ourUuid, - Address.create(obsoleteUuid, deviceId) + new Address(obsoleteUuid, deviceId) ); await window.textsecure.storage.protocol.removeSession(addr); }) @@ -661,14 +660,14 @@ export class ConversationController { if (obsoleteUuid) { await window.textsecure.storage.protocol.removeIdentityKey( - new UUID(obsoleteUuid) + obsoleteUuid ); } log.warn( 'combineConversations: Ensure that all V1 groups have new conversationId instead of old' ); - const groups = await this.getAllGroupsInvolvingId(obsoleteId); + const groups = await this.getAllGroupsInvolvingUuid(obsoleteUuid); groups.forEach(group => { const members = group.get('members'); const withoutObsolete = without(members, obsoleteId); @@ -737,10 +736,10 @@ export class ConversationController { return null; } - async getAllGroupsInvolvingId( - conversationId: string + async getAllGroupsInvolvingUuid( + uuid: UUID ): Promise> { - const groups = await getAllGroupsInvolvingId(conversationId, { + const groups = await getAllGroupsInvolvingUuid(uuid.toString(), { ConversationCollection: window.Whisper.ConversationCollection, }); return groups.map(group => { @@ -836,7 +835,7 @@ export class ConversationController { // Clean up the conversations that have UUID as their e164. const e164 = conversation.get('e164'); const uuid = conversation.get('uuid'); - if (isValidGuid(e164) && uuid) { + if (isValidUuid(e164) && uuid) { conversation.set({ e164: undefined }); updateConversation(conversation.attributes); diff --git a/ts/Crypto.ts b/ts/Crypto.ts index 0aaaa7110a72..1fdb5185e77e 100644 --- a/ts/Crypto.ts +++ b/ts/Crypto.ts @@ -12,6 +12,8 @@ import { calculateAgreement, generateKeyPair } from './Curve'; import * as log from './logging/log'; import { HashType, CipherType } from './types/Crypto'; import { ProfileDecryptError } from './types/errors'; +import { UUID } from './types/UUID'; +import type { UUIDStringType } from './types/UUID'; export { HashType, CipherType }; @@ -457,7 +459,7 @@ export function uuidToBytes(uuid: string): Uint8Array { ); } -export function bytesToUuid(bytes: Uint8Array): undefined | string { +export function bytesToUuid(bytes: Uint8Array): undefined | UUIDStringType { if (bytes.byteLength !== 16) { log.warn( 'bytesToUuid: received an Uint8Array of invalid length. ' + @@ -473,7 +475,7 @@ export function bytesToUuid(bytes: Uint8Array): undefined | string { return undefined; } -export function splitUuids(buffer: Uint8Array): Array { +export function splitUuids(buffer: Uint8Array): Array { const uuids = []; for (let i = 0; i < buffer.byteLength; i += 16) { const bytes = getBytes(buffer, i, 16); @@ -487,7 +489,7 @@ export function splitUuids(buffer: Uint8Array): Array { ]; const uuid = chunks.join('-'); if (uuid !== '00000000-0000-0000-0000-000000000000') { - uuids.push(uuid); + uuids.push(UUID.cast(uuid)); } else { uuids.push(null); } diff --git a/ts/SignalProtocolStore.ts b/ts/SignalProtocolStore.ts index f8648f685ef4..24f28e04f68c 100644 --- a/ts/SignalProtocolStore.ts +++ b/ts/SignalProtocolStore.ts @@ -994,7 +994,7 @@ export class SignalProtocolStore extends EventsMixin { id, version: 2, ourUuid: qualifiedAddress.ourUuid.toString(), - conversationId: new UUID(conversationId).toString(), + conversationId, uuid: uuid.toString(), deviceId, record: record.serialize().toString('base64'), @@ -1394,7 +1394,7 @@ export class SignalProtocolStore extends EventsMixin { return undefined; } - const conversationId = new UUID(conversation.id).toString(); + const conversationId = conversation.id; const record = this.identityKeys.get(`conversation:${conversationId}`); if (!record) { return undefined; diff --git a/ts/background.ts b/ts/background.ts index 60e58d328b9a..8a8a0d01a87b 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -3608,6 +3608,7 @@ export async function startApp(): Promise { messageSentAt: timestamp, receiptTimestamp: envelopeTimestamp, sourceConversationId, + sourceUuid, sourceDevice, type, }); @@ -3791,6 +3792,7 @@ export async function startApp(): Promise { messageSentAt: timestamp, receiptTimestamp: envelopeTimestamp, sourceConversationId, + sourceUuid, sourceDevice, type: MessageReceiptType.Delivery, }); diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index 76113bfa8573..e79dd292a3c3 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -40,12 +40,13 @@ import type { StartCallType, } from '../state/ducks/calling'; import type { LocalizerType } from '../types/Util'; +import type { UUIDStringType } from '../types/UUID'; import { missingCaseError } from '../util/missingCaseError'; const GROUP_CALL_RING_DURATION = 60 * 1000; type MeType = ConversationType & { - uuid: string; + uuid: UUIDStringType; }; export type PropsType = { diff --git a/ts/components/CallScreen.stories.tsx b/ts/components/CallScreen.stories.tsx index 9b34d2c89d29..45c61b25c29c 100644 --- a/ts/components/CallScreen.stories.tsx +++ b/ts/components/CallScreen.stories.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import { times } from 'lodash'; -import { v4 as generateUuid } from 'uuid'; import { storiesOf } from '@storybook/react'; import { boolean, select, number } from '@storybook/addon-knobs'; import { action } from '@storybook/addon-actions'; @@ -21,7 +20,10 @@ import type { PropsType } from './CallScreen'; import { CallScreen } from './CallScreen'; import { setupI18n } from '../util/setupI18n'; import { missingCaseError } from '../util/missingCaseError'; -import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; +import { + getDefaultConversation, + getDefaultConversationWithUuid, +} from '../test-both/helpers/getDefaultConversation'; import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource'; import enMessages from '../../_locales/en/messages.json'; @@ -286,10 +288,9 @@ const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({ presenting: false, sharingScreen: false, videoAspectRatio: 1.3, - ...getDefaultConversation({ + ...getDefaultConversationWithUuid({ isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1, title: `Participant ${index + 1}`, - uuid: generateUuid(), }), })); diff --git a/ts/components/CallScreen.tsx b/ts/components/CallScreen.tsx index 867ad54751c3..e9e06b298d9f 100644 --- a/ts/components/CallScreen.tsx +++ b/ts/components/CallScreen.tsx @@ -38,6 +38,7 @@ import { GroupCallRemoteParticipants } from './GroupCallRemoteParticipants'; import type { LocalizerType } from '../types/Util'; import { NeedsScreenRecordingPermissionsModal } from './NeedsScreenRecordingPermissionsModal'; import { missingCaseError } from '../util/missingCaseError'; +import type { UUIDStringType } from '../types/UUID'; import * as KeyboardLayout from '../services/keyboardLayout'; import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting'; @@ -57,7 +58,7 @@ export type PropsType = { phoneNumber?: string; profileName?: string; title: string; - uuid: string; + uuid: UUIDStringType; }; openSystemPreferencesAction: () => unknown; setGroupCallVideoRequest: (_: Array) => void; diff --git a/ts/components/CallingLobby.stories.tsx b/ts/components/CallingLobby.stories.tsx index 95903e4014aa..af1ff068136e 100644 --- a/ts/components/CallingLobby.stories.tsx +++ b/ts/components/CallingLobby.stories.tsx @@ -6,15 +6,18 @@ import { times } from 'lodash'; import { storiesOf } from '@storybook/react'; import { boolean } from '@storybook/addon-knobs'; import { action } from '@storybook/addon-actions'; -import { v4 as generateUuid } from 'uuid'; import { AvatarColors } from '../types/Colors'; import type { ConversationType } from '../state/ducks/conversations'; import type { PropsType } from './CallingLobby'; import { CallingLobby } from './CallingLobby'; import { setupI18n } from '../util/setupI18n'; +import { UUID } from '../types/UUID'; import enMessages from '../../_locales/en/messages.json'; -import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; +import { + getDefaultConversation, + getDefaultConversationWithUuid, +} from '../test-both/helpers/getDefaultConversation'; const i18n = setupI18n('en', enMessages); @@ -60,8 +63,8 @@ const createProps = (overrideProps: Partial = {}): PropsType => { isCallFull: boolean('isCallFull', overrideProps.isCallFull || false), me: overrideProps.me || { color: AvatarColors[0], - id: generateUuid(), - uuid: generateUuid(), + id: UUID.generate().toString(), + uuid: UUID.generate().toString(), }, onCallCanceled: action('on-call-canceled'), onJoinCall: action('on-join-call'), @@ -81,8 +84,7 @@ const createProps = (overrideProps: Partial = {}): PropsType => { }; const fakePeekedParticipant = (conversationProps: Partial) => - getDefaultConversation({ - uuid: generateUuid(), + getDefaultConversationWithUuid({ ...conversationProps, }); @@ -106,8 +108,8 @@ story.add('No Camera, local avatar', () => { me: { avatarPath: '/fixtures/kitten-4-112-112.jpg', color: AvatarColors[0], - id: generateUuid(), - uuid: generateUuid(), + id: UUID.generate().toString(), + uuid: UUID.generate().toString(), }, }); return ; @@ -141,11 +143,11 @@ story.add('Group Call - 1 peeked participant', () => { }); story.add('Group Call - 1 peeked participant (self)', () => { - const uuid = generateUuid(); + const uuid = UUID.generate().toString(); const props = createProps({ isGroupCall: true, me: { - id: generateUuid(), + id: UUID.generate().toString(), uuid, }, peekedParticipants: [fakePeekedParticipant({ title: 'Ash', uuid })], diff --git a/ts/components/CallingLobby.tsx b/ts/components/CallingLobby.tsx index 4882855e39b9..2a3fc6e4f405 100644 --- a/ts/components/CallingLobby.tsx +++ b/ts/components/CallingLobby.tsx @@ -19,6 +19,7 @@ import { } from './CallingLobbyJoinButton'; import type { AvatarColorType } from '../types/Colors'; import type { LocalizerType } from '../types/Util'; +import type { UUIDStringType } from '../types/UUID'; import { useIsOnline } from '../hooks/useIsOnline'; import * as KeyboardLayout from '../services/keyboardLayout'; import type { ConversationType } from '../state/ducks/conversations'; @@ -52,7 +53,7 @@ export type PropsType = { avatarPath?: string; id: string; color?: AvatarColorType; - uuid: string; + uuid: UUIDStringType; }; onCallCanceled: () => void; onJoinCall: () => void; diff --git a/ts/components/CallingParticipantsList.stories.tsx b/ts/components/CallingParticipantsList.stories.tsx index abe9ad956ad2..cadb2c8fcc20 100644 --- a/ts/components/CallingParticipantsList.stories.tsx +++ b/ts/components/CallingParticipantsList.stories.tsx @@ -5,13 +5,12 @@ import * as React from 'react'; import { sample } from 'lodash'; import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; -import { v4 as generateUuid } from 'uuid'; import type { PropsType } from './CallingParticipantsList'; import { CallingParticipantsList } from './CallingParticipantsList'; import { AvatarColors } from '../types/Colors'; import type { GroupCallRemoteParticipantType } from '../types/Calling'; -import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; +import { getDefaultConversationWithUuid } from '../test-both/helpers/getDefaultConversation'; import { setupI18n } from '../util/setupI18n'; import enMessages from '../../_locales/en/messages.json'; @@ -27,14 +26,13 @@ function createParticipant( presenting: Boolean(participantProps.presenting), sharingScreen: Boolean(participantProps.sharingScreen), videoAspectRatio: 1.3, - ...getDefaultConversation({ + ...getDefaultConversationWithUuid({ avatarPath: participantProps.avatarPath, color: sample(AvatarColors), isBlocked: Boolean(participantProps.isBlocked), name: participantProps.name, profileName: participantProps.title, title: String(participantProps.title), - uuid: generateUuid(), }), }; } diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index cc419df89d90..5dfaa9c8cb83 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -16,6 +16,7 @@ import type { EmojiPickDataType } from './emoji/EmojiPicker'; import { convertShortName } from './emoji/lib'; import type { LocalizerType, BodyRangeType } from '../types/Util'; import type { ConversationType } from '../state/ducks/conversations'; +import { isValidUuid } from '../types/UUID'; import { MentionBlot } from '../quill/mentions/blot'; import { matchEmojiImage, @@ -465,7 +466,7 @@ export function CompositionInput(props: Props): React.ReactElement { const currentMemberUuids = currentMembers .map(m => m.uuid) - .filter((uuid): uuid is string => uuid !== undefined); + .filter(isValidUuid); const newDelta = getDeltaToRemoveStaleMentions(ops, currentMemberUuids); diff --git a/ts/components/GroupCallOverflowArea.stories.tsx b/ts/components/GroupCallOverflowArea.stories.tsx index a37ba5a53219..1ae13046b7d3 100644 --- a/ts/components/GroupCallOverflowArea.stories.tsx +++ b/ts/components/GroupCallOverflowArea.stories.tsx @@ -4,13 +4,12 @@ import type { FC } from 'react'; import React from 'react'; import { memoize, times } from 'lodash'; -import { v4 as generateUuid } from 'uuid'; import { storiesOf } from '@storybook/react'; import { number } from '@storybook/addon-knobs'; import { GroupCallOverflowArea } from './GroupCallOverflowArea'; import { setupI18n } from '../util/setupI18n'; -import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; +import { getDefaultConversationWithUuid } from '../test-both/helpers/getDefaultConversation'; import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource'; import { FRAME_BUFFER_SIZE } from '../calling/constants'; import enMessages from '../../_locales/en/messages.json'; @@ -26,10 +25,9 @@ const allRemoteParticipants = times(MAX_PARTICIPANTS).map(index => ({ presenting: false, sharingScreen: false, videoAspectRatio: 1.3, - ...getDefaultConversation({ + ...getDefaultConversationWithUuid({ isBlocked: index === 10 || index === MAX_PARTICIPANTS - 1, title: `Participant ${index + 1}`, - uuid: generateUuid(), }), })); diff --git a/ts/components/conversation/GroupV2Change.stories.tsx b/ts/components/conversation/GroupV2Change.stories.tsx index c16d96c76bde..f222234a72c2 100644 --- a/ts/components/conversation/GroupV2Change.stories.tsx +++ b/ts/components/conversation/GroupV2Change.stories.tsx @@ -6,6 +6,7 @@ import * as React from 'react'; import { storiesOf } from '@storybook/react'; import { setupI18n } from '../../util/setupI18n'; +import { UUID } from '../../types/UUID'; import enMessages from '../../../_locales/en/messages.json'; import type { GroupV2ChangeType } from '../../groups'; import { SignalService as Proto } from '../../protobuf'; @@ -14,12 +15,12 @@ import { GroupV2Change } from './GroupV2Change'; const i18n = setupI18n('en', enMessages); -const OUR_ID = 'OUR_ID'; -const CONTACT_A = 'CONTACT_A'; -const CONTACT_B = 'CONTACT_B'; -const CONTACT_C = 'CONTACT_C'; -const ADMIN_A = 'ADMIN_A'; -const INVITEE_A = 'INVITEE_A'; +const OUR_ID = UUID.generate().toString(); +const CONTACT_A = UUID.generate().toString(); +const CONTACT_B = UUID.generate().toString(); +const CONTACT_C = UUID.generate().toString(); +const ADMIN_A = UUID.generate().toString(); +const INVITEE_A = UUID.generate().toString(); const AccessControlEnum = Proto.AccessControl.AccessRequired; const RoleEnum = Proto.Member.Role; @@ -35,7 +36,7 @@ const renderChange = (change: GroupV2ChangeType, groupName?: string) => ( change={change} groupName={groupName} i18n={i18n} - ourConversationId={OUR_ID} + ourUuid={OUR_ID} renderContact={renderContact} /> ); @@ -62,7 +63,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) }, { type: 'member-add', - conversationId: OUR_ID, + uuid: OUR_ID, }, { type: 'description', @@ -70,7 +71,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) }, { type: 'member-privilege', - conversationId: OUR_ID, + uuid: OUR_ID, newPrivilege: RoleEnum.ADMINISTRATOR, }, ], @@ -402,7 +403,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add', - conversationId: OUR_ID, + uuid: OUR_ID, }, ], })} @@ -411,7 +412,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add', - conversationId: OUR_ID, + uuid: OUR_ID, }, ], })} @@ -419,7 +420,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add', - conversationId: OUR_ID, + uuid: OUR_ID, }, ], })} @@ -428,7 +429,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add', - conversationId: CONTACT_A, + uuid: CONTACT_A, }, ], })} @@ -437,7 +438,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add', - conversationId: CONTACT_A, + uuid: CONTACT_A, }, ], })} @@ -445,7 +446,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add', - conversationId: CONTACT_A, + uuid: CONTACT_A, }, ], })} @@ -461,7 +462,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add-from-invite', - conversationId: OUR_ID, + uuid: OUR_ID, inviter: CONTACT_B, }, ], @@ -470,7 +471,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add-from-invite', - conversationId: OUR_ID, + uuid: OUR_ID, inviter: CONTACT_A, }, ], @@ -481,7 +482,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add-from-invite', - conversationId: CONTACT_A, + uuid: CONTACT_A, inviter: CONTACT_B, }, ], @@ -491,7 +492,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add-from-invite', - conversationId: CONTACT_B, + uuid: CONTACT_B, inviter: CONTACT_C, }, ], @@ -500,7 +501,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add-from-invite', - conversationId: CONTACT_A, + uuid: CONTACT_A, inviter: CONTACT_B, }, ], @@ -511,7 +512,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add-from-invite', - conversationId: OUR_ID, + uuid: OUR_ID, inviter: CONTACT_A, }, ], @@ -521,7 +522,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add-from-invite', - conversationId: OUR_ID, + uuid: OUR_ID, }, ], })} @@ -530,7 +531,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add-from-invite', - conversationId: CONTACT_A, + uuid: CONTACT_A, inviter: OUR_ID, }, ], @@ -540,7 +541,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add-from-invite', - conversationId: CONTACT_A, + uuid: CONTACT_A, inviter: CONTACT_B, }, ], @@ -550,7 +551,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add-from-invite', - conversationId: CONTACT_A, + uuid: CONTACT_A, }, ], })} @@ -565,7 +566,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add-from-link', - conversationId: OUR_ID, + uuid: OUR_ID, }, ], })} @@ -574,7 +575,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add-from-link', - conversationId: CONTACT_A, + uuid: CONTACT_A, }, ], })} @@ -582,7 +583,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add-from-link', - conversationId: CONTACT_A, + uuid: CONTACT_A, }, ], })} @@ -597,7 +598,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add-from-admin-approval', - conversationId: OUR_ID, + uuid: OUR_ID, }, ], })} @@ -605,7 +606,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add-from-admin-approval', - conversationId: OUR_ID, + uuid: OUR_ID, }, ], })} @@ -614,7 +615,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add-from-admin-approval', - conversationId: CONTACT_A, + uuid: CONTACT_A, }, ], })} @@ -623,7 +624,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add-from-admin-approval', - conversationId: CONTACT_A, + uuid: CONTACT_A, }, ], })} @@ -631,7 +632,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-add-from-admin-approval', - conversationId: CONTACT_A, + uuid: CONTACT_A, }, ], })} @@ -646,7 +647,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-remove', - conversationId: OUR_ID, + uuid: OUR_ID, }, ], })} @@ -655,7 +656,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-remove', - conversationId: OUR_ID, + uuid: OUR_ID, }, ], })} @@ -663,7 +664,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-remove', - conversationId: OUR_ID, + uuid: OUR_ID, }, ], })} @@ -672,7 +673,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-remove', - conversationId: CONTACT_A, + uuid: CONTACT_A, }, ], })} @@ -681,7 +682,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-remove', - conversationId: CONTACT_A, + uuid: CONTACT_A, }, ], })} @@ -690,7 +691,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-remove', - conversationId: CONTACT_A, + uuid: CONTACT_A, }, ], })} @@ -698,7 +699,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-remove', - conversationId: CONTACT_A, + uuid: CONTACT_A, }, ], })} @@ -713,7 +714,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-privilege', - conversationId: OUR_ID, + uuid: OUR_ID, newPrivilege: RoleEnum.ADMINISTRATOR, }, ], @@ -722,7 +723,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-privilege', - conversationId: OUR_ID, + uuid: OUR_ID, newPrivilege: RoleEnum.ADMINISTRATOR, }, ], @@ -732,7 +733,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-privilege', - conversationId: CONTACT_A, + uuid: CONTACT_A, newPrivilege: RoleEnum.ADMINISTRATOR, }, ], @@ -742,7 +743,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-privilege', - conversationId: CONTACT_A, + uuid: CONTACT_A, newPrivilege: RoleEnum.ADMINISTRATOR, }, ], @@ -751,7 +752,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-privilege', - conversationId: CONTACT_A, + uuid: CONTACT_A, newPrivilege: RoleEnum.ADMINISTRATOR, }, ], @@ -761,7 +762,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-privilege', - conversationId: OUR_ID, + uuid: OUR_ID, newPrivilege: RoleEnum.DEFAULT, }, ], @@ -770,7 +771,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-privilege', - conversationId: OUR_ID, + uuid: OUR_ID, newPrivilege: RoleEnum.DEFAULT, }, ], @@ -780,7 +781,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-privilege', - conversationId: CONTACT_A, + uuid: CONTACT_A, newPrivilege: RoleEnum.DEFAULT, }, ], @@ -790,7 +791,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-privilege', - conversationId: CONTACT_A, + uuid: CONTACT_A, newPrivilege: RoleEnum.DEFAULT, }, ], @@ -799,7 +800,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'member-privilege', - conversationId: CONTACT_A, + uuid: CONTACT_A, newPrivilege: RoleEnum.DEFAULT, }, ], @@ -815,7 +816,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-add-one', - conversationId: OUR_ID, + uuid: OUR_ID, }, ], })} @@ -823,7 +824,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-add-one', - conversationId: OUR_ID, + uuid: OUR_ID, }, ], })} @@ -832,7 +833,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-add-one', - conversationId: INVITEE_A, + uuid: INVITEE_A, }, ], })} @@ -841,7 +842,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-add-one', - conversationId: INVITEE_A, + uuid: INVITEE_A, }, ], })} @@ -849,7 +850,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-add-one', - conversationId: INVITEE_A, + uuid: INVITEE_A, }, ], })} @@ -896,7 +897,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-remove-one', - conversationId: INVITEE_A, + uuid: INVITEE_A, inviter: OUR_ID, }, ], @@ -906,7 +907,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-remove-one', - conversationId: INVITEE_A, + uuid: INVITEE_A, inviter: OUR_ID, }, ], @@ -916,7 +917,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-remove-one', - conversationId: INVITEE_A, + uuid: INVITEE_A, inviter: OUR_ID, }, ], @@ -925,7 +926,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-remove-one', - conversationId: INVITEE_A, + uuid: INVITEE_A, inviter: OUR_ID, }, ], @@ -935,7 +936,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-remove-one', - conversationId: INVITEE_A, + uuid: INVITEE_A, }, ], })} @@ -944,7 +945,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-remove-one', - conversationId: INVITEE_A, + uuid: INVITEE_A, inviter: CONTACT_B, }, ], @@ -955,7 +956,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-remove-one', - conversationId: OUR_ID, + uuid: OUR_ID, inviter: CONTACT_B, }, ], @@ -965,7 +966,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-remove-one', - conversationId: CONTACT_B, + uuid: CONTACT_B, inviter: CONTACT_A, }, ], @@ -976,7 +977,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-remove-one', - conversationId: INVITEE_A, + uuid: INVITEE_A, inviter: CONTACT_B, }, ], @@ -986,7 +987,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-remove-one', - conversationId: INVITEE_A, + uuid: INVITEE_A, inviter: CONTACT_B, }, ], @@ -995,7 +996,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-remove-one', - conversationId: INVITEE_A, + uuid: INVITEE_A, inviter: CONTACT_B, }, ], @@ -1006,7 +1007,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-remove-one', - conversationId: INVITEE_A, + uuid: INVITEE_A, }, ], })} @@ -1015,7 +1016,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-remove-one', - conversationId: INVITEE_A, + uuid: INVITEE_A, }, ], })} @@ -1023,7 +1024,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'pending-remove-one', - conversationId: INVITEE_A, + uuid: INVITEE_A, }, ], })} @@ -1128,7 +1129,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'admin-approval-add-one', - conversationId: OUR_ID, + uuid: OUR_ID, }, ], })} @@ -1136,7 +1137,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'admin-approval-add-one', - conversationId: CONTACT_A, + uuid: CONTACT_A, }, ], })} @@ -1151,7 +1152,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'admin-approval-remove-one', - conversationId: OUR_ID, + uuid: OUR_ID, }, ], })} @@ -1159,7 +1160,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'admin-approval-remove-one', - conversationId: OUR_ID, + uuid: OUR_ID, }, ], })} @@ -1168,7 +1169,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'admin-approval-remove-one', - conversationId: CONTACT_A, + uuid: CONTACT_A, }, ], })} @@ -1177,7 +1178,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'admin-approval-remove-one', - conversationId: CONTACT_A, + uuid: CONTACT_A, }, ], })} @@ -1186,7 +1187,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'admin-approval-remove-one', - conversationId: CONTACT_A, + uuid: CONTACT_A, }, ], })} @@ -1194,7 +1195,7 @@ storiesOf('Components/Conversation/GroupV2Change', module) details: [ { type: 'admin-approval-remove-one', - conversationId: CONTACT_A, + uuid: CONTACT_A, }, ], })} diff --git a/ts/components/conversation/GroupV2Change.tsx b/ts/components/conversation/GroupV2Change.tsx index 57ed9641dcb8..2dc24f315c4f 100644 --- a/ts/components/conversation/GroupV2Change.tsx +++ b/ts/components/conversation/GroupV2Change.tsx @@ -9,6 +9,7 @@ import type { ReplacementValuesType } from '../../types/I18N'; import type { FullJSXType } from '../Intl'; import { Intl } from '../Intl'; import type { LocalizerType } from '../../types/Util'; +import type { UUIDStringType } from '../../types/UUID'; import { GroupDescriptionText } from '../GroupDescriptionText'; import { Button, ButtonSize, ButtonVariant } from '../Button'; import { SystemMessage } from './SystemMessage'; @@ -21,7 +22,7 @@ import { Modal } from '../Modal'; export type PropsDataType = { groupName?: string; - ourConversationId: string; + ourUuid: UUIDStringType; change: GroupV2ChangeType; }; @@ -78,11 +79,11 @@ const changeToIconMap = new Map([ function getIcon( detail: GroupV2ChangeDetailType, - fromId?: string + fromId?: UUIDStringType ): GroupIconType { const changeType = detail.type; let possibleIcon = changeToIconMap.get(changeType); - const isSameId = fromId === get(detail, 'conversationId', null); + const isSameId = fromId === get(detail, 'uuid', null); if (isSameId) { if (changeType === 'member-remove') { possibleIcon = 'group-leave'; @@ -103,7 +104,7 @@ function GroupV2Detail({ }: { detail: GroupV2ChangeDetailType; i18n: LocalizerType; - fromId?: string; + fromId?: UUIDStringType; onButtonClick: (x: string) => unknown; text: FullJSXType; }): JSX.Element { @@ -132,7 +133,7 @@ function GroupV2Detail({ } export function GroupV2Change(props: PropsType): ReactElement { - const { change, groupName, i18n, ourConversationId, renderContact } = props; + const { change, groupName, i18n, ourUuid, renderContact } = props; const [groupDescription, setGroupDescription] = useState< string | undefined @@ -142,7 +143,7 @@ export function GroupV2Change(props: PropsType): ReactElement { <> {renderChange(change, { i18n, - ourConversationId, + ourUuid, renderContact, renderString: renderStringToIntl, }).map((text: FullJSXType, index: number) => ( diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index f6af794a71e7..647549c5bbd7 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -301,8 +301,8 @@ const actions = () => ({ ), checkForAccount: action('checkForAccount'), clearChangedMessages: action('clearChangedMessages'), - clearInvitedConversationsForNewlyCreatedGroup: action( - 'clearInvitedConversationsForNewlyCreatedGroup' + clearInvitedUuidsForNewlyCreatedGroup: action( + 'clearInvitedUuidsForNewlyCreatedGroup' ), setLoadCountdownStart: action('setLoadCountdownStart'), setIsNearBottom: action('setIsNearBottom'), diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index 6e13b3db5feb..7ee29904bd63 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -130,7 +130,7 @@ export type PropsActionsType = { groupNameCollisions: Readonly ) => void; clearChangedMessages: (conversationId: string) => unknown; - clearInvitedConversationsForNewlyCreatedGroup: () => void; + clearInvitedUuidsForNewlyCreatedGroup: () => void; closeContactSpoofingReview: () => void; setLoadCountdownStart: ( conversationId: string, @@ -231,7 +231,7 @@ const getActions = createSelector( const unsafe = pick(props, [ 'acknowledgeGroupMemberNameCollisions', 'clearChangedMessages', - 'clearInvitedConversationsForNewlyCreatedGroup', + 'clearInvitedUuidsForNewlyCreatedGroup', 'closeContactSpoofingReview', 'setLoadCountdownStart', 'setIsNearBottom', @@ -1313,7 +1313,7 @@ export class Timeline extends React.PureComponent { const { acknowledgeGroupMemberNameCollisions, areWeAdmin, - clearInvitedConversationsForNewlyCreatedGroup, + clearInvitedUuidsForNewlyCreatedGroup, closeContactSpoofingReview, contactSpoofingReview, i18n, @@ -1566,7 +1566,7 @@ export class Timeline extends React.PureComponent { )} diff --git a/ts/components/conversation/conversation-details/PendingInvites.stories.tsx b/ts/components/conversation/conversation-details/PendingInvites.stories.tsx index 4c6d323d27f1..237b04bc03b7 100644 --- a/ts/components/conversation/conversation-details/PendingInvites.stories.tsx +++ b/ts/components/conversation/conversation-details/PendingInvites.stories.tsx @@ -7,6 +7,7 @@ import { times } from 'lodash'; import { storiesOf } from '@storybook/react'; import { action } from '@storybook/addon-actions'; +import { UUID } from '../../../types/UUID'; import { setupI18n } from '../../../util/setupI18n'; import enMessages from '../../../../_locales/en/messages.json'; import type { PropsType } from './PendingInvites'; @@ -40,11 +41,13 @@ const conversation: ConversationType = { sharedGroupNames: [], }; +const OUR_UUID = UUID.generate().toString(); + const createProps = (): PropsType => ({ approvePendingMembership: action('approvePendingMembership'), conversation, i18n, - ourConversationId: 'abc123', + ourUuid: OUR_UUID, pendingApprovalMemberships: times(5, () => ({ member: getDefaultConversation(), })), @@ -52,13 +55,13 @@ const createProps = (): PropsType => ({ ...times(4, () => ({ member: getDefaultConversation(), metadata: { - addedByUserId: 'abc123', + addedByUserId: OUR_UUID, }, })), ...times(8, () => ({ member: getDefaultConversation(), metadata: { - addedByUserId: 'def456', + addedByUserId: UUID.generate().toString(), }, })), ], diff --git a/ts/components/conversation/conversation-details/PendingInvites.tsx b/ts/components/conversation/conversation-details/PendingInvites.tsx index fef2d83e7bb5..6ca802431c4d 100644 --- a/ts/components/conversation/conversation-details/PendingInvites.tsx +++ b/ts/components/conversation/conversation-details/PendingInvites.tsx @@ -7,6 +7,7 @@ import _ from 'lodash'; import type { ConversationType } from '../../../state/ducks/conversations'; import type { LocalizerType } from '../../../types/Util'; +import type { UUIDStringType } from '../../../types/UUID'; import { Avatar } from '../../Avatar'; import { ConfirmationDialog } from '../../ConfirmationDialog'; import { PanelSection } from './PanelSection'; @@ -16,7 +17,7 @@ import { ConversationDetailsIcon, IconType } from './ConversationDetailsIcon'; export type PropsType = { readonly conversation?: ConversationType; readonly i18n: LocalizerType; - readonly ourConversationId?: string; + readonly ourUuid?: UUIDStringType; readonly pendingApprovalMemberships: ReadonlyArray; readonly pendingMemberships: ReadonlyArray; readonly approvePendingMembership: (conversationId: string) => void; @@ -25,7 +26,7 @@ export type PropsType = { export type GroupV2PendingMembership = { metadata: { - addedByUserId?: string; + addedByUserId?: UUIDStringType; }; member: ConversationType; }; @@ -54,14 +55,14 @@ export const PendingInvites: React.ComponentType = ({ approvePendingMembership, conversation, i18n, - ourConversationId, + ourUuid, pendingMemberships, pendingApprovalMemberships, revokePendingMemberships, }) => { - if (!conversation || !ourConversationId) { + if (!conversation || !ourUuid) { throw new Error( - 'PendingInvites rendered without a conversation or ourConversationId' + 'PendingInvites rendered without a conversation or ourUuid' ); } @@ -131,7 +132,7 @@ export const PendingInvites: React.ComponentType = ({ i18n={i18n} members={conversation.sortedGroupMembers || []} memberships={pendingMemberships} - ourConversationId={ourConversationId} + ourUuid={ourUuid} setStagedMemberships={setStagedMemberships} /> ) : null} @@ -142,7 +143,7 @@ export const PendingInvites: React.ComponentType = ({ i18n={i18n} members={conversation.sortedGroupMembers || []} onClose={() => setStagedMemberships(null)} - ourConversationId={ourConversationId} + ourUuid={ourUuid} revokePendingMemberships={revokePendingMemberships} stagedMemberships={stagedMemberships} /> @@ -156,7 +157,7 @@ function MembershipActionConfirmation({ i18n, members, onClose, - ourConversationId, + ourUuid, revokePendingMemberships, stagedMemberships, }: { @@ -164,7 +165,7 @@ function MembershipActionConfirmation({ i18n: LocalizerType; members: Array; onClose: () => void; - ourConversationId: string; + ourUuid: string; revokePendingMemberships: (conversationIds: Array) => void; stagedMemberships: Array; }) { @@ -216,7 +217,7 @@ function MembershipActionConfirmation({ {getConfirmationMessage({ i18n, members, - ourConversationId, + ourUuid, stagedMemberships, })} @@ -226,12 +227,12 @@ function MembershipActionConfirmation({ function getConfirmationMessage({ i18n, members, - ourConversationId, + ourUuid, stagedMemberships, }: Readonly<{ i18n: LocalizerType; members: ReadonlyArray; - ourConversationId: string; + ourUuid: string; stagedMemberships: ReadonlyArray; }>): string { if (!stagedMemberships || !stagedMemberships.length) { @@ -261,8 +262,7 @@ function getConfirmationMessage({ const firstPendingMembership = firstMembership as GroupV2PendingMembership; // Pending invite - const invitedByUs = - firstPendingMembership.metadata.addedByUserId === ourConversationId; + const invitedByUs = firstPendingMembership.metadata.addedByUserId === ourUuid; if (invitedByUs) { return i18n('PendingInvites--revoke-for', { @@ -364,14 +364,14 @@ function MembersPendingProfileKey({ i18n, members, memberships, - ourConversationId, + ourUuid, setStagedMemberships, }: Readonly<{ conversation: ConversationType; i18n: LocalizerType; members: Array; memberships: ReadonlyArray; - ourConversationId: string; + ourUuid: string; setStagedMemberships: (stagedMembership: Array) => void; }>) { const groupedPendingMemberships = _.groupBy( @@ -380,7 +380,7 @@ function MembersPendingProfileKey({ ); const { - [ourConversationId]: ourPendingMemberships, + [ourUuid]: ourPendingMemberships, ...otherPendingMembershipGroups } = groupedPendingMemberships; diff --git a/ts/groupChange.ts b/ts/groupChange.ts index 5636cd5521bb..84dffffff126 100644 --- a/ts/groupChange.ts +++ b/ts/groupChange.ts @@ -4,13 +4,14 @@ import type { FullJSXType } from './components/Intl'; import type { LocalizerType } from './types/Util'; import type { ReplacementValuesType } from './types/I18N'; +import type { UUIDStringType } from './types/UUID'; import { missingCaseError } from './util/missingCaseError'; import type { GroupV2ChangeDetailType, GroupV2ChangeType } from './groups'; import { SignalService as Proto } from './protobuf'; import * as log from './logging/log'; -export type SmartContactRendererType = (conversationId: string) => FullJSXType; +export type SmartContactRendererType = (uuid: UUIDStringType) => FullJSXType; export type StringRendererType = ( id: string, i18n: LocalizerType, @@ -18,9 +19,9 @@ export type StringRendererType = ( ) => FullJSXType; export type RenderOptionsType = { - from?: string; + from?: UUIDStringType; i18n: LocalizerType; - ourConversationId: string; + ourUuid: UUIDStringType; renderContact: SmartContactRendererType; renderString: StringRendererType; }; @@ -46,14 +47,8 @@ export function renderChangeDetail( detail: GroupV2ChangeDetailType, options: RenderOptionsType ): FullJSXType { - const { - from, - i18n, - ourConversationId, - renderContact, - renderString, - } = options; - const fromYou = Boolean(from && from === ourConversationId); + const { from, i18n, ourUuid, renderContact, renderString } = options; + const fromYou = Boolean(from && from === ourUuid); if (detail.type === 'create') { if (fromYou) { @@ -214,8 +209,8 @@ export function renderChangeDetail( return ''; } if (detail.type === 'member-add') { - const { conversationId } = detail; - const weAreJoiner = conversationId === ourConversationId; + const { uuid } = detail; + const weAreJoiner = uuid === ourUuid; if (weAreJoiner) { if (fromYou) { @@ -230,25 +225,25 @@ export function renderChangeDetail( } if (fromYou) { return renderString('GroupV2--member-add--other--you', i18n, [ - renderContact(conversationId), + renderContact(uuid), ]); } if (from) { return renderString('GroupV2--member-add--other--other', i18n, { adderName: renderContact(from), - addeeName: renderContact(conversationId), + addeeName: renderContact(uuid), }); } return renderString('GroupV2--member-add--other--unknown', i18n, [ - renderContact(conversationId), + renderContact(uuid), ]); } if (detail.type === 'member-add-from-invite') { - const { conversationId, inviter } = detail; - const weAreJoiner = conversationId === ourConversationId; - const weAreInviter = Boolean(inviter && inviter === ourConversationId); + const { uuid, inviter } = detail; + const weAreJoiner = uuid === ourUuid; + const weAreInviter = Boolean(inviter && inviter === ourUuid); - if (!from || from !== conversationId) { + if (!from || from !== uuid) { if (weAreJoiner) { // They can't be the same, no fromYou check here if (from) { @@ -261,17 +256,17 @@ export function renderChangeDetail( if (fromYou) { return renderString('GroupV2--member-add--invited--you', i18n, { - inviteeName: renderContact(conversationId), + inviteeName: renderContact(uuid), }); } if (from) { return renderString('GroupV2--member-add--invited--other', i18n, { memberName: renderContact(from), - inviteeName: renderContact(conversationId), + inviteeName: renderContact(uuid), }); } return renderString('GroupV2--member-add--invited--unknown', i18n, { - inviteeName: renderContact(conversationId), + inviteeName: renderContact(uuid), }); } @@ -288,12 +283,12 @@ export function renderChangeDetail( } if (weAreInviter) { return renderString('GroupV2--member-add--from-invite--from-you', i18n, [ - renderContact(conversationId), + renderContact(uuid), ]); } if (inviter) { return renderString('GroupV2--member-add--from-invite--other', i18n, { - inviteeName: renderContact(conversationId), + inviteeName: renderContact(uuid), inviterName: renderContact(inviter), }); } @@ -301,17 +296,17 @@ export function renderChangeDetail( 'GroupV2--member-add--from-invite--other-no-from', i18n, { - inviteeName: renderContact(conversationId), + inviteeName: renderContact(uuid), } ); } if (detail.type === 'member-add-from-link') { - const { conversationId } = detail; + const { uuid } = detail; - if (fromYou && conversationId === ourConversationId) { + if (fromYou && uuid === ourUuid) { return renderString('GroupV2--member-add-from-link--you--you', i18n); } - if (from && conversationId === from) { + if (from && uuid === from) { return renderString('GroupV2--member-add-from-link--other', i18n, [ renderContact(from), ]); @@ -321,12 +316,12 @@ export function renderChangeDetail( // from group change events, which always have a sender. log.warn('member-add-from-link change type; we have no from!'); return renderString('GroupV2--member-add--other--unknown', i18n, [ - renderContact(conversationId), + renderContact(uuid), ]); } if (detail.type === 'member-add-from-admin-approval') { - const { conversationId } = detail; - const weAreJoiner = conversationId === ourConversationId; + const { uuid } = detail; + const weAreJoiner = uuid === ourUuid; if (weAreJoiner) { if (from) { @@ -352,7 +347,7 @@ export function renderChangeDetail( return renderString( 'GroupV2--member-add-from-admin-approval--other--you', i18n, - [renderContact(conversationId)] + [renderContact(uuid)] ); } if (from) { @@ -361,7 +356,7 @@ export function renderChangeDetail( i18n, { adminName: renderContact(from), - joinerName: renderContact(conversationId), + joinerName: renderContact(uuid), } ); } @@ -372,12 +367,12 @@ export function renderChangeDetail( return renderString( 'GroupV2--member-add-from-admin-approval--other--unknown', i18n, - [renderContact(conversationId)] + [renderContact(uuid)] ); } if (detail.type === 'member-remove') { - const { conversationId } = detail; - const weAreLeaver = conversationId === ourConversationId; + const { uuid } = detail; + const weAreLeaver = uuid === ourUuid; if (weAreLeaver) { if (fromYou) { @@ -393,10 +388,10 @@ export function renderChangeDetail( if (fromYou) { return renderString('GroupV2--member-remove--other--you', i18n, [ - renderContact(conversationId), + renderContact(uuid), ]); } - if (from && from === conversationId) { + if (from && from === uuid) { return renderString('GroupV2--member-remove--other--self', i18n, [ renderContact(from), ]); @@ -404,16 +399,16 @@ export function renderChangeDetail( if (from) { return renderString('GroupV2--member-remove--other--other', i18n, { adminName: renderContact(from), - memberName: renderContact(conversationId), + memberName: renderContact(uuid), }); } return renderString('GroupV2--member-remove--other--unknown', i18n, [ - renderContact(conversationId), + renderContact(uuid), ]); } if (detail.type === 'member-privilege') { - const { conversationId, newPrivilege } = detail; - const weAreMember = conversationId === ourConversationId; + const { uuid, newPrivilege } = detail; + const weAreMember = uuid === ourUuid; if (newPrivilege === RoleEnum.ADMINISTRATOR) { if (weAreMember) { @@ -435,7 +430,7 @@ export function renderChangeDetail( return renderString( 'GroupV2--member-privilege--promote--other--you', i18n, - [renderContact(conversationId)] + [renderContact(uuid)] ); } if (from) { @@ -444,14 +439,14 @@ export function renderChangeDetail( i18n, { adminName: renderContact(from), - memberName: renderContact(conversationId), + memberName: renderContact(uuid), } ); } return renderString( 'GroupV2--member-privilege--promote--other--unknown', i18n, - [renderContact(conversationId)] + [renderContact(uuid)] ); } if (newPrivilege === RoleEnum.DEFAULT) { @@ -473,7 +468,7 @@ export function renderChangeDetail( return renderString( 'GroupV2--member-privilege--demote--other--you', i18n, - [renderContact(conversationId)] + [renderContact(uuid)] ); } if (from) { @@ -482,14 +477,14 @@ export function renderChangeDetail( i18n, { adminName: renderContact(from), - memberName: renderContact(conversationId), + memberName: renderContact(uuid), } ); } return renderString( 'GroupV2--member-privilege--demote--other--unknown', i18n, - [renderContact(conversationId)] + [renderContact(uuid)] ); } log.warn( @@ -498,8 +493,8 @@ export function renderChangeDetail( return ''; } if (detail.type === 'pending-add-one') { - const { conversationId } = detail; - const weAreInvited = conversationId === ourConversationId; + const { uuid } = detail; + const weAreInvited = uuid === ourUuid; if (weAreInvited) { if (from) { return renderString('GroupV2--pending-add--one--you--other', i18n, [ @@ -510,7 +505,7 @@ export function renderChangeDetail( } if (fromYou) { return renderString('GroupV2--pending-add--one--other--you', i18n, [ - renderContact(conversationId), + renderContact(uuid), ]); } if (from) { @@ -539,23 +534,23 @@ export function renderChangeDetail( ]); } if (detail.type === 'pending-remove-one') { - const { inviter, conversationId } = detail; - const weAreInviter = Boolean(inviter && inviter === ourConversationId); - const weAreInvited = conversationId === ourConversationId; - const sentByInvited = Boolean(from && from === conversationId); + const { inviter, uuid } = detail; + const weAreInviter = Boolean(inviter && inviter === ourUuid); + const weAreInvited = uuid === ourUuid; + const sentByInvited = Boolean(from && from === uuid); const sentByInviter = Boolean(from && inviter && from === inviter); if (weAreInviter) { if (sentByInvited) { return renderString('GroupV2--pending-remove--decline--you', i18n, [ - renderContact(conversationId), + renderContact(uuid), ]); } if (fromYou) { return renderString( 'GroupV2--pending-remove--revoke-invite-from-you--one--you', i18n, - [renderContact(conversationId)] + [renderContact(uuid)] ); } if (from) { @@ -564,14 +559,14 @@ export function renderChangeDetail( i18n, { adminName: renderContact(from), - inviteeName: renderContact(conversationId), + inviteeName: renderContact(uuid), } ); } return renderString( 'GroupV2--pending-remove--revoke-invite-from-you--one--unknown', i18n, - [renderContact(conversationId)] + [renderContact(uuid)] ); } if (sentByInvited) { @@ -635,7 +630,7 @@ export function renderChangeDetail( } if (detail.type === 'pending-remove-many') { const { count, inviter } = detail; - const weAreInviter = Boolean(inviter && inviter === ourConversationId); + const weAreInviter = Boolean(inviter && inviter === ourUuid); if (weAreInviter) { if (fromYou) { @@ -714,19 +709,19 @@ export function renderChangeDetail( ); } if (detail.type === 'admin-approval-add-one') { - const { conversationId } = detail; - const weAreJoiner = conversationId === ourConversationId; + const { uuid } = detail; + const weAreJoiner = uuid === ourUuid; if (weAreJoiner) { return renderString('GroupV2--admin-approval-add-one--you', i18n); } return renderString('GroupV2--admin-approval-add-one--other', i18n, [ - renderContact(conversationId), + renderContact(uuid), ]); } if (detail.type === 'admin-approval-remove-one') { - const { conversationId } = detail; - const weAreJoiner = conversationId === ourConversationId; + const { uuid } = detail; + const weAreJoiner = uuid === ourUuid; if (weAreJoiner) { if (fromYou) { @@ -745,14 +740,14 @@ export function renderChangeDetail( return renderString( 'GroupV2--admin-approval-remove-one--other--you', i18n, - [renderContact(conversationId)] + [renderContact(uuid)] ); } - if (from && from === conversationId) { + if (from && from === uuid) { return renderString( 'GroupV2--admin-approval-remove-one--other--own', i18n, - [renderContact(conversationId)] + [renderContact(uuid)] ); } if (from) { @@ -761,7 +756,7 @@ export function renderChangeDetail( i18n, { adminName: renderContact(from), - joinerName: renderContact(conversationId), + joinerName: renderContact(uuid), } ); } @@ -771,7 +766,7 @@ export function renderChangeDetail( return renderString( 'GroupV2--admin-approval-remove-one--other--own', i18n, - [renderContact(conversationId)] + [renderContact(uuid)] ); } if (detail.type === 'group-link-add') { diff --git a/ts/groups.ts b/ts/groups.ts index 11631ea30628..ab920d481fcd 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -1,7 +1,6 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { Dictionary } from 'lodash'; import { compact, difference, @@ -74,6 +73,8 @@ import { handleMessageSend } from './util/handleMessageSend'; import { getSendOptions } from './util/getSendOptions'; import * as Bytes from './Bytes'; import type { AvatarDataType } from './types/Avatar'; +import { UUID, isValidUuid } from './types/UUID'; +import type { UUIDStringType } from './types/UUID'; import { SignalService as Proto } from './protobuf'; import AccessRequiredEnum = Proto.AccessControl.AccessRequired; @@ -122,34 +123,34 @@ type GroupV2GroupLinkRemoveChangeType = { type GroupV2MemberAddChangeType = { type: 'member-add'; - conversationId: string; + uuid: UUIDStringType; }; type GroupV2MemberAddFromInviteChangeType = { type: 'member-add-from-invite'; - conversationId: string; - inviter?: string; + uuid: UUIDStringType; + inviter?: UUIDStringType; }; type GroupV2MemberAddFromLinkChangeType = { type: 'member-add-from-link'; - conversationId: string; + uuid: UUIDStringType; }; type GroupV2MemberAddFromAdminApprovalChangeType = { type: 'member-add-from-admin-approval'; - conversationId: string; + uuid: UUIDStringType; }; type GroupV2MemberPrivilegeChangeType = { type: 'member-privilege'; - conversationId: string; + uuid: UUIDStringType; newPrivilege: number; }; type GroupV2MemberRemoveChangeType = { type: 'member-remove'; - conversationId: string; + uuid: UUIDStringType; }; type GroupV2PendingAddOneChangeType = { type: 'pending-add-one'; - conversationId: string; + uuid: UUIDStringType; }; type GroupV2PendingAddManyChangeType = { type: 'pending-add-many'; @@ -158,26 +159,26 @@ type GroupV2PendingAddManyChangeType = { // Note: pending-remove is only used if user didn't also join the group at the same time type GroupV2PendingRemoveOneChangeType = { type: 'pending-remove-one'; - conversationId: string; - inviter?: string; + uuid: UUIDStringType; + inviter?: UUIDStringType; }; // Note: pending-remove is only used if user didn't also join the group at the same time type GroupV2PendingRemoveManyChangeType = { type: 'pending-remove-many'; count: number; - inviter?: string; + inviter?: UUIDStringType; }; type GroupV2AdminApprovalAddOneChangeType = { type: 'admin-approval-add-one'; - conversationId: string; + uuid: UUIDStringType; }; // Note: admin-approval-remove-one is only used if user didn't also join the group at // the same time type GroupV2AdminApprovalRemoveOneChangeType = { type: 'admin-approval-remove-one'; - conversationId: string; - inviter?: string; + uuid: UUIDStringType; + inviter?: UUIDStringType; }; export type GroupV2DescriptionChangeType = { type: 'description'; @@ -212,7 +213,7 @@ export type GroupV2ChangeDetailType = | GroupV2TitleChangeType; export type GroupV2ChangeType = { - from?: string; + from?: UUIDStringType; details: Array; }; @@ -238,7 +239,7 @@ if (!isNumber(MAX_MESSAGE_SCHEMA)) { type MemberType = { profileKey: string; - uuid: string; + uuid: UUIDStringType; }; type UpdatesResultType = { // The array of new messages to be added into the message timeline @@ -513,7 +514,7 @@ function buildGroupProto( proto.members = (attributes.membersV2 || []).map(item => { const member = new Proto.Member(); - const conversation = window.ConversationController.get(item.conversationId); + const conversation = window.ConversationController.get(item.uuid); if (!conversation) { throw new Error(`buildGroupProto/${logId}: no conversation for member!`); } @@ -536,13 +537,6 @@ function buildGroupProto( return member; }); - const ourConversationId = window.ConversationController.getOurConversationId(); - if (!ourConversationId) { - throw new Error( - `buildGroupProto/${logId}: unable to find our own conversationId!` - ); - } - const ourUuid = window.storage.user.getCheckedUuid(); const ourUuidCipherTextBuffer = encryptUuid( @@ -555,9 +549,7 @@ function buildGroupProto( const pendingMember = new Proto.MemberPendingProfileKey(); const member = new Proto.Member(); - const conversation = window.ConversationController.get( - item.conversationId - ); + const conversation = window.ConversationController.get(item.uuid); if (!conversation) { throw new Error('buildGroupProto: no conversation for pending member!'); } @@ -938,7 +930,7 @@ export function buildDeletePendingAdminApprovalMemberChange({ uuid, }: { group: ConversationAttributesType; - uuid: string; + uuid: UUIDStringType; }): Proto.GroupChange.Actions { const actions = new Proto.GroupChange.Actions(); @@ -1043,7 +1035,7 @@ export function buildDeletePendingMemberChange({ uuids, group, }: { - uuids: Array; + uuids: Array; group: ConversationAttributesType; }): Proto.GroupChange.Actions { const actions = new Proto.GroupChange.Actions(); @@ -1072,7 +1064,7 @@ export function buildDeleteMemberChange({ uuid, group, }: { - uuid: string; + uuid: UUIDStringType; group: ConversationAttributesType; }): Proto.GroupChange.Actions { const actions = new Proto.GroupChange.Actions(); @@ -1097,7 +1089,7 @@ export function buildModifyMemberRoleChange({ group, role, }: { - uuid: string; + uuid: UUIDStringType; group: ConversationAttributesType; role: number; }): Proto.GroupChange.Actions { @@ -1125,7 +1117,7 @@ export function buildPromotePendingAdminApprovalMemberChange({ uuid, }: { group: ConversationAttributesType; - uuid: string; + uuid: UUIDStringType; }): Proto.GroupChange.Actions { const MEMBER_ROLE_ENUM = Proto.Member.Role; const actions = new Proto.GroupChange.Actions(); @@ -1513,17 +1505,11 @@ export async function createGroupV2({ const secretParams = Bytes.toBase64(fields.secretParams); const publicParams = Bytes.toBase64(fields.publicParams); - const ourConversationId = window.ConversationController.getOurConversationIdOrThrow(); - const ourConversation = window.ConversationController.get(ourConversationId); - if (!ourConversation) { - throw new Error( - `createGroupV2/${logId}: cannot get our own conversation. Cannot create the group` - ); - } + const ourUuid = window.storage.user.getCheckedUuid().toString(); const membersV2: Array = [ { - conversationId: ourConversationId, + uuid: ourUuid, role: MEMBER_ROLE_ENUM.ADMINISTRATOR, joinedAtVersion: 0, }, @@ -1543,7 +1529,8 @@ export async function createGroupV2({ return; } - if (!contact.get('uuid')) { + const contactUuid = contact.get('uuid'); + if (!contactUuid) { assert(false, `createGroupV2/${logId}: missing UUID; skipping`); return; } @@ -1567,14 +1554,14 @@ export async function createGroupV2({ if (contact.get('profileKey') && contact.get('profileKeyCredential')) { membersV2.push({ - conversationId, + uuid: contactUuid, role: MEMBER_ROLE_ENUM.DEFAULT, joinedAtVersion: 0, }); } else { pendingMembersV2.push({ - addedByUserId: ourConversationId, - conversationId, + addedByUserId: ourUuid, + uuid: contactUuid, timestamp: Date.now(), role: MEMBER_ROLE_ENUM.DEFAULT, }); @@ -1656,7 +1643,7 @@ export async function createGroupV2({ { ...protoAndConversationAttributes, active_at: now, - addedBy: ourConversationId, + addedBy: ourUuid, avatar: avatarAttribute, avatars, groupVersion: 2, @@ -1701,18 +1688,16 @@ export async function createGroupV2({ timestamp, }); - const ourUuid = window.storage.user.getCheckedUuid(); - const createdTheGroupMessage: MessageAttributesType = { ...generateBasicMessage(), type: 'group-v2-change', - sourceUuid: ourUuid.toString(), + sourceUuid: ourUuid, conversationId: conversation.id, received_at: window.Signal.Util.incrementMessageCounter(), received_at_ms: timestamp, sent_at: timestamp, groupV2Change: { - from: ourConversationId, + from: ourUuid, details: [{ type: 'create' }], }, }; @@ -1806,11 +1791,9 @@ export async function isGroupEligibleToMigrate( return false; } - const ourConversationId = window.ConversationController.getOurConversationId(); + const ourUuid = window.storage.user.getCheckedUuid().toString(); const areWeMember = - !conversation.get('left') && - ourConversationId && - conversation.hasMember(ourConversationId); + !conversation.get('left') && conversation.hasMember(ourUuid); if (!areWeMember) { return false; } @@ -1849,6 +1832,8 @@ export async function getGroupMigrationMembers( ); } + const ourUuid = window.storage.user.getCheckedUuid().toString(); + let areWeMember = false; let areWeInvited = false; @@ -1872,7 +1857,8 @@ export async function getGroupMigrationMembers( return null; } - if (!contact.get('uuid')) { + const contactUuid = contact.get('uuid'); + if (!contactUuid) { log.warn( `getGroupMigrationMembers/${logId}: membersV2 - missing uuid for ${e164}, skipping.` ); @@ -1927,7 +1913,7 @@ export async function getGroupMigrationMembers( memberLookup[conversationId] = true; return { - conversationId, + uuid: contactUuid, role: MEMBER_ROLE_ENUM.ADMINISTRATOR, joinedAtVersion: 0, }; @@ -1960,7 +1946,8 @@ export async function getGroupMigrationMembers( return null; } - if (!contact.get('uuid')) { + const contactUuid = contact.get('uuid'); + if (!contactUuid) { log.warn( `getGroupMigrationMembers/${logId}: pendingMembersV2 - missing uuid for ${e164}, skipping.` ); @@ -1989,9 +1976,9 @@ export async function getGroupMigrationMembers( } return { - conversationId, + uuid: contactUuid, timestamp: now, - addedByUserId: ourConversationId, + addedByUserId: ourUuid, role: MEMBER_ROLE_ENUM.ADMINISTRATOR, }; }) @@ -2336,23 +2323,30 @@ export function buildMigrationBubble( previousGroupV1MembersIds: Array, newAttributes: ConversationAttributesType ): MessageAttributesType { - const ourConversationId = window.ConversationController.getOurConversationIdOrThrow(); + const ourUuid = window.storage.user.getCheckedUuid().toString(); + const ourConversationId = window.ConversationController.getOurConversationId(); // Assemble items to commemorate this event for the timeline.. const combinedConversationIds: Array = [ - ...(newAttributes.membersV2 || []).map(item => item.conversationId), - ...(newAttributes.pendingMembersV2 || []).map(item => item.conversationId), - ]; + ...(newAttributes.membersV2 || []).map(item => item.uuid), + ...(newAttributes.pendingMembersV2 || []).map(item => item.uuid), + ].map(uuid => { + const conversationId = window.ConversationController.ensureContactIds({ + uuid, + }); + strictAssert(conversationId, `Conversation not found for ${uuid}`); + return conversationId; + }); const droppedMemberIds: Array = difference( previousGroupV1MembersIds, combinedConversationIds ).filter(id => id && id !== ourConversationId); const invitedMembers = (newAttributes.pendingMembersV2 || []).filter( - item => item.conversationId !== ourConversationId + item => item.uuid !== ourUuid ); const areWeInvited = (newAttributes.pendingMembersV2 || []).some( - item => item.conversationId === ourConversationId + item => item.uuid === ourUuid ); return { @@ -2476,11 +2470,9 @@ export async function respondToGroupV2Migration({ ); } - const ourConversationId = window.ConversationController.getOurConversationIdOrThrow(); + const ourUuid = window.storage.user.getCheckedUuid().toString(); const wereWePreviouslyAMember = - !conversation.get('left') && - ourConversationId && - conversation.hasMember(ourConversationId); + !conversation.get('left') && conversation.hasMember(ourUuid); // Derive GroupV2 fields const groupV1IdBuffer = Bytes.fromBinary(previousGroupV1Id); @@ -2566,7 +2558,7 @@ export async function respondToGroupV2Migration({ ...conversation.attributes, left: true, members: (conversation.get('members') || []).filter( - item => item !== ourConversationId && item !== ourNumber + item => item !== ourUuid && item !== ourNumber ), }, groupChangeMessages: [ @@ -2577,7 +2569,7 @@ export async function respondToGroupV2Migration({ details: [ { type: 'member-remove' as const, - conversationId: ourConversationId, + uuid: ourUuid, }, ], }, @@ -2619,10 +2611,10 @@ export async function respondToGroupV2Migration({ ); const areWeInvited = (newAttributes.pendingMembersV2 || []).some( - item => item.conversationId === ourConversationId + item => item.uuid === ourUuid ); const areWeMember = (newAttributes.membersV2 || []).some( - item => item.conversationId === ourConversationId + item => item.uuid === ourUuid ); if (!areWeInvited && !areWeMember) { // Add a message to the timeline saying the user was removed. This shouldn't happen. @@ -2633,7 +2625,7 @@ export async function respondToGroupV2Migration({ details: [ { type: 'member-remove' as const, - conversationId: ourConversationId, + uuid: ourUuid, }, ], }, @@ -2892,11 +2884,11 @@ async function getGroupUpdates({ const currentRevision = group.revision; const isFirstFetch = !isNumber(group.revision); - const ourConversationId = window.ConversationController.getOurConversationId(); + const ourUuid = window.storage.user.getCheckedUuid().toString(); const isInitialCreationMessage = isFirstFetch && newRevision === 0; const weAreAwaitingApproval = (group.pendingAdminApprovalV2 || []).find( - item => item.conversationId === ourConversationId + item => item.uuid === ourUuid ); const isOneVersionUp = isNumber(currentRevision) && @@ -3125,12 +3117,7 @@ async function generateLeftGroupChanges( ): Promise { const logId = idForLogging(group.groupId); log.info(`generateLeftGroupChanges/${logId}: Starting...`); - const ourConversationId = window.ConversationController.getOurConversationId(); - if (!ourConversationId) { - throw new Error( - 'generateLeftGroupChanges: We do not have a conversationId!' - ); - } + const ourUuid = window.storage.user.getCheckedUuid().toString(); const { masterKey, groupInviteLinkPassword } = group; let { revision } = group; @@ -3157,9 +3144,7 @@ async function generateLeftGroupChanges( const existingMembers = group.membersV2 || []; const newAttributes: ConversationAttributesType = { ...group, - membersV2: existingMembers.filter( - member => member.conversationId !== ourConversationId - ), + membersV2: existingMembers.filter(member => member.uuid !== ourUuid), left: true, revision, }; @@ -3173,7 +3158,7 @@ async function generateLeftGroupChanges( details: [ { type: 'member-remove' as const, - conversationId: ourConversationId, + uuid: ourUuid, }, ], }, @@ -3384,9 +3369,9 @@ async function integrateGroupChange({ } const isFirstFetch = !isNumber(group.revision); - const ourConversationId = window.ConversationController.getOurConversationIdOrThrow(); + const ourUuid = window.storage.user.getCheckedUuid().toString(); const weAreAwaitingApproval = (group.pendingAdminApprovalV2 || []).find( - item => item.conversationId === ourConversationId + item => item.uuid === ourUuid ); // These need to be populated from the groupChange. But we might not get one! @@ -3394,7 +3379,7 @@ async function integrateGroupChange({ let isMoreThanOneVersionUp = false; let groupChangeActions: undefined | Proto.GroupChange.IActions; let decryptedChangeActions: undefined | DecryptedGroupChangeActions; - let sourceConversationId: undefined | string; + let sourceUuid: undefined | UUIDStringType; if (groupChange) { groupChangeActions = Proto.GroupChange.Actions.decode( @@ -3422,13 +3407,8 @@ async function integrateGroupChange({ decryptedChangeActions !== undefined, 'Should have decrypted group actions' ); - const { sourceUuid } = decryptedChangeActions; + ({ sourceUuid } = decryptedChangeActions); strictAssert(sourceUuid, 'Should have source UUID'); - const sourceConversation = window.ConversationController.getOrCreate( - sourceUuid, - 'private' - ); - sourceConversationId = sourceConversation.id; isChangeSupported = !isNumber(groupChange.changeEpoch) || @@ -3473,7 +3453,7 @@ async function integrateGroupChange({ const { newAttributes, newProfileKeys } = await applyGroupState({ group, groupState: decryptedGroupState, - sourceConversationId: isFirstFetch ? sourceConversationId : undefined, + sourceUuid: isFirstFetch ? sourceUuid : undefined, }); return { @@ -3481,13 +3461,13 @@ async function integrateGroupChange({ groupChangeMessages: extractDiffs({ old: group, current: newAttributes, - sourceConversationId: isFirstFetch ? sourceConversationId : undefined, + sourceUuid: isFirstFetch ? sourceUuid : undefined, }), members: profileKeysToMembers(newProfileKeys), }; } - if (!sourceConversationId || !groupChangeActions || !decryptedChangeActions) { + if (!sourceUuid || !groupChangeActions || !decryptedChangeActions) { throw new Error( `integrateGroupChange/${logId}: Missing necessary information that should have come from group actions` ); @@ -3500,12 +3480,12 @@ async function integrateGroupChange({ const { newAttributes, newProfileKeys } = await applyGroupChange({ group, actions: decryptedChangeActions, - sourceConversationId, + sourceUuid, }); const groupChangeMessages = extractDiffs({ old: group, current: newAttributes, - sourceConversationId, + sourceUuid, }); return { @@ -3577,16 +3557,16 @@ function extractDiffs({ current, dropInitialJoinMessage, old, - sourceConversationId, + sourceUuid, }: { current: ConversationAttributesType; dropInitialJoinMessage?: boolean; old: ConversationAttributesType; - sourceConversationId?: string; + sourceUuid?: UUIDStringType; }): Array { const logId = idForLogging(old.groupId); const details: Array = []; - const ourConversationId = window.ConversationController.getOurConversationId(); + const ourUuid = window.storage.user.getCheckedUuid().toString(); const ACCESS_ENUM = Proto.AccessControl.AccessRequired; let areWeInGroup = false; @@ -3694,55 +3674,54 @@ function extractDiffs({ // membersV2 - const oldMemberLookup: Dictionary = fromPairs( - (old.membersV2 || []).map(member => [member.conversationId, member]) - ); - const oldPendingMemberLookup: Dictionary = fromPairs( - (old.pendingMembersV2 || []).map(member => [member.conversationId, member]) - ); - const oldPendingAdminApprovalLookup: Dictionary = fromPairs( - (old.pendingAdminApprovalV2 || []).map(member => [ - member.conversationId, - member, - ]) + const oldMemberLookup = new Map( + (old.membersV2 || []).map(member => [member.uuid, member]) ); + const oldPendingMemberLookup = new Map< + UUIDStringType, + GroupV2PendingMemberType + >((old.pendingMembersV2 || []).map(member => [member.uuid, member])); + const oldPendingAdminApprovalLookup = new Map< + UUIDStringType, + GroupV2PendingAdminApprovalType + >((old.pendingAdminApprovalV2 || []).map(member => [member.uuid, member])); (current.membersV2 || []).forEach(currentMember => { - const { conversationId } = currentMember; + const { uuid } = currentMember; - if (ourConversationId && conversationId === ourConversationId) { + if (uuid === ourUuid) { areWeInGroup = true; } - const oldMember = oldMemberLookup[conversationId]; + const oldMember = oldMemberLookup.get(uuid); if (!oldMember) { - const pendingMember = oldPendingMemberLookup[conversationId]; + const pendingMember = oldPendingMemberLookup.get(uuid); if (pendingMember) { details.push({ type: 'member-add-from-invite', - conversationId, + uuid, inviter: pendingMember.addedByUserId, }); } else if (currentMember.joinedFromLink) { details.push({ type: 'member-add-from-link', - conversationId, + uuid, }); } else if (currentMember.approvedByAdmin) { details.push({ type: 'member-add-from-admin-approval', - conversationId, + uuid, }); } else { details.push({ type: 'member-add', - conversationId, + uuid, }); } } else if (oldMember.role !== currentMember.role) { details.push({ type: 'member-privilege', - conversationId, + uuid, newPrivilege: currentMember.role, }); } @@ -3750,44 +3729,44 @@ function extractDiffs({ // We don't want to generate an admin-approval-remove event for this newly-added // member. But we don't know for sure if this is an admin approval; for that we // consulted the approvedByAdmin flag saved on the member. - delete oldPendingAdminApprovalLookup[conversationId]; + oldPendingAdminApprovalLookup.delete(uuid); // If we capture a pending remove here, it's an 'accept invitation', and we don't // want to generate a pending-remove event for it - delete oldPendingMemberLookup[conversationId]; + oldPendingMemberLookup.delete(uuid); // This deletion makes it easier to capture removals - delete oldMemberLookup[conversationId]; + oldMemberLookup.delete(uuid); }); - const removedMemberIds = Object.keys(oldMemberLookup); - removedMemberIds.forEach(conversationId => { + const removedMemberIds = Array.from(oldMemberLookup.keys()); + removedMemberIds.forEach(uuid => { details.push({ type: 'member-remove', - conversationId, + uuid, }); }); // pendingMembersV2 - let lastPendingConversationId: string | undefined; + let lastPendingUuid: UUIDStringType | undefined; let pendingCount = 0; (current.pendingMembersV2 || []).forEach(currentPendingMember => { - const { conversationId } = currentPendingMember; - const oldPendingMember = oldPendingMemberLookup[conversationId]; + const { uuid } = currentPendingMember; + const oldPendingMember = oldPendingMemberLookup.get(uuid); - if (ourConversationId && conversationId === ourConversationId) { + if (uuid === ourUuid) { areWeInvitedToGroup = true; whoInvitedUsUserId = currentPendingMember.addedByUserId; } if (!oldPendingMember) { - lastPendingConversationId = conversationId; + lastPendingUuid = uuid; pendingCount += 1; } // This deletion makes it easier to capture removals - delete oldPendingMemberLookup[conversationId]; + oldPendingMemberLookup.delete(uuid); }); if (pendingCount > 1) { @@ -3796,10 +3775,10 @@ function extractDiffs({ count: pendingCount, }); } else if (pendingCount === 1) { - if (lastPendingConversationId) { + if (lastPendingUuid) { details.push({ type: 'pending-add-one', - conversationId: lastPendingConversationId, + uuid: lastPendingUuid, }); } else { log.warn( @@ -3810,13 +3789,17 @@ function extractDiffs({ // Note: The only members left over here should be people who were moved from the // pending list but also not added to the group at the same time. - const removedPendingMemberIds = Object.keys(oldPendingMemberLookup); + const removedPendingMemberIds = Array.from(oldPendingMemberLookup.keys()); if (removedPendingMemberIds.length > 1) { - const firstConversationId = removedPendingMemberIds[0]; - const firstRemovedMember = oldPendingMemberLookup[firstConversationId]; + const firstUuid = removedPendingMemberIds[0]; + const firstRemovedMember = oldPendingMemberLookup.get(firstUuid); + strictAssert( + firstRemovedMember !== undefined, + 'First removed member not found' + ); const inviter = firstRemovedMember.addedByUserId; const allSameInviter = removedPendingMemberIds.every( - id => oldPendingMemberLookup[id].addedByUserId === inviter + id => oldPendingMemberLookup.get(id)?.addedByUserId === inviter ); details.push({ type: 'pending-remove-many', @@ -3824,12 +3807,13 @@ function extractDiffs({ inviter: allSameInviter ? inviter : undefined, }); } else if (removedPendingMemberIds.length === 1) { - const conversationId = removedPendingMemberIds[0]; - const removedMember = oldPendingMemberLookup[conversationId]; + const uuid = removedPendingMemberIds[0]; + const removedMember = oldPendingMemberLookup.get(uuid); + strictAssert(removedMember !== undefined, 'Removed member not found'); details.push({ type: 'pending-remove-one', - conversationId, + uuid, inviter: removedMember.addedByUserId, }); } @@ -3838,30 +3822,30 @@ function extractDiffs({ (current.pendingAdminApprovalV2 || []).forEach( currentPendingAdminAprovalMember => { - const { conversationId } = currentPendingAdminAprovalMember; - const oldPendingMember = oldPendingAdminApprovalLookup[conversationId]; + const { uuid } = currentPendingAdminAprovalMember; + const oldPendingMember = oldPendingAdminApprovalLookup.get(uuid); if (!oldPendingMember) { details.push({ type: 'admin-approval-add-one', - conversationId, + uuid, }); } // This deletion makes it easier to capture removals - delete oldPendingAdminApprovalLookup[conversationId]; + oldPendingAdminApprovalLookup.delete(uuid); } ); // Note: The only members left over here should be people who were moved from the // pendingAdminApproval list but also not added to the group at the same time. - const removedPendingAdminApprovalIds = Object.keys( - oldPendingAdminApprovalLookup + const removedPendingAdminApprovalIds = Array.from( + oldPendingAdminApprovalLookup.keys() ); - removedPendingAdminApprovalIds.forEach(conversationId => { + removedPendingAdminApprovalIds.forEach(uuid => { details.push({ type: 'admin-approval-remove-one', - conversationId, + uuid, }); }); @@ -3878,27 +3862,23 @@ function extractDiffs({ let message: MessageAttributesType | undefined; let timerNotification: MessageAttributesType | undefined; - const conversation = sourceConversationId - ? window.ConversationController.get(sourceConversationId) - : null; - const sourceUuid = conversation ? conversation.get('uuid') : undefined; const firstUpdate = !isNumber(old.revision); // Here we hardcode initial messages if this is our first time processing data this // group. Ideally we can collapse it down to just one of: 'you were added', // 'you were invited', or 'you created.' - if (firstUpdate && ourConversationId && areWeInvitedToGroup) { + if (firstUpdate && areWeInvitedToGroup) { // Note, we will add 'you were invited' to group even if dropInitialJoinMessage = true message = { ...generateBasicMessage(), type: 'group-v2-change', groupV2Change: { - from: whoInvitedUsUserId || sourceConversationId, + from: whoInvitedUsUserId || sourceUuid, details: [ { type: 'pending-add-one', - conversationId: ourConversationId, + uuid: ourUuid, }, ], }, @@ -3906,17 +3886,12 @@ function extractDiffs({ } else if (firstUpdate && dropInitialJoinMessage) { // None of the rest of the messages should be added if dropInitialJoinMessage = true message = undefined; - } else if ( - firstUpdate && - ourConversationId && - sourceConversationId && - sourceConversationId === ourConversationId - ) { + } else if (firstUpdate && sourceUuid && sourceUuid === ourUuid) { message = { ...generateBasicMessage(), type: 'group-v2-change', groupV2Change: { - from: sourceConversationId, + from: sourceUuid, details: [ { type: 'create', @@ -3924,16 +3899,16 @@ function extractDiffs({ ], }, }; - } else if (firstUpdate && ourConversationId && areWeInGroup) { + } else if (firstUpdate && areWeInGroup) { message = { ...generateBasicMessage(), type: 'group-v2-change', groupV2Change: { - from: sourceConversationId, + from: sourceUuid, details: [ { type: 'member-add', - conversationId: ourConversationId, + uuid: ourUuid, }, ], }, @@ -3943,7 +3918,7 @@ function extractDiffs({ ...generateBasicMessage(), type: 'group-v2-change', groupV2Change: { - from: sourceConversationId, + from: sourceUuid, details: [ { type: 'create', @@ -3957,7 +3932,7 @@ function extractDiffs({ type: 'group-v2-change', sourceUuid, groupV2Change: { - from: sourceConversationId, + from: sourceUuid, details, }, }; @@ -4003,7 +3978,7 @@ function profileKeysToMembers(items: Array) { type GroupChangeMemberType = { profileKey: Uint8Array; - uuid: string; + uuid: UUIDStringType; }; type GroupApplyResultType = { newAttributes: ConversationAttributesType; @@ -4013,14 +3988,14 @@ type GroupApplyResultType = { async function applyGroupChange({ actions, group, - sourceConversationId, + sourceUuid, }: { actions: DecryptedGroupChangeActions; group: ConversationAttributesType; - sourceConversationId: string; + sourceUuid: UUIDStringType; }): Promise { const logId = idForLogging(group.groupId); - const ourConversationId = window.ConversationController.getOurConversationId(); + const ourUuid = window.storage.user.getUuid()?.toString(); const ACCESS_ENUM = Proto.AccessControl.AccessRequired; const MEMBER_ROLE_ENUM = Proto.Member.Role; @@ -4029,20 +4004,20 @@ async function applyGroupChange({ const result = { ...group }; const newProfileKeys: Array = []; - const members: Dictionary = fromPairs( - (result.membersV2 || []).map(member => [member.conversationId, member]) + const members: Record = fromPairs( + (result.membersV2 || []).map(member => [member.uuid, member]) ); - const pendingMembers: Dictionary = fromPairs( - (result.pendingMembersV2 || []).map(member => [ - member.conversationId, - member, - ]) + const pendingMembers: Record< + UUIDStringType, + GroupV2PendingMemberType + > = fromPairs( + (result.pendingMembersV2 || []).map(member => [member.uuid, member]) ); - const pendingAdminApprovalMembers: Dictionary = fromPairs( - (result.pendingAdminApprovalV2 || []).map(member => [ - member.conversationId, - member, - ]) + const pendingAdminApprovalMembers: Record< + UUIDStringType, + GroupV2PendingAdminApprovalType + > = fromPairs( + (result.pendingAdminApprovalV2 || []).map(member => [member.uuid, member]) ); // version?: number; @@ -4051,49 +4026,42 @@ async function applyGroupChange({ // addMembers?: Array; (actions.addMembers || []).forEach(addMember => { const { added } = addMember; - if (!added) { + if (!added || !added.userId) { throw new Error('applyGroupChange: addMember.added is missing'); } - const conversation = window.ConversationController.getOrCreate( - added.userId, - 'private' - ); + const addedUuid = UUID.cast(added.userId); - if (members[conversation.id]) { + if (members[addedUuid]) { log.warn( `applyGroupChange/${logId}: Attempt to add member failed; already in members.` ); return; } - members[conversation.id] = { - conversationId: conversation.id, + members[addedUuid] = { + uuid: addedUuid, role: added.role || MEMBER_ROLE_ENUM.DEFAULT, joinedAtVersion: version, joinedFromLink: addMember.joinFromInviteLink || false, }; - if (pendingMembers[conversation.id]) { + if (pendingMembers[addedUuid]) { log.warn( `applyGroupChange/${logId}: Removing newly-added member from pendingMembers.` ); - delete pendingMembers[conversation.id]; + delete pendingMembers[addedUuid]; } // Capture who added us - if ( - ourConversationId && - sourceConversationId && - conversation.id === ourConversationId - ) { - result.addedBy = sourceConversationId; + if (ourUuid && sourceUuid && addedUuid === ourUuid) { + result.addedBy = sourceUuid; } if (added.profileKey) { newProfileKeys.push({ profileKey: added.profileKey, - uuid: added.userId, + uuid: UUID.cast(added.userId), }); } }); @@ -4107,13 +4075,9 @@ async function applyGroupChange({ ); } - const conversation = window.ConversationController.getOrCreate( - deletedUserId, - 'private' - ); - - if (members[conversation.id]) { - delete members[conversation.id]; + const deletedUuid = UUID.cast(deletedUserId); + if (members[deletedUuid]) { + delete members[deletedUuid]; } else { log.warn( `applyGroupChange/${logId}: Attempt to remove member failed; was not in members.` @@ -4128,14 +4092,10 @@ async function applyGroupChange({ throw new Error('applyGroupChange: modifyMemberRole had a missing value'); } - const conversation = window.ConversationController.getOrCreate( - userId, - 'private' - ); - - if (members[conversation.id]) { - members[conversation.id] = { - ...members[conversation.id], + const userUuid = UUID.cast(userId); + if (members[userUuid]) { + members[userUuid] = { + ...members[userUuid], role, }; } else { @@ -4157,7 +4117,7 @@ async function applyGroupChange({ newProfileKeys.push({ profileKey, - uuid, + uuid: UUID.cast(uuid), }); }); @@ -4166,33 +4126,30 @@ async function applyGroupChange({ // >; (actions.addPendingMembers || []).forEach(addPendingMember => { const { added } = addPendingMember; - if (!added || !added.member) { + if (!added || !added.member || !added.member.userId) { throw new Error( 'applyGroupChange: addPendingMembers had a missing value' ); } - const conversation = window.ConversationController.getOrCreate( - added.member.userId, - 'private' - ); + const addedUuid = UUID.cast(added.member.userId); - if (members[conversation.id]) { + if (members[addedUuid]) { log.warn( `applyGroupChange/${logId}: Attempt to add pendingMember failed; was already in members.` ); return; } - if (pendingMembers[conversation.id]) { + if (pendingMembers[addedUuid]) { log.warn( `applyGroupChange/${logId}: Attempt to add pendingMember failed; was already in pendingMembers.` ); return; } - pendingMembers[conversation.id] = { - conversationId: conversation.id, - addedByUserId: added.addedByUserId, + pendingMembers[addedUuid] = { + uuid: addedUuid, + addedByUserId: UUID.cast(added.addedByUserId), timestamp: added.timestamp, role: added.member.role || MEMBER_ROLE_ENUM.DEFAULT, }; @@ -4200,7 +4157,7 @@ async function applyGroupChange({ if (added.member && added.member.profileKey) { newProfileKeys.push({ profileKey: added.member.profileKey, - uuid: added.member.userId, + uuid: addedUuid, }); } }); @@ -4216,13 +4173,10 @@ async function applyGroupChange({ ); } - const conversation = window.ConversationController.getOrCreate( - deletedUserId, - 'private' - ); + const deletedUuid = UUID.cast(deletedUserId); - if (pendingMembers[conversation.id]) { - delete pendingMembers[conversation.id]; + if (pendingMembers[deletedUuid]) { + delete pendingMembers[deletedUuid]; } else { log.warn( `applyGroupChange/${logId}: Attempt to remove pendingMember failed; was not in pendingMembers.` @@ -4234,37 +4188,33 @@ async function applyGroupChange({ // GroupChange.Actions.PromoteMemberPendingProfileKeyAction // >; (actions.promotePendingMembers || []).forEach(promotePendingMember => { - const { profileKey, uuid } = promotePendingMember; - if (!profileKey || !uuid) { + const { profileKey, uuid: rawUuid } = promotePendingMember; + if (!profileKey || !rawUuid) { throw new Error( 'applyGroupChange: promotePendingMember had a missing value' ); } - const conversation = window.ConversationController.getOrCreate( - uuid, - 'private' - ); + const uuid = UUID.cast(rawUuid); + const previousRecord = pendingMembers[uuid]; - const previousRecord = pendingMembers[conversation.id]; - - if (pendingMembers[conversation.id]) { - delete pendingMembers[conversation.id]; + if (pendingMembers[uuid]) { + delete pendingMembers[uuid]; } else { log.warn( `applyGroupChange/${logId}: Attempt to promote pendingMember failed; was not in pendingMembers.` ); } - if (members[conversation.id]) { + if (members[uuid]) { log.warn( `applyGroupChange/${logId}: Attempt to promote pendingMember failed; was already in members.` ); return; } - members[conversation.id] = { - conversationId: conversation.id, + members[uuid] = { + uuid, joinedAtVersion: version, role: previousRecord.role || MEMBER_ROLE_ENUM.DEFAULT, }; @@ -4359,40 +4309,36 @@ async function applyGroupChange({ 'applyGroupChange: modifyMemberProfileKey had a missing value' ); } + const addedUuid = UUID.cast(added.userId); - const conversation = window.ConversationController.getOrCreate( - added.userId, - 'private' - ); - - if (members[conversation.id]) { + if (members[addedUuid]) { log.warn( `applyGroupChange/${logId}: Attempt to add pending admin approval failed; was already in members.` ); return; } - if (pendingMembers[conversation.id]) { + if (pendingMembers[addedUuid]) { log.warn( `applyGroupChange/${logId}: Attempt to add pending admin approval failed; was already in pendingMembers.` ); return; } - if (pendingAdminApprovalMembers[conversation.id]) { + if (pendingAdminApprovalMembers[addedUuid]) { log.warn( `applyGroupChange/${logId}: Attempt to add pending admin approval failed; was already in pendingAdminApprovalMembers.` ); return; } - pendingAdminApprovalMembers[conversation.id] = { - conversationId: conversation.id, + pendingAdminApprovalMembers[addedUuid] = { + uuid: addedUuid, timestamp: added.timestamp, }; if (added.profileKey) { newProfileKeys.push({ profileKey: added.profileKey, - uuid: added.userId, + uuid: addedUuid, }); } } @@ -4410,13 +4356,10 @@ async function applyGroupChange({ ); } - const conversation = window.ConversationController.getOrCreate( - deletedUserId, - 'private' - ); + const deletedUuid = UUID.cast(deletedUserId); - if (pendingAdminApprovalMembers[conversation.id]) { - delete pendingAdminApprovalMembers[conversation.id]; + if (pendingAdminApprovalMembers[deletedUuid]) { + delete pendingAdminApprovalMembers[deletedUuid]; } else { log.warn( `applyGroupChange/${logId}: Attempt to remove pendingAdminApproval failed; was not in pendingAdminApprovalMembers.` @@ -4437,34 +4380,31 @@ async function applyGroupChange({ ); } - const conversation = window.ConversationController.getOrCreate( - userId, - 'private' - ); + const userUuid = UUID.cast(userId); - if (pendingAdminApprovalMembers[conversation.id]) { - delete pendingAdminApprovalMembers[conversation.id]; + if (pendingAdminApprovalMembers[userUuid]) { + delete pendingAdminApprovalMembers[userUuid]; } else { log.warn( `applyGroupChange/${logId}: Attempt to promote pendingAdminApproval failed; was not in pendingAdminApprovalMembers.` ); } - if (pendingMembers[conversation.id]) { - delete pendingAdminApprovalMembers[conversation.id]; + if (pendingMembers[userUuid]) { + delete pendingAdminApprovalMembers[userUuid]; log.warn( `applyGroupChange/${logId}: Deleted pendingAdminApproval from pendingMembers.` ); } - if (members[conversation.id]) { + if (members[userUuid]) { log.warn( `applyGroupChange/${logId}: Attempt to promote pendingMember failed; was already in members.` ); return; } - members[conversation.id] = { - conversationId: conversation.id, + members[userUuid] = { + uuid: userUuid, joinedAtVersion: version, role: role || MEMBER_ROLE_ENUM.DEFAULT, approvedByAdmin: true, @@ -4500,8 +4440,8 @@ async function applyGroupChange({ result.announcementsOnly = announcementsOnly; } - if (ourConversationId) { - result.left = !members[ourConversationId]; + if (ourUuid) { + result.left = !members[ourUuid]; } // Go from lookups back to arrays @@ -4594,11 +4534,11 @@ export async function applyNewAvatar( async function applyGroupState({ group, groupState, - sourceConversationId, + sourceUuid, }: { group: ConversationAttributesType; groupState: DecryptedGroupState; - sourceConversationId?: string; + sourceUuid?: UUIDStringType; }): Promise { const logId = idForLogging(group.groupId); const ACCESS_ENUM = Proto.AccessControl.AccessRequired; @@ -4647,27 +4587,20 @@ async function applyGroupState({ // Optimization: we assume we have left the group unless we are found in members result.left = true; - const ourConversationId = window.ConversationController.getOurConversationId(); + const ourUuid = window.storage.user.getCheckedUuid().toString(); // members if (groupState.members) { result.membersV2 = groupState.members.map(member => { - const conversation = window.ConversationController.getOrCreate( - member.userId, - 'private' - ); - - if (ourConversationId && conversation.id === ourConversationId) { + if (member.userId === ourUuid) { result.left = false; // Capture who added us if we were previously not in group if ( - sourceConversationId && - (result.membersV2 || []).every( - item => item.conversationId !== ourConversationId - ) + sourceUuid && + (result.membersV2 || []).every(item => item.uuid !== ourUuid) ) { - result.addedBy = sourceConversationId; + result.addedBy = sourceUuid; } } @@ -4680,14 +4613,14 @@ async function applyGroupState({ if (member.profileKey) { newProfileKeys.push({ profileKey: member.profileKey, - uuid: member.userId, + uuid: UUID.cast(member.userId), }); } return { role: member.role || MEMBER_ROLE_ENUM.DEFAULT, joinedAtVersion: member.joinedAtVersion || version, - conversationId: conversation.id, + uuid: UUID.cast(member.userId), }; }); } @@ -4696,26 +4629,13 @@ async function applyGroupState({ if (groupState.membersPendingProfileKey) { result.pendingMembersV2 = groupState.membersPendingProfileKey.map( member => { - let pending; - let invitedBy; - - if (member.member && member.member.userId) { - pending = window.ConversationController.getOrCreate( - member.member.userId, - 'private' - ); - } else { + if (!member.member || !member.member.userId) { throw new Error( 'applyGroupState: Member pending profile key did not have an associated userId' ); } - if (member.addedByUserId) { - invitedBy = window.ConversationController.getOrCreate( - member.addedByUserId, - 'private' - ); - } else { + if (!member.addedByUserId) { throw new Error( 'applyGroupState: Member pending profile key did not have an addedByUserID' ); @@ -4730,13 +4650,13 @@ async function applyGroupState({ if (member.member.profileKey) { newProfileKeys.push({ profileKey: member.member.profileKey, - uuid: member.member.userId, + uuid: UUID.cast(member.member.userId), }); } return { - addedByUserId: invitedBy.id, - conversationId: pending.id, + addedByUserId: UUID.cast(member.addedByUserId), + uuid: UUID.cast(member.member.userId), timestamp: member.timestamp, role: member.member.role || MEMBER_ROLE_ENUM.DEFAULT, }; @@ -4748,21 +4668,8 @@ async function applyGroupState({ if (groupState.membersPendingAdminApproval) { result.pendingAdminApprovalV2 = groupState.membersPendingAdminApproval.map( member => { - let pending; - - if (member.userId) { - pending = window.ConversationController.getOrCreate( - member.userId, - 'private' - ); - } else { - throw new Error( - 'applyGroupState: Pending admin approval did not have an associated userId' - ); - } - return { - conversationId: pending.id, + uuid: UUID.cast(member.userId), timestamp: member.timestamp, }; } @@ -4846,7 +4753,7 @@ function normalizeTimestamp( type DecryptedGroupChangeActions = { version?: number; - sourceUuid?: string; + sourceUuid?: UUIDStringType; addMembers?: ReadonlyArray<{ added: DecryptedMember; joinFromInviteLink: boolean; @@ -4860,7 +4767,7 @@ type DecryptedGroupChangeActions = { }>; modifyMemberProfileKeys?: ReadonlyArray<{ profileKey: Uint8Array; - uuid: string; + uuid: UUIDStringType; }>; addPendingMembers?: ReadonlyArray<{ added: DecryptedMemberPendingProfileKey; @@ -4870,7 +4777,7 @@ type DecryptedGroupChangeActions = { }>; promotePendingMembers?: ReadonlyArray<{ profileKey: Uint8Array; - uuid: string; + uuid: UUIDStringType; }>; modifyTitle?: { title?: Proto.GroupAttributeBlob; @@ -4918,9 +4825,11 @@ function decryptGroupChange( if (actions.sourceUuid && actions.sourceUuid.length !== 0) { try { - result.sourceUuid = normalizeUuid( - decryptUuid(clientZkGroupCipher, actions.sourceUuid), - 'actions.sourceUuid' + result.sourceUuid = UUID.cast( + normalizeUuid( + decryptUuid(clientZkGroupCipher, actions.sourceUuid), + 'actions.sourceUuid' + ) ); } catch (error) { log.warn( @@ -4929,7 +4838,7 @@ function decryptGroupChange( ); } - if (!window.isValidGuid(result.sourceUuid)) { + if (!isValidUuid(result.sourceUuid)) { log.warn( `decryptGroupChange/${logId}: Invalid sourceUuid. Clearing sourceUuid.` ); @@ -4985,7 +4894,7 @@ function decryptGroupChange( return null; } - if (!window.isValidGuid(userId)) { + if (!isValidUuid(userId)) { log.warn( `decryptGroupChange/${logId}: Dropping deleteMember due to invalid userId` ); @@ -5019,7 +4928,7 @@ function decryptGroupChange( return null; } - if (!window.isValidGuid(userId)) { + if (!isValidUuid(userId)) { log.warn( `decryptGroupChange/${logId}: Dropping modifyMemberRole due to invalid userId` ); @@ -5063,7 +4972,7 @@ function decryptGroupChange( ); } - if (!window.isValidGuid(decryptedPresentation.uuid)) { + if (!isValidUuid(decryptedPresentation.uuid)) { log.warn( `decryptGroupChange/${logId}: Dropping modifyMemberProfileKey due to invalid userId` ); @@ -5129,7 +5038,7 @@ function decryptGroupChange( return null; } - if (!window.isValidGuid(userId)) { + if (!isValidUuid(userId)) { log.warn( `decryptGroupChange/${logId}: Dropping deletePendingMember due to invalid deletedUserId` ); @@ -5164,7 +5073,7 @@ function decryptGroupChange( ); } - if (!window.isValidGuid(decryptedPresentation.uuid)) { + if (!isValidUuid(decryptedPresentation.uuid)) { log.warn( `decryptGroupChange/${logId}: Dropping modifyMemberProfileKey due to invalid userId` ); @@ -5330,7 +5239,7 @@ function decryptGroupChange( ); return null; } - if (!window.isValidGuid(userId)) { + if (!isValidUuid(userId)) { log.warn( `decryptGroupChange/${logId}: Dropping deletePendingApproval due to invalid deletedUserId` ); @@ -5652,7 +5561,7 @@ function decryptMember( return undefined; } - if (!window.isValidGuid(userId)) { + if (!isValidUuid(userId)) { log.warn(`decryptMember/${logId}: Dropping member due to invalid userId`); return undefined; @@ -5666,7 +5575,7 @@ function decryptMember( const profileKey = decryptProfileKey( clientZkGroupCipher, member.profileKey, - userId + UUID.cast(userId) ); if (!isValidProfileKey(profileKey)) { @@ -5723,7 +5632,7 @@ function decryptMemberPendingProfileKey( return undefined; } - if (!window.isValidGuid(addedByUserId)) { + if (!isValidUuid(addedByUserId)) { log.warn( `decryptMemberPendingProfileKey/${logId}: Dropping pending member due to invalid addedByUserId` ); @@ -5763,7 +5672,7 @@ function decryptMemberPendingProfileKey( return undefined; } - if (!window.isValidGuid(decryptedUserId)) { + if (!isValidUuid(decryptedUserId)) { log.warn( `decryptMemberPendingProfileKey/${logId}: Dropping pending member due to invalid member.userId` ); @@ -5778,7 +5687,7 @@ function decryptMemberPendingProfileKey( decryptedProfileKey = decryptProfileKey( clientZkGroupCipher, profileKey, - decryptedUserId + UUID.cast(decryptedUserId) ); } catch (error) { log.warn( @@ -5850,7 +5759,7 @@ function decryptMemberPendingAdminApproval( return undefined; } - if (!window.isValidGuid(decryptedUserId)) { + if (!isValidUuid(decryptedUserId)) { log.warn( `decryptMemberPendingAdminApproval/${logId}: Invalid userId. Dropping member.` ); @@ -5865,7 +5774,7 @@ function decryptMemberPendingAdminApproval( decryptedProfileKey = decryptProfileKey( clientZkGroupCipher, profileKey, - decryptedUserId + UUID.cast(decryptedUserId) ); } catch (error) { log.warn( @@ -5892,7 +5801,7 @@ function decryptMemberPendingAdminApproval( export function getMembershipList( conversationId: string -): Array<{ uuid: string; uuidCiphertext: Uint8Array }> { +): Array<{ uuid: UUIDStringType; uuidCiphertext: Uint8Array }> { const conversation = window.ConversationController.get(conversationId); if (!conversation) { throw new Error('getMembershipList: cannot find conversation'); diff --git a/ts/messageModifiers/MessageReceipts.ts b/ts/messageModifiers/MessageReceipts.ts index f86838120ddb..d61081d687e3 100644 --- a/ts/messageModifiers/MessageReceipts.ts +++ b/ts/messageModifiers/MessageReceipts.ts @@ -14,6 +14,7 @@ import { isDirectConversation } from '../util/whatTypeOfConversation'; import { getOwn } from '../util/getOwn'; import { missingCaseError } from '../util/missingCaseError'; import { createWaitBatcher } from '../util/waitBatcher'; +import type { UUIDStringType } from '../types/UUID'; import { SendActionType, SendStatus, @@ -34,6 +35,7 @@ export enum MessageReceiptType { type MessageReceiptAttributesType = { messageSentAt: number; receiptTimestamp: number; + sourceUuid: UUIDStringType; sourceConversationId: string; sourceDevice: number; type: MessageReceiptType; @@ -57,6 +59,7 @@ const deleteSentProtoBatcher = createWaitBatcher({ async function getTargetMessage( sourceId: string, + sourceUuid: UUIDStringType, messages: MessageModelCollectionType ): Promise { if (messages.length === 0) { @@ -70,9 +73,12 @@ async function getTargetMessage( return window.MessageController.register(message.id, message); } - const groups = await window.Signal.Data.getAllGroupsInvolvingId(sourceId, { - ConversationCollection: window.Whisper.ConversationCollection, - }); + const groups = await window.Signal.Data.getAllGroupsInvolvingUuid( + sourceUuid, + { + ConversationCollection: window.Whisper.ConversationCollection, + } + ); const ids = groups.pluck('id'); ids.push(sourceId); @@ -136,6 +142,7 @@ export class MessageReceipts extends Collection { const type = receipt.get('type'); const messageSentAt = receipt.get('messageSentAt'); const sourceConversationId = receipt.get('sourceConversationId'); + const sourceUuid = receipt.get('sourceUuid'); try { const messages = await window.Signal.Data.getMessagesBySentAt( @@ -145,7 +152,11 @@ export class MessageReceipts extends Collection { } ); - const message = await getTargetMessage(sourceConversationId, messages); + const message = await getTargetMessage( + sourceConversationId, + sourceUuid, + messages + ); if (!message) { log.info( 'No message for receipt', diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 9da3b92f5037..6f2bedc6ee4f 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -26,6 +26,7 @@ import { AttachmentType, ThumbnailType } from './types/Attachment'; import { EmbeddedContactType } from './types/EmbeddedContact'; import { SignalService as Proto } from './protobuf'; import { AvatarDataType } from './types/Avatar'; +import { UUIDStringType } from './types/UUID'; import AccessRequiredEnum = Proto.AccessControl.AccessRequired; import MemberRoleEnum = Proto.Member.Role; @@ -181,7 +182,7 @@ export type MessageAttributesType = { serverGuid?: string; serverTimestamp?: number; source?: string; - sourceUuid?: string; + sourceUuid?: UUIDStringType; timestamp: number; @@ -253,7 +254,7 @@ export type ConversationAttributesType = { version: number; // Private core info - uuid?: string; + uuid?: UUIDStringType; e164?: string; // Private other fields @@ -327,7 +328,7 @@ export type ConversationAttributesType = { }; export type GroupV2MemberType = { - conversationId: string; + uuid: UUIDStringType; role: MemberRoleEnum; joinedAtVersion: number; @@ -339,14 +340,14 @@ export type GroupV2MemberType = { }; export type GroupV2PendingMemberType = { - addedByUserId?: string; - conversationId: string; + addedByUserId?: UUIDStringType; + uuid: UUIDStringType; timestamp: number; role: MemberRoleEnum; }; export type GroupV2PendingAdminApprovalType = { - conversationId: string; + uuid: UUIDStringType; timestamp: number; }; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index d8ec9345ed33..bd3da4482640 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -46,7 +46,8 @@ import { sniffImageMimeType } from '../util/sniffImageMimeType'; import { isValidE164 } from '../util/isValidE164'; import type { MIMEType } from '../types/MIME'; import { IMAGE_JPEG, IMAGE_GIF, IMAGE_WEBP } from '../types/MIME'; -import { UUID } from '../types/UUID'; +import { UUID, isValidUuid } from '../types/UUID'; +import type { UUIDStringType } from '../types/UUID'; import { deriveAccessKey, decryptProfileName, decryptProfile } from '../Crypto'; import * as Bytes from '../Bytes'; import type { BodyRangesType } from '../types/Util'; @@ -242,7 +243,7 @@ export class ConversationModel extends window.Backbone initialize(attributes: Partial = {}): void { if (isValidE164(attributes.id, false)) { - this.set({ id: window.getGuid(), e164: attributes.id }); + this.set({ id: UUID.generate().toString(), e164: attributes.id }); } this.storeName = 'conversations'; @@ -340,7 +341,7 @@ export class ConversationModel extends window.Backbone } } - isMemberRequestingToJoin(conversationId: string): boolean { + isMemberRequestingToJoin(id: string): boolean { if (!isGroupV2(this.attributes)) { return false; } @@ -350,12 +351,11 @@ export class ConversationModel extends window.Backbone return false; } - return pendingAdminApprovalV2.some( - item => item.conversationId === conversationId - ); + const uuid = UUID.checkedLookup(id).toString(); + return pendingAdminApprovalV2.some(item => item.uuid === uuid); } - isMemberPending(conversationId: string): boolean { + isMemberPending(id: string): boolean { if (!isGroupV2(this.attributes)) { return false; } @@ -365,13 +365,11 @@ export class ConversationModel extends window.Backbone return false; } - return window._.any( - pendingMembersV2, - item => item.conversationId === conversationId - ); + const uuid = UUID.checkedLookup(id).toString(); + return window._.any(pendingMembersV2, item => item.uuid === uuid); } - isMemberAwaitingApproval(conversationId: string): boolean { + isMemberAwaitingApproval(id: string): boolean { if (!isGroupV2(this.attributes)) { return false; } @@ -381,13 +379,11 @@ export class ConversationModel extends window.Backbone return false; } - return window._.any( - pendingAdminApprovalV2, - item => item.conversationId === conversationId - ); + const uuid = UUID.checkedLookup(id).toString(); + return window._.any(pendingAdminApprovalV2, item => item.uuid === uuid); } - isMember(conversationId: string): boolean { + isMember(id: string): boolean { if (!isGroupV2(this.attributes)) { throw new Error( `isMember: Called for non-GroupV2 conversation ${this.idForLogging()}` @@ -398,11 +394,9 @@ export class ConversationModel extends window.Backbone if (!membersV2 || !membersV2.length) { return false; } + const uuid = UUID.checkedLookup(id).toString(); - return window._.any( - membersV2, - item => item.conversationId === conversationId - ); + return window._.any(membersV2, item => item.uuid === uuid); } async updateExpirationTimerInGroupV2( @@ -678,7 +672,7 @@ export class ConversationModel extends window.Backbone } return uuid; }) - .filter((uuid): uuid is string => Boolean(uuid)); + .filter(isNotNil); if (!uuids.length) { return undefined; @@ -1565,7 +1559,7 @@ export class ConversationModel extends window.Backbone updateUuid(uuid?: string): void { const oldValue = this.get('uuid'); if (uuid && uuid !== oldValue) { - this.set('uuid', uuid.toLowerCase()); + this.set('uuid', UUID.cast(uuid.toLowerCase())); window.Signal.Data.updateConversation(this.attributes); this.trigger('idUpdated', this, 'uuid', oldValue); } @@ -1840,6 +1834,7 @@ export class ConversationModel extends window.Backbone approvalRequired: boolean; }): Promise { const ourConversationId = window.ConversationController.getOurConversationIdOrThrow(); + const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString(); try { if (approvalRequired) { await this.modifyGroupV2({ @@ -1870,7 +1865,7 @@ export class ConversationModel extends window.Backbone this.set({ pendingAdminApprovalV2: [ { - conversationId: ourConversationId, + uuid: ourUuid, timestamp: Date.now(), }, ], @@ -2143,14 +2138,12 @@ export class ConversationModel extends window.Backbone } async safeGetVerified(): Promise { - const uuid = this.get('uuid'); + const uuid = this.getUuid(); if (!uuid) { return window.textsecure.storage.protocol.VerifiedStatus.DEFAULT; } - const promise = window.textsecure.storage.protocol.getVerified( - new UUID(uuid) - ); + const promise = window.textsecure.storage.protocol.getVerified(uuid); return promise.catch( () => window.textsecure.storage.protocol.VerifiedStatus.DEFAULT ); @@ -2227,7 +2220,7 @@ export class ConversationModel extends window.Backbone ); } - const uuid = this.get('uuid'); + const uuid = this.getUuid(); const beginningVerified = this.get('verified'); let keyChange; if (options.viaSyncMessage) { @@ -2239,13 +2232,13 @@ export class ConversationModel extends window.Backbone // 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( - new UUID(uuid), + uuid, verified, options.key || undefined ); } else if (uuid) { keyChange = await window.textsecure.storage.protocol.setVerified( - new UUID(uuid), + uuid, verified ); } else { @@ -2289,10 +2282,10 @@ export class ConversationModel extends window.Backbone async sendVerifySyncMessage( e164: string | undefined, - uuid: string, + uuid: UUID, state: number ): Promise { - const identifier = uuid || e164; + const identifier = uuid ? uuid.toString() : e164; if (!identifier) { throw new Error( 'sendVerifySyncMessage: Neither e164 nor UUID were provided' @@ -2328,7 +2321,7 @@ export class ConversationModel extends window.Backbone await handleMessageSend( window.textsecure.messaging.syncVerification( e164, - uuid, + uuid.toString(), state, key, options @@ -2402,20 +2395,20 @@ export class ConversationModel extends window.Backbone ); } - const uuid = this.get('uuid'); + const uuid = this.getUuid(); if (!uuid) { log.warn(`setApproved(${this.id}): no uuid, ignoring`); return; } - return window.textsecure.storage.protocol.setApproval(new UUID(uuid), true); + return window.textsecure.storage.protocol.setApproval(uuid, true); } safeIsUntrusted(): boolean { - const uuid = this.get('uuid'); try { + const uuid = this.getUuid(); strictAssert(uuid, `No uuid for conversation: ${this.id}`); - return window.textsecure.storage.protocol.isUntrusted(new UUID(uuid)); + return window.textsecure.storage.protocol.isUntrusted(uuid); } catch (err) { return false; } @@ -2671,8 +2664,9 @@ export class ConversationModel extends window.Backbone this.trigger('newmessage', model); - if (isDirectConversation(this.attributes)) { - window.ConversationController.getAllGroupsInvolvingId(this.id).then( + const uuid = this.getUuid(); + if (isDirectConversation(this.attributes) && uuid) { + window.ConversationController.getAllGroupsInvolvingUuid(uuid).then( groups => { window._.forEach(groups, group => { group.addVerifiedChange(this.id, verified, options); @@ -2790,8 +2784,9 @@ export class ConversationModel extends window.Backbone this.trigger('newmessage', model); - if (isDirectConversation(this.attributes)) { - window.ConversationController.getAllGroupsInvolvingId(this.id).then( + const uuid = this.getUuid(); + if (isDirectConversation(this.attributes) && uuid) { + window.ConversationController.getAllGroupsInvolvingUuid(uuid).then( groups => { window._.forEach(groups, group => { group.addProfileChange(profileChange, this.id); @@ -2899,17 +2894,21 @@ export class ConversationModel extends window.Backbone `Conversation ${this.idForLogging()}: adding change number notification` ); + const sourceUuid = this.getCheckedUuid( + 'Change number notification without uuid' + ); + const convos = [ this, - ...(await window.ConversationController.getAllGroupsInvolvingId(this.id)), + ...(await window.ConversationController.getAllGroupsInvolvingUuid( + sourceUuid + )), ]; - const sourceUuid = this.get('uuid'); - await Promise.all( convos.map(convo => { return convo.addNotification('change-number-notification', { - sourceUuid, + sourceUuid: sourceUuid.toString(), }); }) ); @@ -2998,7 +2997,7 @@ export class ConversationModel extends window.Backbone validateUuid(): string | null { if (isDirectConversation(this.attributes) && this.get('uuid')) { - if (window.isValidGuid(this.get('uuid'))) { + if (isValidUuid(this.get('uuid'))) { return null; } @@ -3037,13 +3036,14 @@ export class ConversationModel extends window.Backbone }); } - isAdmin(conversationId: string): boolean { + isAdmin(id: string): boolean { if (!isGroupV2(this.attributes)) { return false; } + const uuid = UUID.checkedLookup(id).toString(); const members = this.get('membersV2') || []; - const member = members.find(x => x.conversationId === conversationId); + const member = members.find(x => x.uuid === uuid); if (!member) { return false; } @@ -3053,8 +3053,19 @@ export class ConversationModel extends window.Backbone return member.role === MEMBER_ROLES.ADMINISTRATOR; } + getUuid(): UUID | undefined { + const value = this.get('uuid'); + return value && new UUID(value); + } + + getCheckedUuid(reason: string): UUID { + const result = this.getUuid(); + strictAssert(result !== undefined, reason); + return result; + } + private getMemberships(): Array<{ - conversationId: string; + uuid: UUIDStringType; isAdmin: boolean; }> { if (!isGroupV2(this.attributes)) { @@ -3064,7 +3075,7 @@ export class ConversationModel extends window.Backbone const members = this.get('membersV2') || []; return members.map(member => ({ isAdmin: member.role === Proto.Member.Role.ADMINISTRATOR, - conversationId: member.conversationId, + uuid: member.uuid, })); } @@ -3081,8 +3092,8 @@ export class ConversationModel extends window.Backbone } private getPendingMemberships(): Array<{ - addedByUserId?: string; - conversationId: string; + addedByUserId?: UUIDStringType; + uuid: UUIDStringType; }> { if (!isGroupV2(this.attributes)) { return []; @@ -3091,18 +3102,18 @@ export class ConversationModel extends window.Backbone const members = this.get('pendingMembersV2') || []; return members.map(member => ({ addedByUserId: member.addedByUserId, - conversationId: member.conversationId, + uuid: member.uuid, })); } - private getPendingApprovalMemberships(): Array<{ conversationId: string }> { + private getPendingApprovalMemberships(): Array<{ uuid: UUIDStringType }> { if (!isGroupV2(this.attributes)) { return []; } const members = this.get('pendingAdminApprovalV2') || []; return members.map(member => ({ - conversationId: member.conversationId, + uuid: member.uuid, })); } @@ -3136,6 +3147,13 @@ export class ConversationModel extends window.Backbone return members.map(member => member.id); } + getMemberUuids(): Array { + const members = this.getMembers(); + return members.map(member => { + return member.getCheckedUuid('Group member without uuid'); + }); + } + getRecipients({ includePendingMembers, extraConversationsForSend, @@ -3356,7 +3374,7 @@ export class ConversationModel extends window.Backbone // 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({ - id: window.getGuid(), + id: UUID.generate().toString(), type: 'outgoing', conversationId: this.get('id'), sent_at: timestamp, @@ -3489,7 +3507,7 @@ export class ConversationModel extends window.Backbone // 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({ - id: window.getGuid(), + id: UUID.generate.toString(), type: 'outgoing', conversationId: this.get('id'), sent_at: timestamp, @@ -3731,7 +3749,7 @@ export class ConversationModel extends window.Backbone } const attributes: MessageAttributesType = { ...messageWithSchema, - id: window.getGuid(), + id: UUID.generate().toString(), }; const model = new window.Whisper.Message(attributes); @@ -3840,9 +3858,10 @@ export class ConversationModel extends window.Backbone const conversationId = this.id; + const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString(); const lastMessages = await window.Signal.Data.getLastConversationMessages({ conversationId, - ourConversationId, + ourUuid, Message: window.Whisper.Message, }); @@ -4361,12 +4380,16 @@ export class ConversationModel extends window.Backbone return; } - const ourGroups = await window.ConversationController.getAllGroupsInvolvingId( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - window.ConversationController.getOurConversationId()! + const ourUuid = window.textsecure.storage.user.getCheckedUuid(); + const ourGroups = await window.ConversationController.getAllGroupsInvolvingUuid( + ourUuid ); - const theirGroups = await window.ConversationController.getAllGroupsInvolvingId( - this.id + const theirUuid = this.getUuid(); + if (!theirUuid) { + return; + } + const theirGroups = await window.ConversationController.getAllGroupsInvolvingUuid( + theirUuid ); const sharedGroups = window._.intersection(ourGroups, theirGroups); @@ -4734,8 +4757,8 @@ export class ConversationModel extends window.Backbone const memberEnum = Proto.Member.Role; const members = this.get('membersV2') || []; - const myId = window.ConversationController.getOurConversationId(); - const me = members.find(item => item.conversationId === myId); + const ourUuid = window.textsecure.storage.user.getCheckedUuid().toString(); + const me = members.find(item => item.uuid === ourUuid); if (!me) { return false; } diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 02feed8a84e5..2e2211e00e57 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -33,6 +33,8 @@ import { SendMessageProtoError } from '../textsecure/Errors'; import * as expirationTimer from '../util/expirationTimer'; import type { ReactionType } from '../types/Reactions'; +import { UUID } from '../types/UUID'; +import type { UUIDStringType } from '../types/UUID'; import { copyStickerToAttachments, deletePackReference, @@ -181,8 +183,6 @@ export class MessageModel extends window.Backbone.Model { INITIAL_PROTOCOL_VERSION?: number; - OUR_UUID?: string; - isSelected?: boolean; private pendingMarkRead?: number; @@ -223,7 +223,6 @@ export class MessageModel extends window.Backbone.Model { this.CURRENT_PROTOCOL_VERSION = Proto.DataMessage.ProtocolVersion.CURRENT; this.INITIAL_PROTOCOL_VERSION = Proto.DataMessage.ProtocolVersion.INITIAL; - this.OUR_UUID = window.textsecure.storage.user.getUuid()?.toString(); this.on('change', this.notifyRedux); } @@ -385,7 +384,7 @@ export class MessageModel extends window.Backbone.Model { conversationSelector: findAndFormatContact, ourConversationId, ourNumber: window.textsecure.storage.user.getNumber(), - ourUuid: this.OUR_UUID, + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), regionCode: window.storage.get('regionCode', 'ZZ'), accountSelector: (identifier?: string) => { const state = window.reduxStore.getState(); @@ -1104,7 +1103,7 @@ export class MessageModel extends window.Backbone.Model { return sourceDevice || window.textsecure.storage.user.getDeviceId(); } - getSourceUuid(): string | undefined { + getSourceUuid(): UUIDStringType | undefined { if (isIncoming(this.attributes)) { return this.get('sourceUuid'); } @@ -1114,7 +1113,7 @@ export class MessageModel extends window.Backbone.Model { ); } - return this.OUR_UUID; + return window.textsecure.storage.user.getUuid()?.toString(); } getContactId(): string | undefined { @@ -2510,7 +2509,7 @@ export class MessageModel extends window.Backbone.Model { return; } - const messageId = window.getGuid(); + const messageId = UUID.generate().toString(); // Send delivery receipts, but only for incoming sealed sender messages // and not for messages from unaccepted conversations diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index d37a5e5c9603..d6e4085ab1b7 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -36,8 +36,6 @@ import { import { ourProfileKeyService } from './ourProfileKey'; import { isGroupV1, isGroupV2 } from '../util/whatTypeOfConversation'; import * as preferredReactionEmoji from '../reactions/preferredReactionEmoji'; -import { UUID } from '../types/UUID'; -import * as Errors from '../types/errors'; import { SignalService as Proto } from '../protobuf'; import * as log from '../logging/log'; @@ -107,9 +105,9 @@ export async function toContactRecord( conversation: ConversationModel ): Promise { const contactRecord = new Proto.ContactRecord(); - const uuid = conversation.get('uuid'); + const uuid = conversation.getUuid(); if (uuid) { - contactRecord.serviceUuid = uuid; + contactRecord.serviceUuid = uuid.toString(); } const e164 = conversation.get('e164'); if (e164) { @@ -120,15 +118,8 @@ export async function toContactRecord( contactRecord.profileKey = Bytes.fromBase64(String(profileKey)); } - let maybeUuid: UUID | undefined; - try { - maybeUuid = uuid ? new UUID(uuid) : undefined; - } catch (error) { - log.warn(`Invalid uuid in contact record: ${Errors.toLogFormat(error)}`); - } - - const identityKey = maybeUuid - ? await window.textsecure.storage.protocol.loadIdentityKey(maybeUuid) + const identityKey = uuid + ? await window.textsecure.storage.protocol.loadIdentityKey(uuid) : undefined; if (identityKey) { contactRecord.identityKey = identityKey; diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 70d4c5ea0a35..e7eed0454f2d 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -33,6 +33,7 @@ import { assert, strictAssert } from '../util/assert'; import { cleanDataForIpc } from './cleanDataForIpc'; import type { ReactionType } from '../types/Reactions'; import type { ConversationColorType, CustomColorType } from '../types/Colors'; +import type { UUIDStringType } from '../types/UUID'; import type { ProcessGroupCallRingRequestResult } from '../types/Calling'; import type { RemoveAllConfiguration } from '../types/RemoveAllConfiguration'; import createTaskWithTimeout from '../textsecure/TaskWithTimeout'; @@ -204,7 +205,7 @@ const dataInterface: ClientInterface = { getAllConversations, getAllConversationIds, getAllPrivateConversations, - getAllGroupsInvolvingId, + getAllGroupsInvolvingUuid, searchConversations, searchMessages, @@ -1044,15 +1045,15 @@ async function getAllPrivateConversations({ return collection; } -async function getAllGroupsInvolvingId( - id: string, +async function getAllGroupsInvolvingUuid( + uuid: UUIDStringType, { ConversationCollection, }: { ConversationCollection: typeof ConversationModelCollectionType; } ) { - const conversations = await channels.getAllGroupsInvolvingId(id); + const conversations = await channels.getAllGroupsInvolvingUuid(uuid); const collection = new ConversationCollection(); collection.add(conversations); @@ -1325,11 +1326,11 @@ async function getNewerMessagesByConversation( } async function getLastConversationMessages({ conversationId, - ourConversationId, + ourUuid, Message, }: { conversationId: string; - ourConversationId: string; + ourUuid: UUIDStringType; Message: typeof MessageModel; }): Promise { const { @@ -1338,7 +1339,7 @@ async function getLastConversationMessages({ hasUserInitiatedMessages, } = await channels.getLastConversationMessages({ conversationId, - ourConversationId, + ourUuid, }); return { diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index c7674e4b9974..3463b64c4e21 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -63,7 +63,7 @@ export type EmojiType = { export type IdentityKeyType = { firstUse: boolean; - id: UUIDStringType | `conversation:${UUIDStringType}`; + id: UUIDStringType | `conversation:${string}`; nonblockingApproval: boolean; publicKey: Uint8Array; timestamp: number; @@ -501,7 +501,9 @@ export type DataInterface = { export type ServerInterface = DataInterface & { getAllConversations: () => Promise>; - getAllGroupsInvolvingId: (id: string) => Promise>; + getAllGroupsInvolvingUuid: ( + id: UUIDStringType + ) => Promise>; getAllPrivateConversations: () => Promise>; getConversationById: (id: string) => Promise; getExpiredMessages: () => Promise>; @@ -528,7 +530,7 @@ export type ServerInterface = DataInterface & { ) => Promise>; getLastConversationMessages: (options: { conversationId: string; - ourConversationId: string; + ourUuid: UUIDStringType; }) => Promise; getTapToViewMessagesNeedingErase: () => Promise>; removeConversation: (id: Array | string) => Promise; @@ -576,8 +578,8 @@ export type ClientInterface = DataInterface & { getAllConversations: (options: { ConversationCollection: typeof ConversationModelCollectionType; }) => Promise; - getAllGroupsInvolvingId: ( - id: string, + getAllGroupsInvolvingUuid: ( + id: UUIDStringType, options: { ConversationCollection: typeof ConversationModelCollectionType; } @@ -630,7 +632,7 @@ export type ClientInterface = DataInterface & { ) => Promise; getLastConversationMessages: (options: { conversationId: string; - ourConversationId: string; + ourUuid: UUIDStringType; Message: typeof MessageModel; }) => Promise; getTapToViewMessagesNeedingErase: (options: { diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 4fe9e6f8b470..5e167ba2f123 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -1,10 +1,7 @@ // Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -/* eslint-disable no-nested-ternary */ /* eslint-disable camelcase */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { join } from 'path'; import mkdirp from 'mkdirp'; @@ -13,7 +10,6 @@ import type { Database, Statement } from 'better-sqlite3'; import SQL from 'better-sqlite3'; import pProps from 'p-props'; -import { v4 as generateUUID } from 'uuid'; import type { Dictionary } from 'lodash'; import { forEach, @@ -21,7 +17,6 @@ import { isNil, isNumber, isString, - keyBy, last, map, mapValues, @@ -30,19 +25,19 @@ import { } from 'lodash'; import { ReadStatus } from '../messages/MessageReadStatus'; -import Helpers from '../textsecure/Helpers'; import type { GroupV2MemberType } from '../model-types.d'; import type { ReactionType } from '../types/Reactions'; import { STORAGE_UI_KEYS } from '../types/StorageUIKeys'; +import { UUID } from '../types/UUID'; +import type { UUIDStringType } from '../types/UUID'; import type { StoredJob } from '../jobs/types'; -import { assert } from '../util/assert'; +import { assert, assertSync } from '../util/assert'; import { combineNames } from '../util/combineNames'; import { consoleLogger } from '../util/consoleLogger'; import { dropNull } from '../util/dropNull'; import { isNormalNumber } from '../util/isNormalNumber'; import { isNotNil } from '../util/isNotNil'; import { missingCaseError } from '../util/missingCaseError'; -import { isValidGuid } from '../util/isValidGuid'; import { parseIntOrThrow } from '../util/parseIntOrThrow'; import * as durations from '../util/durations'; import { formatCountForLogging } from '../logging/formatCountForLogging'; @@ -51,6 +46,24 @@ import { ProcessGroupCallRingRequestResult } from '../types/Calling'; import { RemoveAllConfiguration } from '../types/RemoveAllConfiguration'; import type { LoggerType } from '../types/Logging'; import * as log from '../logging/log'; +import type { EmptyQuery, ArrayQuery, Query, JSONRows } from './util'; +import { + jsonToObject, + objectToJSON, + batchMultiVarQuery, + getCountFromTable, + removeById, + removeAllFromTable, + getAllFromTable, + getById, + bulkAdd, + createOrUpdate, + TableIterator, + setUserVersion, + getUserVersion, + getSchemaVersion, +} from './util'; +import { updateSchema } from './migrations'; import type { AllItemsType, @@ -90,7 +103,6 @@ import type { UnprocessedUpdateType, } from './Interface'; -type JSONRows = Array<{ readonly json: string }>; type ConversationRow = Readonly<{ json: string; profileLastFetchedAt: null | number; @@ -107,13 +119,6 @@ type StickerRow = Readonly<{ width: number; }>; -type EmptyQuery = []; -type ArrayQuery = Array>; -type Query = { [key: string]: null | number | bigint | string | Buffer }; - -// This value needs to be below SQLITE_MAX_VARIABLE_NUMBER. -const MAX_VARIABLE_COUNT = 100; - // Because we can't force this module to conform to an interface, we narrow our exports // to this one default export, which does conform to the interface. // Note: In Javascript, you need to access the .default property when requiring it @@ -187,7 +192,7 @@ const dataInterface: ServerInterface = { getAllConversations, getAllConversationIds, getAllPrivateConversations, - getAllGroupsInvolvingId, + getAllGroupsInvolvingUuid, updateAllConversationColors, searchConversations, @@ -287,7 +292,7 @@ const dataInterface: ServerInterface = { }; export default dataInterface; -type DatabaseQueryCache = Map>>; +type DatabaseQueryCache = Map>>; const statementCache = new WeakMap(); @@ -307,16 +312,6 @@ function prepare(db: Database, query: string): Statement { return result; } -function assertSync(value: T extends Promise ? never : T): T { - return value; -} - -function objectToJSON(data: any) { - return JSON.stringify(data); -} -function jsonToObject(json: string): any { - return JSON.parse(json); -} function rowToConversation(row: ConversationRow): ConversationType { const parsedJson = JSON.parse(row.json); @@ -352,40 +347,16 @@ function isRenderer() { return process.type === 'renderer'; } -function getSQLiteVersion(db: Database): string { - const { sqlite_version } = db - .prepare('select sqlite_version() AS sqlite_version') - .get(); - - return sqlite_version; -} - -function getSchemaVersion(db: Database): number { - return db.pragma('schema_version', { simple: true }); -} - -function setUserVersion(db: Database, version: number): void { - if (!isNumber(version)) { - throw new Error(`setUserVersion: version ${version} is not a number`); - } - db.pragma(`user_version = ${version}`); -} function keyDatabase(db: Database, key: string): void { // https://www.zetetic.net/sqlcipher/sqlcipher-api/#key db.pragma(`key = "x'${key}'"`); } + function switchToWAL(db: Database): void { // https://sqlite.org/wal.html db.pragma('journal_mode = WAL'); db.pragma('synchronous = FULL'); } -function getUserVersion(db: Database): number { - return db.pragma('user_version', { simple: true }); -} - -function getSQLCipherVersion(db: Database): string | undefined { - return db.pragma('cipher_version', { simple: true }); -} function migrateSchemaVersion(db: Database): void { const userVersion = getUserVersion(db); @@ -457,2240 +428,6 @@ function openAndSetUpSQLCipher(filePath: string, { key }: { key: string }) { return db; } -function updateToSchemaVersion1(currentVersion: number, db: Database): void { - if (currentVersion >= 1) { - return; - } - - logger.info('updateToSchemaVersion1: starting...'); - - db.transaction(() => { - db.exec(` - CREATE TABLE messages( - id STRING PRIMARY KEY ASC, - json TEXT, - - unread INTEGER, - expires_at INTEGER, - sent_at INTEGER, - schemaVersion INTEGER, - conversationId STRING, - received_at INTEGER, - source STRING, - sourceDevice STRING, - hasAttachments INTEGER, - hasFileAttachments INTEGER, - hasVisualMediaAttachments INTEGER - ); - CREATE INDEX messages_unread ON messages ( - unread - ); - CREATE INDEX messages_expires_at ON messages ( - expires_at - ); - CREATE INDEX messages_receipt ON messages ( - sent_at - ); - CREATE INDEX messages_schemaVersion ON messages ( - schemaVersion - ); - CREATE INDEX messages_conversation ON messages ( - conversationId, - received_at - ); - CREATE INDEX messages_duplicate_check ON messages ( - source, - sourceDevice, - sent_at - ); - CREATE INDEX messages_hasAttachments ON messages ( - conversationId, - hasAttachments, - received_at - ); - CREATE INDEX messages_hasFileAttachments ON messages ( - conversationId, - hasFileAttachments, - received_at - ); - CREATE INDEX messages_hasVisualMediaAttachments ON messages ( - conversationId, - hasVisualMediaAttachments, - received_at - ); - CREATE TABLE unprocessed( - id STRING, - timestamp INTEGER, - json TEXT - ); - CREATE INDEX unprocessed_id ON unprocessed ( - id - ); - CREATE INDEX unprocessed_timestamp ON unprocessed ( - timestamp - ); - `); - - db.pragma('user_version = 1'); - })(); - - logger.info('updateToSchemaVersion1: success!'); -} - -function updateToSchemaVersion2(currentVersion: number, db: Database): void { - if (currentVersion >= 2) { - return; - } - - logger.info('updateToSchemaVersion2: starting...'); - - db.transaction(() => { - db.exec(` - ALTER TABLE messages - ADD COLUMN expireTimer INTEGER; - - ALTER TABLE messages - ADD COLUMN expirationStartTimestamp INTEGER; - - ALTER TABLE messages - ADD COLUMN type STRING; - - CREATE INDEX messages_expiring ON messages ( - expireTimer, - expirationStartTimestamp, - expires_at - ); - - UPDATE messages SET - expirationStartTimestamp = json_extract(json, '$.expirationStartTimestamp'), - expireTimer = json_extract(json, '$.expireTimer'), - type = json_extract(json, '$.type'); - `); - db.pragma('user_version = 2'); - })(); - logger.info('updateToSchemaVersion2: success!'); -} - -function updateToSchemaVersion3(currentVersion: number, db: Database): void { - if (currentVersion >= 3) { - return; - } - - logger.info('updateToSchemaVersion3: starting...'); - - db.transaction(() => { - db.exec(` - DROP INDEX messages_expiring; - DROP INDEX messages_unread; - - CREATE INDEX messages_without_timer ON messages ( - expireTimer, - expires_at, - type - ) WHERE expires_at IS NULL AND expireTimer IS NOT NULL; - - CREATE INDEX messages_unread ON messages ( - conversationId, - unread - ) WHERE unread IS NOT NULL; - - ANALYZE; - `); - - db.pragma('user_version = 3'); - })(); - - logger.info('updateToSchemaVersion3: success!'); -} - -function updateToSchemaVersion4(currentVersion: number, db: Database): void { - if (currentVersion >= 4) { - return; - } - - logger.info('updateToSchemaVersion4: starting...'); - - db.transaction(() => { - db.exec(` - CREATE TABLE conversations( - id STRING PRIMARY KEY ASC, - json TEXT, - - active_at INTEGER, - type STRING, - members TEXT, - name TEXT, - profileName TEXT - ); - CREATE INDEX conversations_active ON conversations ( - active_at - ) WHERE active_at IS NOT NULL; - - CREATE INDEX conversations_type ON conversations ( - type - ) WHERE type IS NOT NULL; - `); - - db.pragma('user_version = 4'); - })(); - - logger.info('updateToSchemaVersion4: success!'); -} - -function updateToSchemaVersion6(currentVersion: number, db: Database): void { - if (currentVersion >= 6) { - return; - } - logger.info('updateToSchemaVersion6: starting...'); - - db.transaction(() => { - db.exec(` - -- key-value, ids are strings, one extra column - CREATE TABLE sessions( - id STRING PRIMARY KEY ASC, - number STRING, - json TEXT - ); - CREATE INDEX sessions_number ON sessions ( - number - ) WHERE number IS NOT NULL; - -- key-value, ids are strings - CREATE TABLE groups( - id STRING PRIMARY KEY ASC, - json TEXT - ); - CREATE TABLE identityKeys( - id STRING PRIMARY KEY ASC, - json TEXT - ); - CREATE TABLE items( - id STRING PRIMARY KEY ASC, - json TEXT - ); - -- key-value, ids are integers - CREATE TABLE preKeys( - id INTEGER PRIMARY KEY ASC, - json TEXT - ); - CREATE TABLE signedPreKeys( - id INTEGER PRIMARY KEY ASC, - json TEXT - ); - `); - - db.pragma('user_version = 6'); - })(); - - logger.info('updateToSchemaVersion6: success!'); -} - -function updateToSchemaVersion7(currentVersion: number, db: Database): void { - if (currentVersion >= 7) { - return; - } - logger.info('updateToSchemaVersion7: starting...'); - - db.transaction(() => { - db.exec(` - -- SQLite has been coercing our STRINGs into numbers, so we force it with TEXT - -- We create a new table then copy the data into it, since we can't modify columns - DROP INDEX sessions_number; - ALTER TABLE sessions RENAME TO sessions_old; - - CREATE TABLE sessions( - id TEXT PRIMARY KEY, - number TEXT, - json TEXT - ); - CREATE INDEX sessions_number ON sessions ( - number - ) WHERE number IS NOT NULL; - INSERT INTO sessions(id, number, json) - SELECT "+" || id, number, json FROM sessions_old; - DROP TABLE sessions_old; - `); - - db.pragma('user_version = 7'); - })(); - logger.info('updateToSchemaVersion7: success!'); -} - -function updateToSchemaVersion8(currentVersion: number, db: Database): void { - if (currentVersion >= 8) { - return; - } - logger.info('updateToSchemaVersion8: starting...'); - db.transaction(() => { - db.exec(` - -- First, we pull a new body field out of the message table's json blob - ALTER TABLE messages - ADD COLUMN body TEXT; - UPDATE messages SET body = json_extract(json, '$.body'); - - -- Then we create our full-text search table and populate it - CREATE VIRTUAL TABLE messages_fts - USING fts5(id UNINDEXED, body); - - INSERT INTO messages_fts(id, body) - SELECT id, body FROM messages; - - -- Then we set up triggers to keep the full-text search table up to date - CREATE TRIGGER messages_on_insert AFTER INSERT ON messages BEGIN - INSERT INTO messages_fts ( - id, - body - ) VALUES ( - new.id, - new.body - ); - END; - CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN - DELETE FROM messages_fts WHERE id = old.id; - END; - CREATE TRIGGER messages_on_update AFTER UPDATE ON messages BEGIN - DELETE FROM messages_fts WHERE id = old.id; - INSERT INTO messages_fts( - id, - body - ) VALUES ( - new.id, - new.body - ); - END; - `); - - // For formatting search results: - // https://sqlite.org/fts5.html#the_highlight_function - // https://sqlite.org/fts5.html#the_snippet_function - - db.pragma('user_version = 8'); - })(); - logger.info('updateToSchemaVersion8: success!'); -} - -function updateToSchemaVersion9(currentVersion: number, db: Database): void { - if (currentVersion >= 9) { - return; - } - logger.info('updateToSchemaVersion9: starting...'); - - db.transaction(() => { - db.exec(` - CREATE TABLE attachment_downloads( - id STRING primary key, - timestamp INTEGER, - pending INTEGER, - json TEXT - ); - - CREATE INDEX attachment_downloads_timestamp - ON attachment_downloads ( - timestamp - ) WHERE pending = 0; - CREATE INDEX attachment_downloads_pending - ON attachment_downloads ( - pending - ) WHERE pending != 0; - `); - - db.pragma('user_version = 9'); - })(); - - logger.info('updateToSchemaVersion9: success!'); -} - -function updateToSchemaVersion10(currentVersion: number, db: Database): void { - if (currentVersion >= 10) { - return; - } - logger.info('updateToSchemaVersion10: starting...'); - db.transaction(() => { - db.exec(` - DROP INDEX unprocessed_id; - DROP INDEX unprocessed_timestamp; - ALTER TABLE unprocessed RENAME TO unprocessed_old; - - CREATE TABLE unprocessed( - id STRING, - timestamp INTEGER, - version INTEGER, - attempts INTEGER, - envelope TEXT, - decrypted TEXT, - source TEXT, - sourceDevice TEXT, - serverTimestamp INTEGER - ); - - CREATE INDEX unprocessed_id ON unprocessed ( - id - ); - CREATE INDEX unprocessed_timestamp ON unprocessed ( - timestamp - ); - - INSERT INTO unprocessed ( - id, - timestamp, - version, - attempts, - envelope, - decrypted, - source, - sourceDevice, - serverTimestamp - ) SELECT - id, - timestamp, - json_extract(json, '$.version'), - json_extract(json, '$.attempts'), - json_extract(json, '$.envelope'), - json_extract(json, '$.decrypted'), - json_extract(json, '$.source'), - json_extract(json, '$.sourceDevice'), - json_extract(json, '$.serverTimestamp') - FROM unprocessed_old; - - DROP TABLE unprocessed_old; - `); - - db.pragma('user_version = 10'); - })(); - logger.info('updateToSchemaVersion10: success!'); -} - -function updateToSchemaVersion11(currentVersion: number, db: Database): void { - if (currentVersion >= 11) { - return; - } - logger.info('updateToSchemaVersion11: starting...'); - - db.transaction(() => { - db.exec(` - DROP TABLE groups; - `); - - db.pragma('user_version = 11'); - })(); - logger.info('updateToSchemaVersion11: success!'); -} - -function updateToSchemaVersion12(currentVersion: number, db: Database): void { - if (currentVersion >= 12) { - return; - } - - logger.info('updateToSchemaVersion12: starting...'); - db.transaction(() => { - db.exec(` - CREATE TABLE sticker_packs( - id TEXT PRIMARY KEY, - key TEXT NOT NULL, - - author STRING, - coverStickerId INTEGER, - createdAt INTEGER, - downloadAttempts INTEGER, - installedAt INTEGER, - lastUsed INTEGER, - status STRING, - stickerCount INTEGER, - title STRING - ); - - CREATE TABLE stickers( - id INTEGER NOT NULL, - packId TEXT NOT NULL, - - emoji STRING, - height INTEGER, - isCoverOnly INTEGER, - lastUsed INTEGER, - path STRING, - width INTEGER, - - PRIMARY KEY (id, packId), - CONSTRAINT stickers_fk - FOREIGN KEY (packId) - REFERENCES sticker_packs(id) - ON DELETE CASCADE - ); - - CREATE INDEX stickers_recents - ON stickers ( - lastUsed - ) WHERE lastUsed IS NOT NULL; - - CREATE TABLE sticker_references( - messageId STRING, - packId TEXT, - CONSTRAINT sticker_references_fk - FOREIGN KEY(packId) - REFERENCES sticker_packs(id) - ON DELETE CASCADE - ); - `); - - db.pragma('user_version = 12'); - })(); - logger.info('updateToSchemaVersion12: success!'); -} - -function updateToSchemaVersion13(currentVersion: number, db: Database): void { - if (currentVersion >= 13) { - return; - } - - logger.info('updateToSchemaVersion13: starting...'); - db.transaction(() => { - db.exec(` - ALTER TABLE sticker_packs ADD COLUMN attemptedStatus STRING; - `); - - db.pragma('user_version = 13'); - })(); - logger.info('updateToSchemaVersion13: success!'); -} - -function updateToSchemaVersion14(currentVersion: number, db: Database): void { - if (currentVersion >= 14) { - return; - } - - logger.info('updateToSchemaVersion14: starting...'); - db.transaction(() => { - db.exec(` - CREATE TABLE emojis( - shortName STRING PRIMARY KEY, - lastUsage INTEGER - ); - - CREATE INDEX emojis_lastUsage - ON emojis ( - lastUsage - ); - `); - - db.pragma('user_version = 14'); - })(); - - logger.info('updateToSchemaVersion14: success!'); -} - -function updateToSchemaVersion15(currentVersion: number, db: Database): void { - if (currentVersion >= 15) { - return; - } - - logger.info('updateToSchemaVersion15: starting...'); - db.transaction(() => { - db.exec(` - -- SQLite has again coerced our STRINGs into numbers, so we force it with TEXT - -- We create a new table then copy the data into it, since we can't modify columns - - DROP INDEX emojis_lastUsage; - ALTER TABLE emojis RENAME TO emojis_old; - - CREATE TABLE emojis( - shortName TEXT PRIMARY KEY, - lastUsage INTEGER - ); - CREATE INDEX emojis_lastUsage - ON emojis ( - lastUsage - ); - - DELETE FROM emojis WHERE shortName = 1; - INSERT INTO emojis(shortName, lastUsage) - SELECT shortName, lastUsage FROM emojis_old; - - DROP TABLE emojis_old; - `); - - db.pragma('user_version = 15'); - })(); - logger.info('updateToSchemaVersion15: success!'); -} - -function updateToSchemaVersion16(currentVersion: number, db: Database): void { - if (currentVersion >= 16) { - return; - } - - logger.info('updateToSchemaVersion16: starting...'); - db.transaction(() => { - db.exec(` - ALTER TABLE messages - ADD COLUMN messageTimer INTEGER; - ALTER TABLE messages - ADD COLUMN messageTimerStart INTEGER; - ALTER TABLE messages - ADD COLUMN messageTimerExpiresAt INTEGER; - ALTER TABLE messages - ADD COLUMN isErased INTEGER; - - CREATE INDEX messages_message_timer ON messages ( - messageTimer, - messageTimerStart, - messageTimerExpiresAt, - isErased - ) WHERE messageTimer IS NOT NULL; - - -- Updating full-text triggers to avoid anything with a messageTimer set - - DROP TRIGGER messages_on_insert; - DROP TRIGGER messages_on_delete; - DROP TRIGGER messages_on_update; - - CREATE TRIGGER messages_on_insert AFTER INSERT ON messages - WHEN new.messageTimer IS NULL - BEGIN - INSERT INTO messages_fts ( - id, - body - ) VALUES ( - new.id, - new.body - ); - END; - CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN - DELETE FROM messages_fts WHERE id = old.id; - END; - CREATE TRIGGER messages_on_update AFTER UPDATE ON messages - WHEN new.messageTimer IS NULL - BEGIN - DELETE FROM messages_fts WHERE id = old.id; - INSERT INTO messages_fts( - id, - body - ) VALUES ( - new.id, - new.body - ); - END; - `); - - db.pragma('user_version = 16'); - })(); - logger.info('updateToSchemaVersion16: success!'); -} - -function updateToSchemaVersion17(currentVersion: number, db: Database): void { - if (currentVersion >= 17) { - return; - } - - logger.info('updateToSchemaVersion17: starting...'); - db.transaction(() => { - try { - db.exec(` - ALTER TABLE messages - ADD COLUMN isViewOnce INTEGER; - - DROP INDEX messages_message_timer; - `); - } catch (error) { - logger.info( - 'updateToSchemaVersion17: Message table already had isViewOnce column' - ); - } - - try { - db.exec('DROP INDEX messages_view_once;'); - } catch (error) { - logger.info( - 'updateToSchemaVersion17: Index messages_view_once did not already exist' - ); - } - - db.exec(` - CREATE INDEX messages_view_once ON messages ( - isErased - ) WHERE isViewOnce = 1; - - -- Updating full-text triggers to avoid anything with isViewOnce = 1 - - DROP TRIGGER messages_on_insert; - DROP TRIGGER messages_on_update; - - CREATE TRIGGER messages_on_insert AFTER INSERT ON messages - WHEN new.isViewOnce != 1 - BEGIN - INSERT INTO messages_fts ( - id, - body - ) VALUES ( - new.id, - new.body - ); - END; - CREATE TRIGGER messages_on_update AFTER UPDATE ON messages - WHEN new.isViewOnce != 1 - BEGIN - DELETE FROM messages_fts WHERE id = old.id; - INSERT INTO messages_fts( - id, - body - ) VALUES ( - new.id, - new.body - ); - END; - `); - - db.pragma('user_version = 17'); - })(); - logger.info('updateToSchemaVersion17: success!'); -} - -function updateToSchemaVersion18(currentVersion: number, db: Database): void { - if (currentVersion >= 18) { - return; - } - - logger.info('updateToSchemaVersion18: starting...'); - db.transaction(() => { - db.exec(` - -- Delete and rebuild full-text search index to capture everything - - DELETE FROM messages_fts; - INSERT INTO messages_fts(messages_fts) VALUES('rebuild'); - - INSERT INTO messages_fts(id, body) - SELECT id, body FROM messages WHERE isViewOnce IS NULL OR isViewOnce != 1; - - -- Fixing full-text triggers - - DROP TRIGGER messages_on_insert; - DROP TRIGGER messages_on_update; - - CREATE TRIGGER messages_on_insert AFTER INSERT ON messages - WHEN new.isViewOnce IS NULL OR new.isViewOnce != 1 - BEGIN - INSERT INTO messages_fts ( - id, - body - ) VALUES ( - new.id, - new.body - ); - END; - CREATE TRIGGER messages_on_update AFTER UPDATE ON messages - WHEN new.isViewOnce IS NULL OR new.isViewOnce != 1 - BEGIN - DELETE FROM messages_fts WHERE id = old.id; - INSERT INTO messages_fts( - id, - body - ) VALUES ( - new.id, - new.body - ); - END; - `); - - db.pragma('user_version = 18'); - })(); - logger.info('updateToSchemaVersion18: success!'); -} - -function updateToSchemaVersion19(currentVersion: number, db: Database): void { - if (currentVersion >= 19) { - return; - } - - logger.info('updateToSchemaVersion19: starting...'); - db.transaction(() => { - db.exec(` - ALTER TABLE conversations - ADD COLUMN profileFamilyName TEXT; - ALTER TABLE conversations - ADD COLUMN profileFullName TEXT; - - -- Preload new field with the profileName we already have - UPDATE conversations SET profileFullName = profileName; - `); - - db.pragma('user_version = 19'); - })(); - - logger.info('updateToSchemaVersion19: success!'); -} - -function updateToSchemaVersion20(currentVersion: number, db: Database): void { - if (currentVersion >= 20) { - return; - } - - logger.info('updateToSchemaVersion20: starting...'); - db.transaction(() => { - // The triggers on the messages table slow down this migration - // significantly, so we drop them and recreate them later. - // Drop triggers - const triggers = db - .prepare( - 'SELECT * FROM sqlite_master WHERE type = "trigger" AND tbl_name = "messages"' - ) - .all(); - - for (const trigger of triggers) { - db.exec(`DROP TRIGGER ${trigger.name}`); - } - - // Create new columns and indices - db.exec(` - ALTER TABLE conversations ADD COLUMN e164 TEXT; - ALTER TABLE conversations ADD COLUMN uuid TEXT; - ALTER TABLE conversations ADD COLUMN groupId TEXT; - ALTER TABLE messages ADD COLUMN sourceUuid TEXT; - ALTER TABLE sessions RENAME COLUMN number TO conversationId; - CREATE INDEX conversations_e164 ON conversations(e164); - CREATE INDEX conversations_uuid ON conversations(uuid); - CREATE INDEX conversations_groupId ON conversations(groupId); - CREATE INDEX messages_sourceUuid on messages(sourceUuid); - - -- Migrate existing IDs - UPDATE conversations SET e164 = '+' || id WHERE type = 'private'; - UPDATE conversations SET groupId = id WHERE type = 'group'; - `); - - // Drop invalid groups and any associated messages - const maybeInvalidGroups = db - .prepare( - "SELECT * FROM conversations WHERE type = 'group' AND members IS NULL;" - ) - .all(); - for (const group of maybeInvalidGroups) { - const json: { id: string; members: Array } = JSON.parse(group.json); - if (!json.members || !json.members.length) { - db.prepare('DELETE FROM conversations WHERE id = $id;').run({ - id: json.id, - }); - db.prepare( - 'DELETE FROM messages WHERE conversationId = $id;' - ).run({ id: json.id }); - } - } - - // Generate new IDs and alter data - const allConversations = db - .prepare('SELECT * FROM conversations;') - .all(); - const allConversationsByOldId = keyBy(allConversations, 'id'); - - for (const row of allConversations) { - const oldId = row.id; - const newId = generateUUID(); - allConversationsByOldId[oldId].id = newId; - const patchObj: any = { id: newId }; - if (row.type === 'private') { - patchObj.e164 = `+${oldId}`; - } else if (row.type === 'group') { - patchObj.groupId = oldId; - } - const patch = JSON.stringify(patchObj); - - db.prepare( - ` - UPDATE conversations - SET id = $newId, json = JSON_PATCH(json, $patch) - WHERE id = $oldId - ` - ).run({ - newId, - oldId, - patch, - }); - const messagePatch = JSON.stringify({ conversationId: newId }); - db.prepare( - ` - UPDATE messages - SET conversationId = $newId, json = JSON_PATCH(json, $patch) - WHERE conversationId = $oldId - ` - ).run({ newId, oldId, patch: messagePatch }); - } - - const groupConversations: Array<{ - id: string; - members: string; - json: string; - }> = db - .prepare( - ` - SELECT id, members, json FROM conversations WHERE type = 'group'; - ` - ) - .all(); - - // Update group conversations, point members at new conversation ids - groupConversations.forEach(groupRow => { - const members = groupRow.members.split(/\s?\+/).filter(Boolean); - const newMembers = []; - for (const m of members) { - const memberRow = allConversationsByOldId[m]; - - if (memberRow) { - newMembers.push(memberRow.id); - } else { - // We didn't previously have a private conversation for this member, - // we need to create one - const id = generateUUID(); - saveConversation({ - id, - e164: m, - type: 'private', - version: 2, - unreadCount: 0, - verified: 0, - - // Not directly used by saveConversation, but are necessary - // for conversation model - inbox_position: 0, - isPinned: false, - lastMessageDeletedForEveryone: false, - markedUnread: false, - messageCount: 0, - sentMessageCount: 0, - profileSharing: false, - }); - - newMembers.push(id); - } - } - const json = { ...jsonToObject(groupRow.json), members: newMembers }; - const newMembersValue = newMembers.join(' '); - db.prepare( - ` - UPDATE conversations - SET members = $newMembersValue, json = $newJsonValue - WHERE id = $id - ` - ).run({ - id: groupRow.id, - newMembersValue, - newJsonValue: objectToJSON(json), - }); - }); - - // Update sessions to stable IDs - const allSessions = db.prepare('SELECT * FROM sessions;').all(); - for (const session of allSessions) { - // Not using patch here so we can explicitly delete a property rather than - // implicitly delete via null - const newJson = JSON.parse(session.json); - const conversation = allConversationsByOldId[newJson.number.substr(1)]; - if (conversation) { - newJson.conversationId = conversation.id; - newJson.id = `${newJson.conversationId}.${newJson.deviceId}`; - } - delete newJson.number; - db.prepare( - ` - UPDATE sessions - SET id = $newId, json = $newJson, conversationId = $newConversationId - WHERE id = $oldId - ` - ).run({ - newId: newJson.id, - newJson: objectToJSON(newJson), - oldId: session.id, - newConversationId: newJson.conversationId, - }); - } - - // Update identity keys to stable IDs - const allIdentityKeys = db - .prepare('SELECT * FROM identityKeys;') - .all(); - for (const identityKey of allIdentityKeys) { - const newJson = JSON.parse(identityKey.json); - newJson.id = allConversationsByOldId[newJson.id]; - db.prepare( - ` - UPDATE identityKeys - SET id = $newId, json = $newJson - WHERE id = $oldId - ` - ).run({ - newId: newJson.id, - newJson: objectToJSON(newJson), - oldId: identityKey.id, - }); - } - - // Recreate triggers - for (const trigger of triggers) { - db.exec(trigger.sql); - } - - db.pragma('user_version = 20'); - })(); - logger.info('updateToSchemaVersion20: success!'); -} - -function updateToSchemaVersion21(currentVersion: number, db: Database): void { - if (currentVersion >= 21) { - return; - } - - db.transaction(() => { - db.exec(` - UPDATE conversations - SET json = json_set( - json, - '$.messageCount', - (SELECT count(*) FROM messages WHERE messages.conversationId = conversations.id) - ); - UPDATE conversations - SET json = json_set( - json, - '$.sentMessageCount', - (SELECT count(*) FROM messages WHERE messages.conversationId = conversations.id AND messages.type = 'outgoing') - ); - `); - db.pragma('user_version = 21'); - })(); - logger.info('updateToSchemaVersion21: success!'); -} - -function updateToSchemaVersion22(currentVersion: number, db: Database): void { - if (currentVersion >= 22) { - return; - } - - db.transaction(() => { - db.exec(` - ALTER TABLE unprocessed - ADD COLUMN sourceUuid STRING; - `); - - db.pragma('user_version = 22'); - })(); - logger.info('updateToSchemaVersion22: success!'); -} - -function updateToSchemaVersion23(currentVersion: number, db: Database): void { - if (currentVersion >= 23) { - return; - } - - db.transaction(() => { - db.exec(` - -- Remove triggers which keep full-text search up to date - DROP TRIGGER messages_on_insert; - DROP TRIGGER messages_on_update; - DROP TRIGGER messages_on_delete; - `); - - db.pragma('user_version = 23'); - })(); - logger.info('updateToSchemaVersion23: success!'); -} - -function updateToSchemaVersion24(currentVersion: number, db: Database): void { - if (currentVersion >= 24) { - return; - } - - db.transaction(() => { - db.exec(` - ALTER TABLE conversations - ADD COLUMN profileLastFetchedAt INTEGER; - `); - - db.pragma('user_version = 24'); - })(); - logger.info('updateToSchemaVersion24: success!'); -} - -async function updateToSchemaVersion25(currentVersion: number, db: Database) { - if (currentVersion >= 25) { - return; - } - - db.transaction(() => { - db.exec(` - ALTER TABLE messages - RENAME TO old_messages - `); - - const indicesToDrop = [ - 'messages_expires_at', - 'messages_receipt', - 'messages_schemaVersion', - 'messages_conversation', - 'messages_duplicate_check', - 'messages_hasAttachments', - 'messages_hasFileAttachments', - 'messages_hasVisualMediaAttachments', - 'messages_without_timer', - 'messages_unread', - 'messages_view_once', - 'messages_sourceUuid', - ]; - for (const index of indicesToDrop) { - db.exec(`DROP INDEX IF EXISTS ${index};`); - } - - db.exec(` - -- - -- Create a new table with a different primary key - -- - - CREATE TABLE messages( - rowid INTEGER PRIMARY KEY ASC, - id STRING UNIQUE, - json TEXT, - unread INTEGER, - expires_at INTEGER, - sent_at INTEGER, - schemaVersion INTEGER, - conversationId STRING, - received_at INTEGER, - source STRING, - sourceDevice STRING, - hasAttachments INTEGER, - hasFileAttachments INTEGER, - hasVisualMediaAttachments INTEGER, - expireTimer INTEGER, - expirationStartTimestamp INTEGER, - type STRING, - body TEXT, - messageTimer INTEGER, - messageTimerStart INTEGER, - messageTimerExpiresAt INTEGER, - isErased INTEGER, - isViewOnce INTEGER, - sourceUuid TEXT); - - -- Create index in lieu of old PRIMARY KEY - CREATE INDEX messages_id ON messages (id ASC); - - -- - -- Recreate indices - -- - - CREATE INDEX messages_expires_at ON messages (expires_at); - - CREATE INDEX messages_receipt ON messages (sent_at); - - CREATE INDEX messages_schemaVersion ON messages (schemaVersion); - - CREATE INDEX messages_conversation ON messages - (conversationId, received_at); - - CREATE INDEX messages_duplicate_check ON messages - (source, sourceDevice, sent_at); - - CREATE INDEX messages_hasAttachments ON messages - (conversationId, hasAttachments, received_at); - - CREATE INDEX messages_hasFileAttachments ON messages - (conversationId, hasFileAttachments, received_at); - - CREATE INDEX messages_hasVisualMediaAttachments ON messages - (conversationId, hasVisualMediaAttachments, received_at); - - CREATE INDEX messages_without_timer ON messages - (expireTimer, expires_at, type) - WHERE expires_at IS NULL AND expireTimer IS NOT NULL; - - CREATE INDEX messages_unread ON messages - (conversationId, unread) WHERE unread IS NOT NULL; - - CREATE INDEX messages_view_once ON messages - (isErased) WHERE isViewOnce = 1; - - CREATE INDEX messages_sourceUuid on messages(sourceUuid); - - -- New index for searchMessages - CREATE INDEX messages_searchOrder on messages(received_at, sent_at); - - -- - -- Re-create messages_fts and add triggers - -- - - DROP TABLE messages_fts; - - CREATE VIRTUAL TABLE messages_fts USING fts5(body); - - CREATE TRIGGER messages_on_insert AFTER INSERT ON messages - WHEN new.isViewOnce IS NULL OR new.isViewOnce != 1 - BEGIN - INSERT INTO messages_fts - (rowid, body) - VALUES - (new.rowid, new.body); - END; - - CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN - DELETE FROM messages_fts WHERE rowid = old.rowid; - END; - - CREATE TRIGGER messages_on_update AFTER UPDATE ON messages - WHEN new.isViewOnce IS NULL OR new.isViewOnce != 1 - BEGIN - DELETE FROM messages_fts WHERE rowid = old.rowid; - INSERT INTO messages_fts - (rowid, body) - VALUES - (new.rowid, new.body); - END; - - -- - -- Copy data over - -- - - INSERT INTO messages - ( - id, json, unread, expires_at, sent_at, schemaVersion, conversationId, - received_at, source, sourceDevice, hasAttachments, hasFileAttachments, - hasVisualMediaAttachments, expireTimer, expirationStartTimestamp, type, - body, messageTimer, messageTimerStart, messageTimerExpiresAt, isErased, - isViewOnce, sourceUuid - ) - SELECT - id, json, unread, expires_at, sent_at, schemaVersion, conversationId, - received_at, source, sourceDevice, hasAttachments, hasFileAttachments, - hasVisualMediaAttachments, expireTimer, expirationStartTimestamp, type, - body, messageTimer, messageTimerStart, messageTimerExpiresAt, isErased, - isViewOnce, sourceUuid - FROM old_messages; - - -- Drop old database - DROP TABLE old_messages; - `); - - db.pragma('user_version = 25'); - })(); - logger.info('updateToSchemaVersion25: success!'); -} - -async function updateToSchemaVersion26(currentVersion: number, db: Database) { - if (currentVersion >= 26) { - return; - } - - db.transaction(() => { - db.exec(` - DROP TRIGGER messages_on_insert; - DROP TRIGGER messages_on_update; - - CREATE TRIGGER messages_on_insert AFTER INSERT ON messages - WHEN new.isViewOnce IS NULL OR new.isViewOnce != 1 - BEGIN - INSERT INTO messages_fts - (rowid, body) - VALUES - (new.rowid, new.body); - END; - - CREATE TRIGGER messages_on_update AFTER UPDATE ON messages - WHEN new.body != old.body AND - (new.isViewOnce IS NULL OR new.isViewOnce != 1) - BEGIN - DELETE FROM messages_fts WHERE rowid = old.rowid; - INSERT INTO messages_fts - (rowid, body) - VALUES - (new.rowid, new.body); - END; - `); - - db.pragma('user_version = 26'); - })(); - logger.info('updateToSchemaVersion26: success!'); -} - -async function updateToSchemaVersion27(currentVersion: number, db: Database) { - if (currentVersion >= 27) { - return; - } - - db.transaction(() => { - db.exec(` - DELETE FROM messages_fts WHERE rowid IN - (SELECT rowid FROM messages WHERE body IS NULL); - - DROP TRIGGER messages_on_update; - - CREATE TRIGGER messages_on_update AFTER UPDATE ON messages - WHEN - new.body IS NULL OR - ((old.body IS NULL OR new.body != old.body) AND - (new.isViewOnce IS NULL OR new.isViewOnce != 1)) - BEGIN - DELETE FROM messages_fts WHERE rowid = old.rowid; - INSERT INTO messages_fts - (rowid, body) - VALUES - (new.rowid, new.body); - END; - - CREATE TRIGGER messages_on_view_once_update AFTER UPDATE ON messages - WHEN - new.body IS NOT NULL AND new.isViewOnce = 1 - BEGIN - DELETE FROM messages_fts WHERE rowid = old.rowid; - END; - `); - - db.pragma('user_version = 27'); - })(); - logger.info('updateToSchemaVersion27: success!'); -} - -function updateToSchemaVersion28(currentVersion: number, db: Database) { - if (currentVersion >= 28) { - return; - } - - db.transaction(() => { - db.exec(` - CREATE TABLE jobs( - id TEXT PRIMARY KEY, - queueType TEXT STRING NOT NULL, - timestamp INTEGER NOT NULL, - data STRING TEXT - ); - - CREATE INDEX jobs_timestamp ON jobs (timestamp); - `); - - db.pragma('user_version = 28'); - })(); - logger.info('updateToSchemaVersion28: success!'); -} - -function updateToSchemaVersion29(currentVersion: number, db: Database) { - if (currentVersion >= 29) { - return; - } - - db.transaction(() => { - db.exec(` - CREATE TABLE reactions( - conversationId STRING, - emoji STRING, - fromId STRING, - messageReceivedAt INTEGER, - targetAuthorUuid STRING, - targetTimestamp INTEGER, - unread INTEGER - ); - - CREATE INDEX reactions_unread ON reactions ( - unread, - conversationId - ); - - CREATE INDEX reaction_identifier ON reactions ( - emoji, - targetAuthorUuid, - targetTimestamp - ); - `); - - db.pragma('user_version = 29'); - })(); - logger.info('updateToSchemaVersion29: success!'); -} - -function updateToSchemaVersion30(currentVersion: number, db: Database) { - if (currentVersion >= 30) { - return; - } - - db.transaction(() => { - db.exec(` - CREATE TABLE senderKeys( - id TEXT PRIMARY KEY NOT NULL, - senderId TEXT NOT NULL, - distributionId TEXT NOT NULL, - data BLOB NOT NULL, - lastUpdatedDate NUMBER NOT NULL - ); - `); - - db.pragma('user_version = 30'); - })(); - logger.info('updateToSchemaVersion30: success!'); -} - -function updateToSchemaVersion31(currentVersion: number, db: Database): void { - if (currentVersion >= 31) { - return; - } - logger.info('updateToSchemaVersion31: starting...'); - db.transaction(() => { - db.exec(` - DROP INDEX unprocessed_id; - DROP INDEX unprocessed_timestamp; - ALTER TABLE unprocessed RENAME TO unprocessed_old; - - CREATE TABLE unprocessed( - id STRING PRIMARY KEY ASC, - timestamp INTEGER, - version INTEGER, - attempts INTEGER, - envelope TEXT, - decrypted TEXT, - source TEXT, - sourceDevice TEXT, - serverTimestamp INTEGER, - sourceUuid STRING - ); - - CREATE INDEX unprocessed_timestamp ON unprocessed ( - timestamp - ); - - INSERT OR REPLACE INTO unprocessed - (id, timestamp, version, attempts, envelope, decrypted, source, - sourceDevice, serverTimestamp, sourceUuid) - SELECT - id, timestamp, version, attempts, envelope, decrypted, source, - sourceDevice, serverTimestamp, sourceUuid - FROM unprocessed_old; - - DROP TABLE unprocessed_old; - `); - - db.pragma('user_version = 31'); - })(); - logger.info('updateToSchemaVersion31: success!'); -} - -function updateToSchemaVersion32(currentVersion: number, db: Database) { - if (currentVersion >= 32) { - return; - } - - db.transaction(() => { - db.exec(` - ALTER TABLE messages - ADD COLUMN serverGuid STRING NULL; - - ALTER TABLE unprocessed - ADD COLUMN serverGuid STRING NULL; - `); - - db.pragma('user_version = 32'); - })(); - logger.info('updateToSchemaVersion32: success!'); -} - -function updateToSchemaVersion33(currentVersion: number, db: Database) { - if (currentVersion >= 33) { - return; - } - - db.transaction(() => { - db.exec(` - -- These indexes should exist, but we add "IF EXISTS" for safety. - DROP INDEX IF EXISTS messages_expires_at; - DROP INDEX IF EXISTS messages_without_timer; - - ALTER TABLE messages - ADD COLUMN - expiresAt INT - GENERATED ALWAYS - AS (expirationStartTimestamp + (expireTimer * 1000)); - - CREATE INDEX message_expires_at ON messages ( - expiresAt - ); - - CREATE INDEX outgoing_messages_without_expiration_start_timestamp ON messages ( - expireTimer, expirationStartTimestamp, type - ) - WHERE expireTimer IS NOT NULL AND expirationStartTimestamp IS NULL; - `); - - db.pragma('user_version = 33'); - })(); - logger.info('updateToSchemaVersion33: success!'); -} - -function updateToSchemaVersion34(currentVersion: number, db: Database) { - if (currentVersion >= 34) { - return; - } - - db.transaction(() => { - db.exec(` - -- This index should exist, but we add "IF EXISTS" for safety. - DROP INDEX IF EXISTS outgoing_messages_without_expiration_start_timestamp; - - CREATE INDEX messages_unexpectedly_missing_expiration_start_timestamp ON messages ( - expireTimer, expirationStartTimestamp, type - ) - WHERE expireTimer IS NOT NULL AND expirationStartTimestamp IS NULL; - `); - - db.pragma('user_version = 34'); - })(); - logger.info('updateToSchemaVersion34: success!'); -} - -function updateToSchemaVersion35(currentVersion: number, db: Database) { - if (currentVersion >= 35) { - return; - } - - db.transaction(() => { - db.exec(` - CREATE INDEX expiring_message_by_conversation_and_received_at - ON messages - ( - expirationStartTimestamp, - expireTimer, - conversationId, - received_at - ); - `); - - db.pragma('user_version = 35'); - })(); - logger.info('updateToSchemaVersion35: success!'); -} - -// Reverted -function updateToSchemaVersion36(currentVersion: number, db: Database) { - if (currentVersion >= 36) { - return; - } - - db.pragma('user_version = 36'); - logger.info('updateToSchemaVersion36: success!'); -} - -function updateToSchemaVersion37(currentVersion: number, db: Database) { - if (currentVersion >= 37) { - return; - } - - db.transaction(() => { - db.exec(` - -- Create send log primary table - - CREATE TABLE sendLogPayloads( - id INTEGER PRIMARY KEY ASC, - - timestamp INTEGER NOT NULL, - contentHint INTEGER NOT NULL, - proto BLOB NOT NULL - ); - - CREATE INDEX sendLogPayloadsByTimestamp ON sendLogPayloads (timestamp); - - -- Create send log recipients table with foreign key relationship to payloads - - CREATE TABLE sendLogRecipients( - payloadId INTEGER NOT NULL, - - recipientUuid STRING NOT NULL, - deviceId INTEGER NOT NULL, - - PRIMARY KEY (payloadId, recipientUuid, deviceId), - - CONSTRAINT sendLogRecipientsForeignKey - FOREIGN KEY (payloadId) - REFERENCES sendLogPayloads(id) - ON DELETE CASCADE - ); - - CREATE INDEX sendLogRecipientsByRecipient - ON sendLogRecipients (recipientUuid, deviceId); - - -- Create send log messages table with foreign key relationship to payloads - - CREATE TABLE sendLogMessageIds( - payloadId INTEGER NOT NULL, - - messageId STRING NOT NULL, - - PRIMARY KEY (payloadId, messageId), - - CONSTRAINT sendLogMessageIdsForeignKey - FOREIGN KEY (payloadId) - REFERENCES sendLogPayloads(id) - ON DELETE CASCADE - ); - - CREATE INDEX sendLogMessageIdsByMessage - ON sendLogMessageIds (messageId); - - -- Recreate messages table delete trigger with send log support - - DROP TRIGGER messages_on_delete; - - CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN - DELETE FROM messages_fts WHERE rowid = old.rowid; - DELETE FROM sendLogPayloads WHERE id IN ( - SELECT payloadId FROM sendLogMessageIds - WHERE messageId = old.id - ); - END; - - --- Add messageId column to reactions table to properly track proto associations - - ALTER TABLE reactions ADD column messageId STRING; - `); - - db.pragma('user_version = 37'); - })(); - logger.info('updateToSchemaVersion37: success!'); -} - -function updateToSchemaVersion38(currentVersion: number, db: Database) { - if (currentVersion >= 38) { - return; - } - - db.transaction(() => { - // TODO: Remove deprecated columns once sqlcipher is updated to support it - db.exec(` - DROP INDEX IF EXISTS messages_duplicate_check; - - ALTER TABLE messages - RENAME COLUMN sourceDevice TO deprecatedSourceDevice; - ALTER TABLE messages - ADD COLUMN sourceDevice INTEGER; - - UPDATE messages - SET - sourceDevice = CAST(deprecatedSourceDevice AS INTEGER), - deprecatedSourceDevice = NULL; - - ALTER TABLE unprocessed - RENAME COLUMN sourceDevice TO deprecatedSourceDevice; - ALTER TABLE unprocessed - ADD COLUMN sourceDevice INTEGER; - - UPDATE unprocessed - SET - sourceDevice = CAST(deprecatedSourceDevice AS INTEGER), - deprecatedSourceDevice = NULL; - `); - - db.pragma('user_version = 38'); - })(); - logger.info('updateToSchemaVersion38: success!'); -} - -function updateToSchemaVersion39(currentVersion: number, db: Database) { - if (currentVersion >= 39) { - return; - } - - db.transaction(() => { - db.exec('ALTER TABLE messages RENAME COLUMN unread TO readStatus;'); - - db.pragma('user_version = 39'); - })(); - logger.info('updateToSchemaVersion39: success!'); -} - -function updateToSchemaVersion40(currentVersion: number, db: Database) { - if (currentVersion >= 40) { - return; - } - - db.transaction(() => { - db.exec( - ` - CREATE TABLE groupCallRings( - ringId INTEGER PRIMARY KEY, - isActive INTEGER NOT NULL, - createdAt INTEGER NOT NULL - ); - ` - ); - - db.pragma('user_version = 40'); - })(); - logger.info('updateToSchemaVersion40: success!'); -} - -function updateToSchemaVersion41(currentVersion: number, db: Database) { - if (currentVersion >= 41) { - return; - } - - const getConversationUuid = db - .prepare( - ` - SELECT uuid - FROM - conversations - WHERE - id = $conversationId - ` - ) - .pluck(); - - const getConversationStats = db.prepare( - ` - SELECT uuid, e164, active_at - FROM - conversations - WHERE - id = $conversationId - ` - ); - - const compareConvoRecency = (a: string, b: string): number => { - const aStats = getConversationStats.get({ conversationId: a }); - const bStats = getConversationStats.get({ conversationId: b }); - - const isAComplete = Boolean(aStats?.uuid && aStats?.e164); - const isBComplete = Boolean(bStats?.uuid && bStats?.e164); - - if (!isAComplete && !isBComplete) { - return 0; - } - if (!isAComplete) { - return -1; - } - if (!isBComplete) { - return 1; - } - - return aStats.active_at - bStats.active_at; - }; - - const clearSessionsAndKeys = () => { - // ts/background.ts will ask user to relink so all that matters here is - // to maintain an invariant: - // - // After this migration all sessions and keys are prefixed by - // "uuid:". - db.exec( - ` - DELETE FROM senderKeys; - DELETE FROM sessions; - DELETE FROM signedPreKeys; - DELETE FROM preKeys; - ` - ); - - assertSync(removeById('items', 'identityKey', db)); - assertSync(removeById('items', 'registrationId', db)); - }; - - const moveIdentityKeyToMap = (ourUuid: string) => { - type IdentityKeyType = { - privKey: string; - publicKey: string; - }; - - const identityKey = assertSync( - getById('items', 'identityKey', db) - ); - - type RegistrationId = number; - - const registrationId = assertSync( - getById('items', 'registrationId', db) - ); - - if (identityKey) { - assertSync( - createOrUpdateSync( - 'items', - { - id: 'identityKeyMap', - value: { - [ourUuid]: identityKey.value, - }, - }, - db - ) - ); - } - - if (registrationId) { - assertSync( - createOrUpdateSync( - 'items', - { - id: 'registrationIdMap', - value: { - [ourUuid]: registrationId.value, - }, - }, - db - ) - ); - } - - assertSync(removeById('items', 'identityKey', db)); - assertSync(removeById('items', 'registrationId', db)); - }; - - const prefixKeys = (ourUuid: string) => { - for (const table of ['signedPreKeys', 'preKeys']) { - // Update id to include suffix, add `ourUuid` and `keyId` fields. - db.prepare( - ` - UPDATE ${table} - SET - id = $ourUuid || ':' || id, - json = json_set( - json, - '$.id', - $ourUuid || ':' || json_extract(json, '$.id'), - '$.keyId', - json_extract(json, '$.id'), - '$.ourUuid', - $ourUuid - ) - ` - ).run({ ourUuid }); - } - }; - - const updateSenderKeys = (ourUuid: string) => { - const senderKeys: ReadonlyArray<{ - id: string; - senderId: string; - lastUpdatedDate: number; - }> = db - .prepare( - 'SELECT id, senderId, lastUpdatedDate FROM senderKeys' - ) - .all(); - - logger.info(`Updating ${senderKeys.length} sender keys`); - - const updateSenderKey = db.prepare( - ` - UPDATE senderKeys - SET - id = $newId, - senderId = $newSenderId - WHERE - id = $id - ` - ); - - const deleteSenderKey = db.prepare( - 'DELETE FROM senderKeys WHERE id = $id' - ); - - const pastKeys = new Map< - string, - { - conversationId: string; - lastUpdatedDate: number; - } - >(); - - let updated = 0; - let deleted = 0; - let skipped = 0; - for (const { id, senderId, lastUpdatedDate } of senderKeys) { - const [conversationId] = Helpers.unencodeNumber(senderId); - const uuid = getConversationUuid.get({ conversationId }); - - if (!uuid) { - deleted += 1; - deleteSenderKey.run({ id }); - continue; - } - - const newId = `${ourUuid}:${id.replace(conversationId, uuid)}`; - - const existing = pastKeys.get(newId); - - // We are going to delete on of the keys anyway - if (existing) { - skipped += 1; - } else { - updated += 1; - } - - const isOlder = - existing && - (lastUpdatedDate < existing.lastUpdatedDate || - compareConvoRecency(conversationId, existing.conversationId) < 0); - if (isOlder) { - deleteSenderKey.run({ id }); - continue; - } else if (existing) { - deleteSenderKey.run({ id: newId }); - } - - pastKeys.set(newId, { conversationId, lastUpdatedDate }); - - updateSenderKey.run({ - id, - newId, - newSenderId: `${senderId.replace(conversationId, uuid)}`, - }); - } - - logger.info( - `Updated ${senderKeys.length} sender keys: ` + - `updated: ${updated}, deleted: ${deleted}, skipped: ${skipped}` - ); - }; - - const updateSessions = (ourUuid: string) => { - // Use uuid instead of conversation id in existing sesions and prefix id - // with ourUuid. - // - // Set ourUuid column and field in json - const allSessions = db - .prepare('SELECT id, conversationId FROM SESSIONS') - .all(); - - logger.info(`Updating ${allSessions.length} sessions`); - - const updateSession = db.prepare( - ` - UPDATE sessions - SET - id = $newId, - ourUuid = $ourUuid, - uuid = $uuid, - json = json_set( - sessions.json, - '$.id', - $newId, - '$.uuid', - $uuid, - '$.ourUuid', - $ourUuid - ) - WHERE - id = $id - ` - ); - - const deleteSession = db.prepare( - 'DELETE FROM sessions WHERE id = $id' - ); - - const pastSessions = new Map< - string, - { - conversationId: string; - } - >(); - - let updated = 0; - let deleted = 0; - let skipped = 0; - for (const { id, conversationId } of allSessions) { - const uuid = getConversationUuid.get({ conversationId }); - if (!uuid) { - deleted += 1; - deleteSession.run({ id }); - continue; - } - - const newId = `${ourUuid}:${id.replace(conversationId, uuid)}`; - - const existing = pastSessions.get(newId); - - // We are going to delete on of the keys anyway - if (existing) { - skipped += 1; - } else { - updated += 1; - } - - const isOlder = - existing && - compareConvoRecency(conversationId, existing.conversationId) < 0; - if (isOlder) { - deleteSession.run({ id }); - continue; - } else if (existing) { - deleteSession.run({ id: newId }); - } - - pastSessions.set(newId, { conversationId }); - - updateSession.run({ - id, - newId, - uuid, - ourUuid, - }); - } - - logger.info( - `Updated ${allSessions.length} sessions: ` + - `updated: ${updated}, deleted: ${deleted}, skipped: ${skipped}` - ); - }; - - const updateIdentityKeys = () => { - const identityKeys: ReadonlyArray<{ - id: string; - }> = db.prepare('SELECT id FROM identityKeys').all(); - - logger.info(`Updating ${identityKeys.length} identity keys`); - - const updateIdentityKey = db.prepare( - ` - UPDATE identityKeys - SET - id = $newId, - json = json_set( - identityKeys.json, - '$.id', - $newId - ) - WHERE - id = $id - ` - ); - - let migrated = 0; - for (const { id } of identityKeys) { - const uuid = getConversationUuid.get({ conversationId: id }); - - let newId: string; - if (uuid) { - migrated += 1; - newId = uuid; - } else { - newId = `conversation:${id}`; - } - - updateIdentityKey.run({ id, newId }); - } - - logger.info(`Migrated ${migrated} identity keys`); - }; - - db.transaction(() => { - db.exec( - ` - -- Change type of 'id' column from INTEGER to STRING - - ALTER TABLE preKeys - RENAME TO old_preKeys; - - ALTER TABLE signedPreKeys - RENAME TO old_signedPreKeys; - - CREATE TABLE preKeys( - id STRING PRIMARY KEY ASC, - json TEXT - ); - CREATE TABLE signedPreKeys( - id STRING PRIMARY KEY ASC, - json TEXT - ); - - -- sqlite handles the type conversion - INSERT INTO preKeys SELECT * FROM old_preKeys; - INSERT INTO signedPreKeys SELECT * FROM old_signedPreKeys; - - DROP TABLE old_preKeys; - DROP TABLE old_signedPreKeys; - - -- Alter sessions - - ALTER TABLE sessions - ADD COLUMN ourUuid STRING; - - ALTER TABLE sessions - ADD COLUMN uuid STRING; - ` - ); - - const ourUuid = getOurUuid(db); - - if (!isValidGuid(ourUuid)) { - logger.error( - 'updateToSchemaVersion41: no uuid is available clearing sessions' - ); - - clearSessionsAndKeys(); - - db.pragma('user_version = 41'); - return; - } - - prefixKeys(ourUuid); - - updateSenderKeys(ourUuid); - - updateSessions(ourUuid); - - moveIdentityKeyToMap(ourUuid); - - updateIdentityKeys(); - - db.pragma('user_version = 41'); - })(); - logger.info('updateToSchemaVersion41: success!'); -} - -function updateToSchemaVersion42(currentVersion: number, db: Database) { - if (currentVersion >= 42) { - return; - } - - db.transaction(() => { - // First, recreate messages table delete trigger with reaction support - - db.exec(` - DROP TRIGGER messages_on_delete; - - CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN - DELETE FROM messages_fts WHERE rowid = old.rowid; - DELETE FROM sendLogPayloads WHERE id IN ( - SELECT payloadId FROM sendLogMessageIds - WHERE messageId = old.id - ); - DELETE FROM reactions WHERE rowid IN ( - SELECT rowid FROM reactions - WHERE messageId = old.id - ); - END; - `); - - // Then, delete previously-orphaned reactions - - // Note: we use `pluck` here to fetch only the first column of - // returned row. - const messageIdList: Array = db - .prepare('SELECT id FROM messages ORDER BY id ASC;') - .pluck() - .all(); - const allReactions: Array<{ - rowid: number; - messageId: string; - }> = db.prepare('SELECT rowid, messageId FROM reactions;').all(); - - const messageIds = new Set(messageIdList); - const reactionsToDelete: Array = []; - - allReactions.forEach(reaction => { - if (!messageIds.has(reaction.messageId)) { - reactionsToDelete.push(reaction.rowid); - } - }); - - function deleteReactions(rowids: Array) { - db.prepare( - ` - DELETE FROM reactions - WHERE rowid IN ( ${rowids.map(() => '?').join(', ')} ); - ` - ).run(rowids); - } - - if (reactionsToDelete.length > 0) { - logger.info(`Deleting ${reactionsToDelete.length} orphaned reactions`); - batchMultiVarQuery(reactionsToDelete, deleteReactions, db); - } - - db.pragma('user_version = 42'); - })(); - logger.info('updateToSchemaVersion42: success!'); -} - -export const SCHEMA_VERSIONS = [ - updateToSchemaVersion1, - updateToSchemaVersion2, - updateToSchemaVersion3, - updateToSchemaVersion4, - (_v: number, _i: Database) => null, // version 5 was dropped - updateToSchemaVersion6, - updateToSchemaVersion7, - updateToSchemaVersion8, - updateToSchemaVersion9, - updateToSchemaVersion10, - updateToSchemaVersion11, - updateToSchemaVersion12, - updateToSchemaVersion13, - updateToSchemaVersion14, - updateToSchemaVersion15, - updateToSchemaVersion16, - updateToSchemaVersion17, - updateToSchemaVersion18, - updateToSchemaVersion19, - updateToSchemaVersion20, - updateToSchemaVersion21, - updateToSchemaVersion22, - updateToSchemaVersion23, - updateToSchemaVersion24, - updateToSchemaVersion25, - updateToSchemaVersion26, - updateToSchemaVersion27, - updateToSchemaVersion28, - updateToSchemaVersion29, - updateToSchemaVersion30, - updateToSchemaVersion31, - updateToSchemaVersion32, - updateToSchemaVersion33, - updateToSchemaVersion34, - updateToSchemaVersion35, - updateToSchemaVersion36, - updateToSchemaVersion37, - updateToSchemaVersion38, - updateToSchemaVersion39, - updateToSchemaVersion40, - updateToSchemaVersion41, - updateToSchemaVersion42, -]; - -export function updateSchema(db: Database) { - const sqliteVersion = getSQLiteVersion(db); - const sqlcipherVersion = getSQLCipherVersion(db); - const userVersion = getUserVersion(db); - const maxUserVersion = SCHEMA_VERSIONS.length; - const schemaVersion = getSchemaVersion(db); - - logger.info( - 'updateSchema:\n', - ` Current user_version: ${userVersion};\n`, - ` Most recent db schema: ${maxUserVersion};\n`, - ` SQLite version: ${sqliteVersion};\n`, - ` SQLCipher version: ${sqlcipherVersion};\n`, - ` (deprecated) schema_version: ${schemaVersion};\n` - ); - - if (userVersion > maxUserVersion) { - throw new Error( - `SQL: User version is ${userVersion} but the expected maximum version ` + - `is ${maxUserVersion}. Did you try to start an old version of Signal?` - ); - } - - for (let index = 0; index < maxUserVersion; index += 1) { - const runSchemaUpdate = SCHEMA_VERSIONS[index]; - - runSchemaUpdate(userVersion, db); - } -} - -function getOurUuid(db: Database): string | undefined { - const UUID_ID: ItemKeyType = 'uuid_id'; - - const row: { json: string } | undefined = db - .prepare('SELECT json FROM items WHERE id = $id;') - .get({ id: UUID_ID }); - - if (!row) { - return undefined; - } - - const { value } = JSON.parse(row.json); - - const [ourUuid] = Helpers.unencodeNumber(String(value).toLowerCase()); - return ourUuid; -} - let globalInstance: Database | undefined; let logger = consoleLogger; let globalInstanceRenderer: Database | undefined; @@ -2718,7 +455,7 @@ async function initialize({ configDir: string; key: string; logger: LoggerType; -}) { +}): Promise { if (globalInstance) { throw new Error('Cannot initialize more than once!'); } @@ -2747,13 +484,13 @@ async function initialize({ // For profiling use: // db.pragma('cipher_profile=\'sqlcipher.log\''); - updateSchema(db); + updateSchema(db, logger); // At this point we can allow general access to the database globalInstance = db; // test database - await getMessageCount(); + getMessageCountSync(); } catch (error) { logger.error('Database startup error:', error.stack); if (db) { @@ -2769,7 +506,7 @@ async function initializeRenderer({ }: { configDir: string; key: string; -}) { +}): Promise { if (!isRenderer()) { throw new Error('Cannot call from main process.'); } @@ -2802,7 +539,7 @@ async function initializeRenderer({ globalInstanceRenderer = promisified; // test database - await getMessageCount(); + getMessageCountSync(); } catch (error) { log.error('Database startup error:', error.stack); throw error; @@ -2869,103 +606,73 @@ function getInstance(): Database { return globalInstance; } -function batchMultiVarQuery( - values: Array, - query: (batch: Array) => void, - providedDatabase?: Database -): []; -function batchMultiVarQuery( - values: Array, - query: (batch: Array) => Array, - providedDatabase?: Database -): Array; -function batchMultiVarQuery( - values: Array, - query: - | ((batch: Array) => void) - | ((batch: Array) => Array), - providedDatabase?: Database -): Array { - const db = providedDatabase || getInstance(); - if (values.length > MAX_VARIABLE_COUNT) { - const result: Array = []; - db.transaction(() => { - for (let i = 0; i < values.length; i += MAX_VARIABLE_COUNT) { - const batch = values.slice(i, i + MAX_VARIABLE_COUNT); - const batchResult = query(batch); - if (Array.isArray(batchResult)) { - result.push(...batchResult); - } - } - })(); - return result; - } - - const result = query(values); - return Array.isArray(result) ? result : []; -} - const IDENTITY_KEYS_TABLE = 'identityKeys'; -function createOrUpdateIdentityKey(data: IdentityKeyType): Promise { - return createOrUpdate(IDENTITY_KEYS_TABLE, data); +async function createOrUpdateIdentityKey(data: IdentityKeyType): Promise { + return createOrUpdate(getInstance(), IDENTITY_KEYS_TABLE, data); } async function getIdentityKeyById( id: IdentityKeyIdType ): Promise { - return getById(IDENTITY_KEYS_TABLE, id); + return getById(getInstance(), IDENTITY_KEYS_TABLE, id); } -function bulkAddIdentityKeys(array: Array): Promise { - return bulkAdd(IDENTITY_KEYS_TABLE, array); +async function bulkAddIdentityKeys( + array: Array +): Promise { + return bulkAdd(getInstance(), IDENTITY_KEYS_TABLE, array); } async function removeIdentityKeyById(id: IdentityKeyIdType): Promise { - return removeById(IDENTITY_KEYS_TABLE, id); + return removeById(getInstance(), IDENTITY_KEYS_TABLE, id); } -function removeAllIdentityKeys(): Promise { - return removeAllFromTable(IDENTITY_KEYS_TABLE); +async function removeAllIdentityKeys(): Promise { + return removeAllFromTable(getInstance(), IDENTITY_KEYS_TABLE); } -function getAllIdentityKeys(): Promise> { - return getAllFromTable(IDENTITY_KEYS_TABLE); +async function getAllIdentityKeys(): Promise> { + return getAllFromTable(getInstance(), IDENTITY_KEYS_TABLE); } const PRE_KEYS_TABLE = 'preKeys'; -function createOrUpdatePreKey(data: PreKeyType): Promise { - return createOrUpdate(PRE_KEYS_TABLE, data); +async function createOrUpdatePreKey(data: PreKeyType): Promise { + return createOrUpdate(getInstance(), PRE_KEYS_TABLE, data); } async function getPreKeyById( id: PreKeyIdType ): Promise { - return getById(PRE_KEYS_TABLE, id); + return getById(getInstance(), PRE_KEYS_TABLE, id); } -function bulkAddPreKeys(array: Array): Promise { - return bulkAdd(PRE_KEYS_TABLE, array); +async function bulkAddPreKeys(array: Array): Promise { + return bulkAdd(getInstance(), PRE_KEYS_TABLE, array); } async function removePreKeyById(id: PreKeyIdType): Promise { - return removeById(PRE_KEYS_TABLE, id); + return removeById(getInstance(), PRE_KEYS_TABLE, id); } -function removeAllPreKeys(): Promise { - return removeAllFromTable(PRE_KEYS_TABLE); +async function removeAllPreKeys(): Promise { + return removeAllFromTable(getInstance(), PRE_KEYS_TABLE); } -function getAllPreKeys(): Promise> { - return getAllFromTable(PRE_KEYS_TABLE); +async function getAllPreKeys(): Promise> { + return getAllFromTable(getInstance(), PRE_KEYS_TABLE); } const SIGNED_PRE_KEYS_TABLE = 'signedPreKeys'; -function createOrUpdateSignedPreKey(data: SignedPreKeyType): Promise { - return createOrUpdate(SIGNED_PRE_KEYS_TABLE, data); +async function createOrUpdateSignedPreKey( + data: SignedPreKeyType +): Promise { + return createOrUpdate(getInstance(), SIGNED_PRE_KEYS_TABLE, data); } async function getSignedPreKeyById( id: SignedPreKeyIdType ): Promise { - return getById(SIGNED_PRE_KEYS_TABLE, id); + return getById(getInstance(), SIGNED_PRE_KEYS_TABLE, id); } -function bulkAddSignedPreKeys(array: Array): Promise { - return bulkAdd(SIGNED_PRE_KEYS_TABLE, array); +async function bulkAddSignedPreKeys( + array: Array +): Promise { + return bulkAdd(getInstance(), SIGNED_PRE_KEYS_TABLE, array); } async function removeSignedPreKeyById(id: SignedPreKeyIdType): Promise { - return removeById(SIGNED_PRE_KEYS_TABLE, id); + return removeById(getInstance(), SIGNED_PRE_KEYS_TABLE, id); } -function removeAllSignedPreKeys(): Promise { - return removeAllFromTable(SIGNED_PRE_KEYS_TABLE); +async function removeAllSignedPreKeys(): Promise { + return removeAllFromTable(getInstance(), SIGNED_PRE_KEYS_TABLE); } async function getAllSignedPreKeys(): Promise> { const db = getInstance(); @@ -2983,15 +690,15 @@ async function getAllSignedPreKeys(): Promise> { } const ITEMS_TABLE = 'items'; -function createOrUpdateItem( +async function createOrUpdateItem( data: ItemType ): Promise { - return createOrUpdate(ITEMS_TABLE, data); + return createOrUpdate(getInstance(), ITEMS_TABLE, data); } async function getItemById( id: K ): Promise | undefined> { - return getById(ITEMS_TABLE, id); + return getById(getInstance(), ITEMS_TABLE, id); } async function getAllItems(): Promise { const db = getInstance(); @@ -2999,22 +706,23 @@ async function getAllItems(): Promise { .prepare('SELECT json FROM items ORDER BY id ASC;') .all(); - const items = rows.map(row => jsonToObject(row.json)); + type RawItemType = { id: ItemKeyType; value: unknown }; - const result: AllItemsType = Object.create(null); + const items = rows.map(row => jsonToObject(row.json)); + + const result: Record = Object.create(null); for (const { id, value } of items) { - const key = id as ItemKeyType; - result[key] = value; + result[id] = value; } - return result; + return (result as unknown) as AllItemsType; } async function removeItemById(id: ItemKeyType): Promise { - return removeById(ITEMS_TABLE, id); + return removeById(getInstance(), ITEMS_TABLE, id); } -function removeAllItems(): Promise { - return removeAllFromTable(ITEMS_TABLE); +async function removeAllItems(): Promise { + return removeAllFromTable(getInstance(), ITEMS_TABLE); } async function createOrUpdateSenderKey(key: SenderKeyType): Promise { @@ -3456,11 +1164,11 @@ async function commitSessionsAndUnprocessed({ })(); } -function bulkAddSessions(array: Array): Promise { - return bulkAdd(SESSIONS_TABLE, array); +async function bulkAddSessions(array: Array): Promise { + return bulkAdd(getInstance(), SESSIONS_TABLE, array); } async function removeSessionById(id: SessionIdType): Promise { - return removeById(SESSIONS_TABLE, id); + return removeById(getInstance(), SESSIONS_TABLE, id); } async function removeSessionsByConversation( conversationId: string @@ -3475,144 +1183,26 @@ async function removeSessionsByConversation( conversationId, }); } -function removeAllSessions(): Promise { - return removeAllFromTable(SESSIONS_TABLE); +async function removeAllSessions(): Promise { + return removeAllFromTable(getInstance(), SESSIONS_TABLE); } -function getAllSessions(): Promise> { - return getAllFromTable(SESSIONS_TABLE); +async function getAllSessions(): Promise> { + return getAllFromTable(getInstance(), SESSIONS_TABLE); } - -function createOrUpdateSync( - table: string, - data: Record & { id: Key }, - db = getInstance() -): void { - const { id } = data; - if (!id) { - throw new Error('createOrUpdate: Provided data did not have a truthy id'); - } - - db.prepare( - ` - INSERT OR REPLACE INTO ${table} ( - id, - json - ) values ( - $id, - $json - ) - ` - ).run({ - id, - json: objectToJSON(data), - }); -} - -async function createOrUpdate( - table: string, - data: Record & { id: string | number } -): Promise { - return createOrUpdateSync(table, data); -} - -async function bulkAdd( - table: string, - array: Array & { id: string | number }> -): Promise { - const db = getInstance(); - - db.transaction(() => { - for (const data of array) { - assertSync(createOrUpdateSync(table, data)); - } - })(); -} - -function getById( - table: string, - id: Key, - db = getInstance() -): Result | undefined { - const row = db - .prepare( - ` - SELECT * - FROM ${table} - WHERE id = $id; - ` - ) - .get({ - id, - }); - - if (!row) { - return undefined; - } - - return jsonToObject(row.json); -} - -function removeById( - table: string, - id: Key | Array, - db = getInstance() -): void { - if (!Array.isArray(id)) { - db.prepare( - ` - DELETE FROM ${table} - WHERE id = $id; - ` - ).run({ id }); - return; - } - - if (!id.length) { - throw new Error('removeById: No ids to delete!'); - } - - const removeByIdsSync = (ids: Array): void => { - db.prepare( - ` - DELETE FROM ${table} - WHERE id IN ( ${id.map(() => '?').join(', ')} ); - ` - ).run(ids); - }; - - batchMultiVarQuery(id, removeByIdsSync); -} - -async function removeAllFromTable(table: string): Promise { - const db = getInstance(); - db.prepare(`DELETE FROM ${table};`).run(); -} - -async function getAllFromTable(table: string): Promise> { - const db = getInstance(); - const rows: JSONRows = db - .prepare(`SELECT json FROM ${table};`) - .all(); - - return rows.map(row => jsonToObject(row.json)); -} - -function getCountFromTable(table: string): number { - const db = getInstance(); - const result: null | number = db - .prepare(`SELECT count(*) from ${table};`) - .pluck(true) - .get(); - if (isNumber(result)) { - return result; - } - throw new Error(`getCountFromTable: Unable to get count from table ${table}`); -} - // Conversations async function getConversationCount(): Promise { - return getCountFromTable('conversations'); + return getCountFromTable(getInstance(), 'conversations'); +} + +function getConversationMembersList({ members, membersV2 }: ConversationType) { + if (membersV2) { + return membersV2.map((item: GroupV2MemberType) => item.uuid).join(' '); + } + if (members) { + return members.join(' '); + } + return null; } function saveConversationSync( @@ -3624,8 +1214,6 @@ function saveConversationSync( e164, groupId, id, - members, - membersV2, name, profileFamilyName, profileName, @@ -3634,12 +1222,7 @@ function saveConversationSync( uuid, } = data; - // prettier-ignore - const membersList = membersV2 - ? membersV2.map((item: GroupV2MemberType) => item.conversationId).join(' ') - : members - ? members.join(' ') - : null; + const membersList = getConversationMembersList(data); db.prepare( ` @@ -3717,14 +1300,14 @@ async function saveConversations( })(); } -function updateConversationSync(data: ConversationType): void { - const db = getInstance(); +function updateConversationSync( + data: ConversationType, + db = getInstance() +): void { const { id, active_at, type, - members, - membersV2, name, profileName, profileFamilyName, @@ -3733,12 +1316,7 @@ function updateConversationSync(data: ConversationType): void { uuid, } = data; - // prettier-ignore - const membersList = membersV2 - ? membersV2.map((item: GroupV2MemberType) => item.conversationId).join(' ') - : members - ? members.join(' ') - : null; + const membersList = getConversationMembersList(data); db.prepare( ` @@ -3807,8 +1385,9 @@ function removeConversationsSync(ids: Array): void { } async function removeConversation(id: Array | string): Promise { + const db = getInstance(); + if (!Array.isArray(id)) { - const db = getInstance(); db.prepare('DELETE FROM conversations WHERE id = $id;').run({ id, }); @@ -3820,7 +1399,7 @@ async function removeConversation(id: Array | string): Promise { throw new Error('removeConversation: No ids to delete!'); } - batchMultiVarQuery(id, removeConversationsSync); + batchMultiVarQuery(db, id, removeConversationsSync); } async function getConversationById( @@ -3850,8 +1429,7 @@ async function eraseStorageServiceStateFromConversations(): Promise { ).run(); } -async function getAllConversations(): Promise> { - const db = getInstance(); +function getAllConversationsSync(db = getInstance()): Array { const rows: ConversationRows = db .prepare( ` @@ -3865,6 +1443,10 @@ async function getAllConversations(): Promise> { return rows.map(row => rowToConversation(row)); } +async function getAllConversations(): Promise> { + return getAllConversationsSync(); +} + async function getAllConversationIds(): Promise> { const db = getInstance(); const rows: Array<{ id: string }> = db @@ -3894,8 +1476,8 @@ async function getAllPrivateConversations(): Promise> { return rows.map(row => rowToConversation(row)); } -async function getAllGroupsInvolvingId( - id: string +async function getAllGroupsInvolvingUuid( + uuid: UUIDStringType ): Promise> { const db = getInstance(); const rows: ConversationRows = db @@ -3904,12 +1486,12 @@ async function getAllGroupsInvolvingId( SELECT json, profileLastFetchedAt FROM conversations WHERE type = 'group' AND - members LIKE $id + members LIKE $uuid ORDER BY id ASC; ` ) .all({ - id: `%${id}%`, + uuid: `%${uuid}%`, }); return rows.map(row => rowToConversation(row)); @@ -4055,13 +1637,15 @@ async function searchMessagesInConversation( return searchMessages(query, { conversationId, limit }); } -async function getMessageCount(conversationId?: string): Promise { +function getMessageCountSync( + conversationId?: string, + db = getInstance() +): number { if (conversationId === undefined) { - return getCountFromTable('messages'); + return getCountFromTable(db, 'messages'); } - const db = getInstance(); - const row: { 'count(*)': number } | undefined = db + const count = db .prepare( ` SELECT count(*) @@ -4069,13 +1653,14 @@ async function getMessageCount(conversationId?: string): Promise { WHERE conversationId = $conversationId; ` ) + .pluck() .get({ conversationId }); - if (!row) { - throw new Error('getMessageCount: Unable to get count of messages'); - } + return count; +} - return row['count(*)']; +async function getMessageCount(conversationId?: string): Promise { + return getMessageCountSync(conversationId); } function hasUserInitiatedMessages(conversationId: string): boolean { @@ -4115,15 +1700,19 @@ function hasUserInitiatedMessages(conversationId: string): boolean { function saveMessageSync( data: MessageType, - options?: { + options: { jobToInsert?: StoredJob; forceSave?: boolean; alreadyInTransaction?: boolean; - } + db?: Database; + } = {} ): string { - const db = getInstance(); - - const { jobToInsert, forceSave, alreadyInTransaction } = options || {}; + const { + jobToInsert, + forceSave, + alreadyInTransaction, + db = getInstance(), + } = options; if (!alreadyInTransaction) { return db.transaction(() => { @@ -4221,7 +1810,7 @@ function saveMessageSync( const toCreate = { ...data, - id: id || generateUUID(), + id: id || UUID.generate().toString(), }; prepare( @@ -4331,7 +1920,7 @@ function removeMessagesSync(ids: Array): void { } async function removeMessages(ids: Array): Promise { - batchMultiVarQuery(ids, removeMessagesSync); + batchMultiVarQuery(getInstance(), ids, removeMessagesSync); } async function getMessageById(id: string): Promise { @@ -4355,6 +1944,7 @@ async function getMessagesById( const db = getInstance(); return batchMultiVarQuery( + db, messageIds, (batch: Array): Array => { const query = db.prepare( @@ -4503,7 +2093,7 @@ async function getUnreadByConversationAndMarkRead( }); return rows.map(row => { - const json = jsonToObject(row.json); + const json = jsonToObject(row.json); return { readStatus: ReadStatus.Read, ...pick(json, [ @@ -4822,10 +2412,10 @@ function getNewestMessageForConversation( function getLastConversationActivity({ conversationId, - ourConversationId, + ourUuid, }: { conversationId: string; - ourConversationId: string; + ourUuid: UUIDStringType; }): MessageType | undefined { const db = getInstance(); const row = prepare( @@ -4853,17 +2443,17 @@ function getLastConversationActivity({ ) AND NOT ( type = 'group-v2-change' AND - json_extract(json, '$.groupV2Change.from') != $ourConversationId AND + json_extract(json, '$.groupV2Change.from') != $ourUuid AND json_extract(json, '$.groupV2Change.details.length') = 1 AND json_extract(json, '$.groupV2Change.details[0].type') = 'member-remove' AND - json_extract(json, '$.groupV2Change.details[0].conversationId') != $ourConversationId + json_extract(json, '$.groupV2Change.details[0].uuid') != $ourUuid ) ORDER BY received_at DESC, sent_at DESC LIMIT 1; ` ).get({ conversationId, - ourConversationId, + ourUuid, }); if (!row) { @@ -4874,10 +2464,10 @@ function getLastConversationActivity({ } function getLastConversationPreview({ conversationId, - ourConversationId, + ourUuid, }: { conversationId: string; - ourConversationId: string; + ourUuid: UUIDStringType; }): MessageType | undefined { const db = getInstance(); const row = prepare( @@ -4904,17 +2494,17 @@ function getLastConversationPreview({ ) AND NOT ( type = 'group-v2-change' AND - json_extract(json, '$.groupV2Change.from') != $ourConversationId AND + json_extract(json, '$.groupV2Change.from') != $ourUuid AND json_extract(json, '$.groupV2Change.details.length') = 1 AND json_extract(json, '$.groupV2Change.details[0].type') = 'member-remove' AND - json_extract(json, '$.groupV2Change.details[0].conversationId') != $ourConversationId + json_extract(json, '$.groupV2Change.details[0].uuid') != $ourUuid ) ORDER BY received_at DESC, sent_at DESC LIMIT 1; ` ).get({ conversationId, - ourConversationId, + ourUuid, now: Date.now(), }); @@ -4927,10 +2517,10 @@ function getLastConversationPreview({ async function getLastConversationMessages({ conversationId, - ourConversationId, + ourUuid, }: { conversationId: string; - ourConversationId: string; + ourUuid: UUIDStringType; }): Promise { const db = getInstance(); @@ -4938,11 +2528,11 @@ async function getLastConversationMessages({ return { activity: getLastConversationActivity({ conversationId, - ourConversationId, + ourUuid, }), preview: getLastConversationPreview({ conversationId, - ourConversationId, + ourUuid, }), hasUserInitiatedMessages: hasUserInitiatedMessages(conversationId), }; @@ -5164,7 +2754,7 @@ async function getNextTapToViewMessageTimestampToAgeOut(): Promise< return undefined; } - const data = jsonToObject(row.json); + const data = jsonToObject(row.json); const result = data.received_at_ms || data.received_at; return isNormalNumber(result) ? result : undefined; } @@ -5334,7 +2924,7 @@ async function getUnprocessedById( } async function getUnprocessedCount(): Promise { - return getCountFromTable('unprocessed'); + return getCountFromTable(getInstance(), 'unprocessed'); } async function getAllUnprocessed(): Promise> { @@ -5364,9 +2954,9 @@ function removeUnprocessedsSync(ids: Array): void { } function removeUnprocessedSync(id: string | Array): void { - if (!Array.isArray(id)) { - const db = getInstance(); + const db = getInstance(); + if (!Array.isArray(id)) { prepare(db, 'DELETE FROM unprocessed WHERE id = $id;').run({ id }); return; @@ -5376,7 +2966,7 @@ function removeUnprocessedSync(id: string | Array): void { throw new Error('removeUnprocessedSync: No ids to delete!'); } - assertSync(batchMultiVarQuery(id, removeUnprocessedsSync)); + assertSync(batchMultiVarQuery(db, id, removeUnprocessedsSync)); } async function removeUnprocessed(id: string | Array): Promise { @@ -5475,10 +3065,10 @@ async function resetAttachmentDownloadPending(): Promise { ).run(); } async function removeAttachmentDownloadJob(id: string): Promise { - return removeById(ATTACHMENT_DOWNLOADS_TABLE, id); + return removeById(getInstance(), ATTACHMENT_DOWNLOADS_TABLE, id); } -function removeAllAttachmentDownloadJobs(): Promise { - return removeAllFromTable(ATTACHMENT_DOWNLOADS_TABLE); +async function removeAllAttachmentDownloadJobs(): Promise { + return removeAllFromTable(getInstance(), ATTACHMENT_DOWNLOADS_TABLE); } // Stickers @@ -5874,7 +3464,7 @@ async function deleteStickerPack(packId: string): Promise> { } async function getStickerCount(): Promise { - return getCountFromTable('stickers'); + return getCountFromTable(getInstance(), 'stickers'); } async function getAllStickerPacks(): Promise> { const db = getInstance(); @@ -6040,7 +3630,7 @@ async function removeAllConfiguration( const allowedSet = new Set(STORAGE_UI_KEYS); for (const id of itemIds) { if (!allowedSet.has(id)) { - removeById('items', id); + removeById(db, 'items', id); } } } else { @@ -6254,55 +3844,26 @@ async function removeKnownAttachments( ); const chunkSize = 500; - const total = await getMessageCount(); + const total = getMessageCountSync(); logger.info( `removeKnownAttachments: About to iterate through ${total} messages` ); let count = 0; - let complete = false; - let id: string | number = ''; - const fetchMessages = db.prepare( - ` - SELECT json FROM messages - WHERE id > $id - ORDER BY id ASC - LIMIT $chunkSize; - ` - ); - - while (!complete) { - const rows: JSONRows = fetchMessages.all({ - id, - chunkSize, + for (const message of new TableIterator(db, 'messages')) { + const externalFiles = getExternalFilesForMessage(message); + forEach(externalFiles, file => { + delete lookup[file]; }); - - const messages: Array = rows.map(row => - jsonToObject(row.json) - ); - messages.forEach(message => { - const externalFiles = getExternalFilesForMessage(message); - forEach(externalFiles, file => { - delete lookup[file]; - }); - }); - - const lastMessage: MessageType | undefined = last(messages); - if (lastMessage) { - ({ id } = lastMessage); - } - complete = messages.length < chunkSize; - count += messages.length; + count += 1; } logger.info(`removeKnownAttachments: Done processing ${count} messages`); - complete = false; + let complete = false; count = 0; - // Though conversations.id is a string, this ensures that, when coerced, this - // value is still a string but it's smaller than every other string. - id = 0; + let id = ''; const conversationTotal = await getConversationCount(); logger.info( @@ -6610,11 +4171,12 @@ async function getMaxMessageCounter(): Promise { } async function getStatisticsForLogging(): Promise> { + const db = getInstance(); const counts = await pProps({ messageCount: getMessageCount(), conversationCount: getConversationCount(), - sessionCount: getCountFromTable('sessions'), - senderKeyCount: getCountFromTable('senderKeys'), + sessionCount: getCountFromTable(db, 'sessions'), + senderKeyCount: getCountFromTable(db, 'senderKeys'), }); return mapValues(counts, formatCountForLogging); } diff --git a/ts/sql/migrations/41-uuid-keys.ts b/ts/sql/migrations/41-uuid-keys.ts new file mode 100644 index 000000000000..6a9e16bf68b6 --- /dev/null +++ b/ts/sql/migrations/41-uuid-keys.ts @@ -0,0 +1,448 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from 'better-sqlite3'; + +import type { LoggerType } from '../../types/Logging'; +import { isValidUuid } from '../../types/UUID'; +import { assertSync } from '../../util/assert'; +import Helpers from '../../textsecure/Helpers'; +import { createOrUpdate, getById, removeById } from '../util'; +import type { EmptyQuery, Query } from '../util'; +import type { ItemKeyType } from '../Interface'; + +function getOurUuid(db: Database): string | undefined { + const UUID_ID: ItemKeyType = 'uuid_id'; + + const row: { json: string } | undefined = db + .prepare('SELECT json FROM items WHERE id = $id;') + .get({ id: UUID_ID }); + + if (!row) { + return undefined; + } + + const { value } = JSON.parse(row.json); + + const [ourUuid] = Helpers.unencodeNumber(String(value).toLowerCase()); + return ourUuid; +} + +export default function updateToSchemaVersion41( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 41) { + return; + } + + const getConversationUuid = db + .prepare( + ` + SELECT uuid + FROM + conversations + WHERE + id = $conversationId + ` + ) + .pluck(); + + const getConversationStats = db.prepare( + ` + SELECT uuid, e164, active_at + FROM + conversations + WHERE + id = $conversationId + ` + ); + + const compareConvoRecency = (a: string, b: string): number => { + const aStats = getConversationStats.get({ conversationId: a }); + const bStats = getConversationStats.get({ conversationId: b }); + + const isAComplete = Boolean(aStats?.uuid && aStats?.e164); + const isBComplete = Boolean(bStats?.uuid && bStats?.e164); + + if (!isAComplete && !isBComplete) { + return 0; + } + if (!isAComplete) { + return -1; + } + if (!isBComplete) { + return 1; + } + + return aStats.active_at - bStats.active_at; + }; + + const clearSessionsAndKeys = () => { + // ts/background.ts will ask user to relink so all that matters here is + // to maintain an invariant: + // + // After this migration all sessions and keys are prefixed by + // "uuid:". + db.exec( + ` + DELETE FROM senderKeys; + DELETE FROM sessions; + DELETE FROM signedPreKeys; + DELETE FROM preKeys; + ` + ); + + assertSync(removeById(db, 'items', 'identityKey')); + assertSync(removeById(db, 'items', 'registrationId')); + }; + + const moveIdentityKeyToMap = (ourUuid: string) => { + type IdentityKeyType = { + privKey: string; + publicKey: string; + }; + + const identityKey = assertSync( + getById(db, 'items', 'identityKey') + ); + + type RegistrationId = number; + + const registrationId = assertSync( + getById(db, 'items', 'registrationId') + ); + + if (identityKey) { + assertSync( + createOrUpdate(db, 'items', { + id: 'identityKeyMap', + value: { + [ourUuid]: identityKey.value, + }, + }) + ); + } + + if (registrationId) { + assertSync( + createOrUpdate(db, 'items', { + id: 'registrationIdMap', + value: { + [ourUuid]: registrationId.value, + }, + }) + ); + } + + db.exec( + ` + DELETE FROM items WHERE id = "identityKey" OR id = "registrationId"; + ` + ); + }; + + const prefixKeys = (ourUuid: string) => { + for (const table of ['signedPreKeys', 'preKeys']) { + // Update id to include suffix, add `ourUuid` and `keyId` fields. + db.prepare( + ` + UPDATE ${table} + SET + id = $ourUuid || ':' || id, + json = json_set( + json, + '$.id', + $ourUuid || ':' || json_extract(json, '$.id'), + '$.keyId', + json_extract(json, '$.id'), + '$.ourUuid', + $ourUuid + ) + ` + ).run({ ourUuid }); + } + }; + + const updateSenderKeys = (ourUuid: string) => { + const senderKeys: ReadonlyArray<{ + id: string; + senderId: string; + lastUpdatedDate: number; + }> = db + .prepare( + 'SELECT id, senderId, lastUpdatedDate FROM senderKeys' + ) + .all(); + + logger.info(`Updating ${senderKeys.length} sender keys`); + + const updateSenderKey = db.prepare( + ` + UPDATE senderKeys + SET + id = $newId, + senderId = $newSenderId + WHERE + id = $id + ` + ); + + const deleteSenderKey = db.prepare( + 'DELETE FROM senderKeys WHERE id = $id' + ); + + const pastKeys = new Map< + string, + { + conversationId: string; + lastUpdatedDate: number; + } + >(); + + let updated = 0; + let deleted = 0; + let skipped = 0; + for (const { id, senderId, lastUpdatedDate } of senderKeys) { + const [conversationId] = Helpers.unencodeNumber(senderId); + const uuid = getConversationUuid.get({ conversationId }); + + if (!uuid) { + deleted += 1; + deleteSenderKey.run({ id }); + continue; + } + + const newId = `${ourUuid}:${id.replace(conversationId, uuid)}`; + + const existing = pastKeys.get(newId); + + // We are going to delete on of the keys anyway + if (existing) { + skipped += 1; + } else { + updated += 1; + } + + const isOlder = + existing && + (lastUpdatedDate < existing.lastUpdatedDate || + compareConvoRecency(conversationId, existing.conversationId) < 0); + if (isOlder) { + deleteSenderKey.run({ id }); + continue; + } else if (existing) { + deleteSenderKey.run({ id: newId }); + } + + pastKeys.set(newId, { conversationId, lastUpdatedDate }); + + updateSenderKey.run({ + id, + newId, + newSenderId: `${senderId.replace(conversationId, uuid)}`, + }); + } + + logger.info( + `Updated ${senderKeys.length} sender keys: ` + + `updated: ${updated}, deleted: ${deleted}, skipped: ${skipped}` + ); + }; + + const updateSessions = (ourUuid: string) => { + // Use uuid instead of conversation id in existing sesions and prefix id + // with ourUuid. + // + // Set ourUuid column and field in json + const allSessions = db + .prepare('SELECT id, conversationId FROM SESSIONS') + .all(); + + logger.info(`Updating ${allSessions.length} sessions`); + + const updateSession = db.prepare( + ` + UPDATE sessions + SET + id = $newId, + ourUuid = $ourUuid, + uuid = $uuid, + json = json_set( + sessions.json, + '$.id', + $newId, + '$.uuid', + $uuid, + '$.ourUuid', + $ourUuid + ) + WHERE + id = $id + ` + ); + + const deleteSession = db.prepare( + 'DELETE FROM sessions WHERE id = $id' + ); + + const pastSessions = new Map< + string, + { + conversationId: string; + } + >(); + + let updated = 0; + let deleted = 0; + let skipped = 0; + for (const { id, conversationId } of allSessions) { + const uuid = getConversationUuid.get({ conversationId }); + if (!uuid) { + deleted += 1; + deleteSession.run({ id }); + continue; + } + + const newId = `${ourUuid}:${id.replace(conversationId, uuid)}`; + + const existing = pastSessions.get(newId); + + // We are going to delete on of the keys anyway + if (existing) { + skipped += 1; + } else { + updated += 1; + } + + const isOlder = + existing && + compareConvoRecency(conversationId, existing.conversationId) < 0; + if (isOlder) { + deleteSession.run({ id }); + continue; + } else if (existing) { + deleteSession.run({ id: newId }); + } + + pastSessions.set(newId, { conversationId }); + + updateSession.run({ + id, + newId, + uuid, + ourUuid, + }); + } + + logger.info( + `Updated ${allSessions.length} sessions: ` + + `updated: ${updated}, deleted: ${deleted}, skipped: ${skipped}` + ); + }; + + const updateIdentityKeys = () => { + const identityKeys: ReadonlyArray<{ + id: string; + }> = db.prepare('SELECT id FROM identityKeys').all(); + + logger.info(`Updating ${identityKeys.length} identity keys`); + + const updateIdentityKey = db.prepare( + ` + UPDATE identityKeys + SET + id = $newId, + json = json_set( + identityKeys.json, + '$.id', + $newId + ) + WHERE + id = $id + ` + ); + + let migrated = 0; + for (const { id } of identityKeys) { + const uuid = getConversationUuid.get({ conversationId: id }); + + let newId: string; + if (uuid) { + migrated += 1; + newId = uuid; + } else { + newId = `conversation:${id}`; + } + + updateIdentityKey.run({ id, newId }); + } + + logger.info(`Migrated ${migrated} identity keys`); + }; + + db.transaction(() => { + db.exec( + ` + -- Change type of 'id' column from INTEGER to STRING + + ALTER TABLE preKeys + RENAME TO old_preKeys; + + ALTER TABLE signedPreKeys + RENAME TO old_signedPreKeys; + + CREATE TABLE preKeys( + id STRING PRIMARY KEY ASC, + json TEXT + ); + CREATE TABLE signedPreKeys( + id STRING PRIMARY KEY ASC, + json TEXT + ); + + -- sqlite handles the type conversion + INSERT INTO preKeys SELECT * FROM old_preKeys; + INSERT INTO signedPreKeys SELECT * FROM old_signedPreKeys; + + DROP TABLE old_preKeys; + DROP TABLE old_signedPreKeys; + + -- Alter sessions + + ALTER TABLE sessions + ADD COLUMN ourUuid STRING; + + ALTER TABLE sessions + ADD COLUMN uuid STRING; + ` + ); + + const ourUuid = getOurUuid(db); + + if (!isValidUuid(ourUuid)) { + logger.error( + 'updateToSchemaVersion41: no uuid is available clearing sessions' + ); + + clearSessionsAndKeys(); + + db.pragma('user_version = 41'); + return; + } + + prefixKeys(ourUuid); + + updateSenderKeys(ourUuid); + + updateSessions(ourUuid); + + moveIdentityKeyToMap(ourUuid); + + updateIdentityKeys(); + + db.pragma('user_version = 41'); + })(); + logger.info('updateToSchemaVersion41: success!'); +} diff --git a/ts/sql/migrations/42-stale-reactions.ts b/ts/sql/migrations/42-stale-reactions.ts new file mode 100644 index 000000000000..8e8343e23644 --- /dev/null +++ b/ts/sql/migrations/42-stale-reactions.ts @@ -0,0 +1,77 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from 'better-sqlite3'; + +import { batchMultiVarQuery } from '../util'; +import type { ArrayQuery } from '../util'; +import type { LoggerType } from '../../types/Logging'; + +export default function updateToSchemaVersion42( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 42) { + return; + } + + db.transaction(() => { + // First, recreate messages table delete trigger with reaction support + + db.exec(` + DROP TRIGGER messages_on_delete; + + CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN + DELETE FROM messages_fts WHERE rowid = old.rowid; + DELETE FROM sendLogPayloads WHERE id IN ( + SELECT payloadId FROM sendLogMessageIds + WHERE messageId = old.id + ); + DELETE FROM reactions WHERE rowid IN ( + SELECT rowid FROM reactions + WHERE messageId = old.id + ); + END; + `); + + // Then, delete previously-orphaned reactions + + // Note: we use `pluck` here to fetch only the first column of + // returned row. + const messageIdList: Array = db + .prepare('SELECT id FROM messages ORDER BY id ASC;') + .pluck() + .all(); + const allReactions: Array<{ + rowid: number; + messageId: string; + }> = db.prepare('SELECT rowid, messageId FROM reactions;').all(); + + const messageIds = new Set(messageIdList); + const reactionsToDelete: Array = []; + + allReactions.forEach(reaction => { + if (!messageIds.has(reaction.messageId)) { + reactionsToDelete.push(reaction.rowid); + } + }); + + function deleteReactions(rowids: Array) { + db.prepare( + ` + DELETE FROM reactions + WHERE rowid IN ( ${rowids.map(() => '?').join(', ')} ); + ` + ).run(rowids); + } + + if (reactionsToDelete.length > 0) { + logger.info(`Deleting ${reactionsToDelete.length} orphaned reactions`); + batchMultiVarQuery(db, reactionsToDelete, deleteReactions); + } + + db.pragma('user_version = 42'); + })(); + logger.info('updateToSchemaVersion42: success!'); +} diff --git a/ts/sql/migrations/43-gv2-uuid.ts b/ts/sql/migrations/43-gv2-uuid.ts new file mode 100644 index 000000000000..164fccd260fb --- /dev/null +++ b/ts/sql/migrations/43-gv2-uuid.ts @@ -0,0 +1,417 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from 'better-sqlite3'; +import { omit } from 'lodash'; + +import type { LoggerType } from '../../types/Logging'; +import { UUID } from '../../types/UUID'; +import type { UUIDStringType } from '../../types/UUID'; +import { isNotNil } from '../../util/isNotNil'; +import { assert } from '../../util/assert'; +import { + TableIterator, + getCountFromTable, + jsonToObject, + objectToJSON, +} from '../util'; +import type { EmptyQuery, Query } from '../util'; +import type { MessageType, ConversationType } from '../Interface'; + +export default function updateToSchemaVersion43( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 43) { + return; + } + + type LegacyPendingMemberType = { + addedByUserId?: string; + conversationId: string; + }; + + type LegacyAdminApprovalType = { + conversationId: string; + }; + + type LegacyConversationType = { + id: string; + membersV2?: Array<{ + conversationId: string; + }>; + pendingMembersV2?: Array; + pendingAdminApprovalV2?: Array; + }; + + const getConversationUuid = db + .prepare( + ` + SELECT uuid + FROM + conversations + WHERE + id = $conversationId + ` + ) + .pluck(); + + const updateConversationStmt = db.prepare( + ` + UPDATE conversations SET + json = $json, + members = $members + WHERE id = $id; + ` + ); + + const updateMessageStmt = db.prepare( + ` + UPDATE messages SET + json = $json, + sourceUuid = $sourceUuid + WHERE id = $id; + ` + ); + + const upgradeConversation = (convo: ConversationType) => { + const legacy = (convo as unknown) as LegacyConversationType; + let result = convo; + + const memberKeys: Array = [ + 'membersV2', + 'pendingMembersV2', + 'pendingAdminApprovalV2', + ]; + for (const key of memberKeys) { + const oldValue = legacy[key]; + if (!Array.isArray(oldValue)) { + continue; + } + + let addedByCount = 0; + + const newValue = oldValue + .map(member => { + const uuid: UUIDStringType = getConversationUuid.get({ + conversationId: member.conversationId, + }); + if (!uuid) { + logger.warn( + `updateToSchemaVersion43: ${legacy.id}.${key} UUID not found ` + + `for ${member.conversationId}` + ); + return undefined; + } + + const updated = { + ...omit(member, 'conversationId'), + uuid, + }; + + // We previously stored our conversation + if (!('addedByUserId' in member) || !member.addedByUserId) { + return updated; + } + + const addedByUserId: + | UUIDStringType + | undefined = getConversationUuid.get({ + conversationId: member.addedByUserId, + }); + + if (!addedByUserId) { + return updated; + } + + addedByCount += 1; + + return { + ...updated, + addedByUserId, + }; + }) + .filter(isNotNil); + + result = { + ...result, + [key]: newValue, + }; + + if (oldValue.length !== 0) { + logger.info( + `updateToSchemaVersion43: migrated ${oldValue.length} ${key} ` + + `entries to ${newValue.length} for ${legacy.id}` + ); + } + + if (addedByCount > 0) { + logger.info( + `updateToSchemaVersion43: migrated ${addedByCount} addedByUserId ` + + `in ${key} for ${legacy.id}` + ); + } + } + + if (result === convo) { + return; + } + + let dbMembers: string | null; + if (result.membersV2) { + dbMembers = result.membersV2.map(item => item.uuid).join(' '); + } else if (result.members) { + dbMembers = result.members.join(' '); + } else { + dbMembers = null; + } + + updateConversationStmt.run({ + id: result.id, + json: objectToJSON(result), + members: dbMembers, + }); + }; + + type LegacyMessageType = { + id: string; + groupV2Change?: { + from: string; + details: Array< + ( + | { + type: + | 'member-add' + | 'member-add-from-invite' + | 'member-add-from-link' + | 'member-add-from-admin-approval' + | 'member-privilege' + | 'member-remove' + | 'pending-add-one' + | 'pending-remove-one' + | 'admin-approval-add-one' + | 'admin-approval-remove-one'; + conversationId: string; + } + | { + type: unknown; + conversationId?: undefined; + } + ) & + ( + | { + type: + | 'member-add-from-invite' + | 'pending-remove-one' + | 'pending-remove-many' + | 'admin-approval-remove-one'; + inviter: string; + } + | { + inviter?: undefined; + } + ) + >; + }; + sourceUuid: string; + invitedGV2Members?: Array; + }; + + const upgradeMessage = (message: MessageType): boolean => { + const { + id, + groupV2Change, + sourceUuid, + invitedGV2Members, + } = (message as unknown) as LegacyMessageType; + let result = message; + + if (groupV2Change) { + assert(result.groupV2Change, 'Pacify typescript'); + + const from: UUIDStringType | undefined = getConversationUuid.get({ + conversationId: groupV2Change.from, + }); + + if (from) { + result = { + ...result, + groupV2Change: { + ...result.groupV2Change, + from, + }, + }; + } else { + result = { + ...result, + groupV2Change: omit(result.groupV2Change, ['from']), + }; + } + + let changedDetails = false; + const details = groupV2Change.details + .map((legacyDetail, i) => { + const oldDetail = result.groupV2Change?.details[i]; + assert(oldDetail, 'Pacify typescript'); + let newDetail = oldDetail; + + for (const key of ['conversationId' as const, 'inviter' as const]) { + const oldValue = legacyDetail[key]; + const newKey = key === 'conversationId' ? 'uuid' : key; + + if (oldValue === undefined) { + continue; + } + changedDetails = true; + + let newValue: UUIDStringType | null = getConversationUuid.get({ + conversationId: oldValue, + }); + if (key === 'inviter') { + newValue = newValue ?? UUID.cast(oldValue); + } + if (!newValue) { + logger.warn( + `updateToSchemaVersion43: ${id}.groupV2Change.details.${key} ` + + `UUID not found for ${oldValue}` + ); + return undefined; + } + + assert(newDetail.type === legacyDetail.type, 'Pacify typescript'); + newDetail = { + ...omit(newDetail, key), + [newKey]: newValue, + }; + } + + return newDetail; + }) + .filter(isNotNil); + + if (changedDetails) { + result = { + ...result, + groupV2Change: { + ...result.groupV2Change, + details, + }, + }; + } + } + + if (sourceUuid) { + const newValue: UUIDStringType = + getConversationUuid.get({ + conversationId: sourceUuid, + }) ?? UUID.cast(sourceUuid); + + if (newValue !== sourceUuid) { + result = { + ...result, + sourceUuid: newValue, + }; + } + } + + if (invitedGV2Members) { + const newMembers = invitedGV2Members + .map(({ addedByUserId, conversationId }, i) => { + const uuid: UUIDStringType | null = getConversationUuid.get({ + conversationId, + }); + const oldMember = + result.invitedGV2Members && result.invitedGV2Members[i]; + assert(oldMember !== undefined, 'Pacify typescript'); + + if (!uuid) { + logger.warn( + `updateToSchemaVersion43: ${id}.invitedGV2Members UUID ` + + `not found for ${conversationId}` + ); + return undefined; + } + + const newMember = { + ...omit(oldMember, ['conversationId']), + uuid, + }; + + if (!addedByUserId) { + return newMember; + } + + const newAddedBy: UUIDStringType | null = getConversationUuid.get({ + conversationId: addedByUserId, + }); + if (!newAddedBy) { + return newMember; + } + + return { + ...newMember, + addedByUserId: newAddedBy, + }; + }) + .filter(isNotNil); + + result = { + ...result, + invitedGV2Members: newMembers, + }; + } + + if (result === message) { + return false; + } + + updateMessageStmt.run({ + id: result.id, + json: JSON.stringify(result), + sourceUuid: result.sourceUuid ?? null, + }); + + return true; + }; + + db.transaction(() => { + const allConversations = db + .prepare( + ` + SELECT json, profileLastFetchedAt + FROM conversations + ORDER BY id ASC; + ` + ) + .all() + .map(({ json }) => jsonToObject(json)); + + logger.info( + 'updateToSchemaVersion43: About to iterate through ' + + `${allConversations.length} conversations` + ); + + for (const convo of allConversations) { + upgradeConversation(convo); + } + + const messageCount = getCountFromTable(db, 'messages'); + logger.info( + 'updateToSchemaVersion43: About to iterate through ' + + `${messageCount} messages` + ); + + let updatedCount = 0; + for (const message of new TableIterator(db, 'messages')) { + if (upgradeMessage(message)) { + updatedCount += 1; + } + } + + logger.info(`updateToSchemaVersion43: Updated ${updatedCount} messages`); + + db.pragma('user_version = 43'); + })(); + logger.info('updateToSchemaVersion43: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts new file mode 100644 index 000000000000..fe9d3fe6b7fc --- /dev/null +++ b/ts/sql/migrations/index.ts @@ -0,0 +1,1934 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from 'better-sqlite3'; +import { keyBy } from 'lodash'; + +import type { LoggerType } from '../../types/Logging'; +import { UUID } from '../../types/UUID'; +import { + getSchemaVersion, + getUserVersion, + getSQLCipherVersion, + getSQLiteVersion, + objectToJSON, + jsonToObject, +} from '../util'; +import type { Query, EmptyQuery } from '../util'; + +import updateToSchemaVersion41 from './41-uuid-keys'; +import updateToSchemaVersion42 from './42-stale-reactions'; +import updateToSchemaVersion43 from './43-gv2-uuid'; + +function updateToSchemaVersion1( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 1) { + return; + } + + logger.info('updateToSchemaVersion1: starting...'); + + db.transaction(() => { + db.exec(` + CREATE TABLE messages( + id STRING PRIMARY KEY ASC, + json TEXT, + + unread INTEGER, + expires_at INTEGER, + sent_at INTEGER, + schemaVersion INTEGER, + conversationId STRING, + received_at INTEGER, + source STRING, + sourceDevice STRING, + hasAttachments INTEGER, + hasFileAttachments INTEGER, + hasVisualMediaAttachments INTEGER + ); + CREATE INDEX messages_unread ON messages ( + unread + ); + CREATE INDEX messages_expires_at ON messages ( + expires_at + ); + CREATE INDEX messages_receipt ON messages ( + sent_at + ); + CREATE INDEX messages_schemaVersion ON messages ( + schemaVersion + ); + CREATE INDEX messages_conversation ON messages ( + conversationId, + received_at + ); + CREATE INDEX messages_duplicate_check ON messages ( + source, + sourceDevice, + sent_at + ); + CREATE INDEX messages_hasAttachments ON messages ( + conversationId, + hasAttachments, + received_at + ); + CREATE INDEX messages_hasFileAttachments ON messages ( + conversationId, + hasFileAttachments, + received_at + ); + CREATE INDEX messages_hasVisualMediaAttachments ON messages ( + conversationId, + hasVisualMediaAttachments, + received_at + ); + CREATE TABLE unprocessed( + id STRING, + timestamp INTEGER, + json TEXT + ); + CREATE INDEX unprocessed_id ON unprocessed ( + id + ); + CREATE INDEX unprocessed_timestamp ON unprocessed ( + timestamp + ); + `); + + db.pragma('user_version = 1'); + })(); + + logger.info('updateToSchemaVersion1: success!'); +} + +function updateToSchemaVersion2( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 2) { + return; + } + + logger.info('updateToSchemaVersion2: starting...'); + + db.transaction(() => { + db.exec(` + ALTER TABLE messages + ADD COLUMN expireTimer INTEGER; + + ALTER TABLE messages + ADD COLUMN expirationStartTimestamp INTEGER; + + ALTER TABLE messages + ADD COLUMN type STRING; + + CREATE INDEX messages_expiring ON messages ( + expireTimer, + expirationStartTimestamp, + expires_at + ); + + UPDATE messages SET + expirationStartTimestamp = json_extract(json, '$.expirationStartTimestamp'), + expireTimer = json_extract(json, '$.expireTimer'), + type = json_extract(json, '$.type'); + `); + db.pragma('user_version = 2'); + })(); + logger.info('updateToSchemaVersion2: success!'); +} + +function updateToSchemaVersion3( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 3) { + return; + } + + logger.info('updateToSchemaVersion3: starting...'); + + db.transaction(() => { + db.exec(` + DROP INDEX messages_expiring; + DROP INDEX messages_unread; + + CREATE INDEX messages_without_timer ON messages ( + expireTimer, + expires_at, + type + ) WHERE expires_at IS NULL AND expireTimer IS NOT NULL; + + CREATE INDEX messages_unread ON messages ( + conversationId, + unread + ) WHERE unread IS NOT NULL; + + ANALYZE; + `); + + db.pragma('user_version = 3'); + })(); + + logger.info('updateToSchemaVersion3: success!'); +} + +function updateToSchemaVersion4( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 4) { + return; + } + + logger.info('updateToSchemaVersion4: starting...'); + + db.transaction(() => { + db.exec(` + CREATE TABLE conversations( + id STRING PRIMARY KEY ASC, + json TEXT, + + active_at INTEGER, + type STRING, + members TEXT, + name TEXT, + profileName TEXT + ); + CREATE INDEX conversations_active ON conversations ( + active_at + ) WHERE active_at IS NOT NULL; + + CREATE INDEX conversations_type ON conversations ( + type + ) WHERE type IS NOT NULL; + `); + + db.pragma('user_version = 4'); + })(); + + logger.info('updateToSchemaVersion4: success!'); +} + +function updateToSchemaVersion6( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 6) { + return; + } + logger.info('updateToSchemaVersion6: starting...'); + + db.transaction(() => { + db.exec(` + -- key-value, ids are strings, one extra column + CREATE TABLE sessions( + id STRING PRIMARY KEY ASC, + number STRING, + json TEXT + ); + CREATE INDEX sessions_number ON sessions ( + number + ) WHERE number IS NOT NULL; + -- key-value, ids are strings + CREATE TABLE groups( + id STRING PRIMARY KEY ASC, + json TEXT + ); + CREATE TABLE identityKeys( + id STRING PRIMARY KEY ASC, + json TEXT + ); + CREATE TABLE items( + id STRING PRIMARY KEY ASC, + json TEXT + ); + -- key-value, ids are integers + CREATE TABLE preKeys( + id INTEGER PRIMARY KEY ASC, + json TEXT + ); + CREATE TABLE signedPreKeys( + id INTEGER PRIMARY KEY ASC, + json TEXT + ); + `); + + db.pragma('user_version = 6'); + })(); + + logger.info('updateToSchemaVersion6: success!'); +} + +function updateToSchemaVersion7( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 7) { + return; + } + logger.info('updateToSchemaVersion7: starting...'); + + db.transaction(() => { + db.exec(` + -- SQLite has been coercing our STRINGs into numbers, so we force it with TEXT + -- We create a new table then copy the data into it, since we can't modify columns + DROP INDEX sessions_number; + ALTER TABLE sessions RENAME TO sessions_old; + + CREATE TABLE sessions( + id TEXT PRIMARY KEY, + number TEXT, + json TEXT + ); + CREATE INDEX sessions_number ON sessions ( + number + ) WHERE number IS NOT NULL; + INSERT INTO sessions(id, number, json) + SELECT "+" || id, number, json FROM sessions_old; + DROP TABLE sessions_old; + `); + + db.pragma('user_version = 7'); + })(); + logger.info('updateToSchemaVersion7: success!'); +} + +function updateToSchemaVersion8( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 8) { + return; + } + logger.info('updateToSchemaVersion8: starting...'); + db.transaction(() => { + db.exec(` + -- First, we pull a new body field out of the message table's json blob + ALTER TABLE messages + ADD COLUMN body TEXT; + UPDATE messages SET body = json_extract(json, '$.body'); + + -- Then we create our full-text search table and populate it + CREATE VIRTUAL TABLE messages_fts + USING fts5(id UNINDEXED, body); + + INSERT INTO messages_fts(id, body) + SELECT id, body FROM messages; + + -- Then we set up triggers to keep the full-text search table up to date + CREATE TRIGGER messages_on_insert AFTER INSERT ON messages BEGIN + INSERT INTO messages_fts ( + id, + body + ) VALUES ( + new.id, + new.body + ); + END; + CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN + DELETE FROM messages_fts WHERE id = old.id; + END; + CREATE TRIGGER messages_on_update AFTER UPDATE ON messages BEGIN + DELETE FROM messages_fts WHERE id = old.id; + INSERT INTO messages_fts( + id, + body + ) VALUES ( + new.id, + new.body + ); + END; + `); + + // For formatting search results: + // https://sqlite.org/fts5.html#the_highlight_function + // https://sqlite.org/fts5.html#the_snippet_function + + db.pragma('user_version = 8'); + })(); + logger.info('updateToSchemaVersion8: success!'); +} + +function updateToSchemaVersion9( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 9) { + return; + } + logger.info('updateToSchemaVersion9: starting...'); + + db.transaction(() => { + db.exec(` + CREATE TABLE attachment_downloads( + id STRING primary key, + timestamp INTEGER, + pending INTEGER, + json TEXT + ); + + CREATE INDEX attachment_downloads_timestamp + ON attachment_downloads ( + timestamp + ) WHERE pending = 0; + CREATE INDEX attachment_downloads_pending + ON attachment_downloads ( + pending + ) WHERE pending != 0; + `); + + db.pragma('user_version = 9'); + })(); + + logger.info('updateToSchemaVersion9: success!'); +} + +function updateToSchemaVersion10( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 10) { + return; + } + logger.info('updateToSchemaVersion10: starting...'); + db.transaction(() => { + db.exec(` + DROP INDEX unprocessed_id; + DROP INDEX unprocessed_timestamp; + ALTER TABLE unprocessed RENAME TO unprocessed_old; + + CREATE TABLE unprocessed( + id STRING, + timestamp INTEGER, + version INTEGER, + attempts INTEGER, + envelope TEXT, + decrypted TEXT, + source TEXT, + sourceDevice TEXT, + serverTimestamp INTEGER + ); + + CREATE INDEX unprocessed_id ON unprocessed ( + id + ); + CREATE INDEX unprocessed_timestamp ON unprocessed ( + timestamp + ); + + INSERT INTO unprocessed ( + id, + timestamp, + version, + attempts, + envelope, + decrypted, + source, + sourceDevice, + serverTimestamp + ) SELECT + id, + timestamp, + json_extract(json, '$.version'), + json_extract(json, '$.attempts'), + json_extract(json, '$.envelope'), + json_extract(json, '$.decrypted'), + json_extract(json, '$.source'), + json_extract(json, '$.sourceDevice'), + json_extract(json, '$.serverTimestamp') + FROM unprocessed_old; + + DROP TABLE unprocessed_old; + `); + + db.pragma('user_version = 10'); + })(); + logger.info('updateToSchemaVersion10: success!'); +} + +function updateToSchemaVersion11( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 11) { + return; + } + logger.info('updateToSchemaVersion11: starting...'); + + db.transaction(() => { + db.exec(` + DROP TABLE groups; + `); + + db.pragma('user_version = 11'); + })(); + logger.info('updateToSchemaVersion11: success!'); +} + +function updateToSchemaVersion12( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 12) { + return; + } + + logger.info('updateToSchemaVersion12: starting...'); + db.transaction(() => { + db.exec(` + CREATE TABLE sticker_packs( + id TEXT PRIMARY KEY, + key TEXT NOT NULL, + + author STRING, + coverStickerId INTEGER, + createdAt INTEGER, + downloadAttempts INTEGER, + installedAt INTEGER, + lastUsed INTEGER, + status STRING, + stickerCount INTEGER, + title STRING + ); + + CREATE TABLE stickers( + id INTEGER NOT NULL, + packId TEXT NOT NULL, + + emoji STRING, + height INTEGER, + isCoverOnly INTEGER, + lastUsed INTEGER, + path STRING, + width INTEGER, + + PRIMARY KEY (id, packId), + CONSTRAINT stickers_fk + FOREIGN KEY (packId) + REFERENCES sticker_packs(id) + ON DELETE CASCADE + ); + + CREATE INDEX stickers_recents + ON stickers ( + lastUsed + ) WHERE lastUsed IS NOT NULL; + + CREATE TABLE sticker_references( + messageId STRING, + packId TEXT, + CONSTRAINT sticker_references_fk + FOREIGN KEY(packId) + REFERENCES sticker_packs(id) + ON DELETE CASCADE + ); + `); + + db.pragma('user_version = 12'); + })(); + logger.info('updateToSchemaVersion12: success!'); +} + +function updateToSchemaVersion13( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 13) { + return; + } + + logger.info('updateToSchemaVersion13: starting...'); + db.transaction(() => { + db.exec(` + ALTER TABLE sticker_packs ADD COLUMN attemptedStatus STRING; + `); + + db.pragma('user_version = 13'); + })(); + logger.info('updateToSchemaVersion13: success!'); +} + +function updateToSchemaVersion14( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 14) { + return; + } + + logger.info('updateToSchemaVersion14: starting...'); + db.transaction(() => { + db.exec(` + CREATE TABLE emojis( + shortName STRING PRIMARY KEY, + lastUsage INTEGER + ); + + CREATE INDEX emojis_lastUsage + ON emojis ( + lastUsage + ); + `); + + db.pragma('user_version = 14'); + })(); + + logger.info('updateToSchemaVersion14: success!'); +} + +function updateToSchemaVersion15( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 15) { + return; + } + + logger.info('updateToSchemaVersion15: starting...'); + db.transaction(() => { + db.exec(` + -- SQLite has again coerced our STRINGs into numbers, so we force it with TEXT + -- We create a new table then copy the data into it, since we can't modify columns + + DROP INDEX emojis_lastUsage; + ALTER TABLE emojis RENAME TO emojis_old; + + CREATE TABLE emojis( + shortName TEXT PRIMARY KEY, + lastUsage INTEGER + ); + CREATE INDEX emojis_lastUsage + ON emojis ( + lastUsage + ); + + DELETE FROM emojis WHERE shortName = 1; + INSERT INTO emojis(shortName, lastUsage) + SELECT shortName, lastUsage FROM emojis_old; + + DROP TABLE emojis_old; + `); + + db.pragma('user_version = 15'); + })(); + logger.info('updateToSchemaVersion15: success!'); +} + +function updateToSchemaVersion16( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 16) { + return; + } + + logger.info('updateToSchemaVersion16: starting...'); + db.transaction(() => { + db.exec(` + ALTER TABLE messages + ADD COLUMN messageTimer INTEGER; + ALTER TABLE messages + ADD COLUMN messageTimerStart INTEGER; + ALTER TABLE messages + ADD COLUMN messageTimerExpiresAt INTEGER; + ALTER TABLE messages + ADD COLUMN isErased INTEGER; + + CREATE INDEX messages_message_timer ON messages ( + messageTimer, + messageTimerStart, + messageTimerExpiresAt, + isErased + ) WHERE messageTimer IS NOT NULL; + + -- Updating full-text triggers to avoid anything with a messageTimer set + + DROP TRIGGER messages_on_insert; + DROP TRIGGER messages_on_delete; + DROP TRIGGER messages_on_update; + + CREATE TRIGGER messages_on_insert AFTER INSERT ON messages + WHEN new.messageTimer IS NULL + BEGIN + INSERT INTO messages_fts ( + id, + body + ) VALUES ( + new.id, + new.body + ); + END; + CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN + DELETE FROM messages_fts WHERE id = old.id; + END; + CREATE TRIGGER messages_on_update AFTER UPDATE ON messages + WHEN new.messageTimer IS NULL + BEGIN + DELETE FROM messages_fts WHERE id = old.id; + INSERT INTO messages_fts( + id, + body + ) VALUES ( + new.id, + new.body + ); + END; + `); + + db.pragma('user_version = 16'); + })(); + logger.info('updateToSchemaVersion16: success!'); +} + +function updateToSchemaVersion17( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 17) { + return; + } + + logger.info('updateToSchemaVersion17: starting...'); + db.transaction(() => { + try { + db.exec(` + ALTER TABLE messages + ADD COLUMN isViewOnce INTEGER; + + DROP INDEX messages_message_timer; + `); + } catch (error) { + logger.info( + 'updateToSchemaVersion17: Message table already had isViewOnce column' + ); + } + + try { + db.exec('DROP INDEX messages_view_once;'); + } catch (error) { + logger.info( + 'updateToSchemaVersion17: Index messages_view_once did not already exist' + ); + } + + db.exec(` + CREATE INDEX messages_view_once ON messages ( + isErased + ) WHERE isViewOnce = 1; + + -- Updating full-text triggers to avoid anything with isViewOnce = 1 + + DROP TRIGGER messages_on_insert; + DROP TRIGGER messages_on_update; + + CREATE TRIGGER messages_on_insert AFTER INSERT ON messages + WHEN new.isViewOnce != 1 + BEGIN + INSERT INTO messages_fts ( + id, + body + ) VALUES ( + new.id, + new.body + ); + END; + CREATE TRIGGER messages_on_update AFTER UPDATE ON messages + WHEN new.isViewOnce != 1 + BEGIN + DELETE FROM messages_fts WHERE id = old.id; + INSERT INTO messages_fts( + id, + body + ) VALUES ( + new.id, + new.body + ); + END; + `); + + db.pragma('user_version = 17'); + })(); + logger.info('updateToSchemaVersion17: success!'); +} + +function updateToSchemaVersion18( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 18) { + return; + } + + logger.info('updateToSchemaVersion18: starting...'); + db.transaction(() => { + db.exec(` + -- Delete and rebuild full-text search index to capture everything + + DELETE FROM messages_fts; + INSERT INTO messages_fts(messages_fts) VALUES('rebuild'); + + INSERT INTO messages_fts(id, body) + SELECT id, body FROM messages WHERE isViewOnce IS NULL OR isViewOnce != 1; + + -- Fixing full-text triggers + + DROP TRIGGER messages_on_insert; + DROP TRIGGER messages_on_update; + + CREATE TRIGGER messages_on_insert AFTER INSERT ON messages + WHEN new.isViewOnce IS NULL OR new.isViewOnce != 1 + BEGIN + INSERT INTO messages_fts ( + id, + body + ) VALUES ( + new.id, + new.body + ); + END; + CREATE TRIGGER messages_on_update AFTER UPDATE ON messages + WHEN new.isViewOnce IS NULL OR new.isViewOnce != 1 + BEGIN + DELETE FROM messages_fts WHERE id = old.id; + INSERT INTO messages_fts( + id, + body + ) VALUES ( + new.id, + new.body + ); + END; + `); + + db.pragma('user_version = 18'); + })(); + logger.info('updateToSchemaVersion18: success!'); +} + +function updateToSchemaVersion19( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 19) { + return; + } + + logger.info('updateToSchemaVersion19: starting...'); + db.transaction(() => { + db.exec(` + ALTER TABLE conversations + ADD COLUMN profileFamilyName TEXT; + ALTER TABLE conversations + ADD COLUMN profileFullName TEXT; + + -- Preload new field with the profileName we already have + UPDATE conversations SET profileFullName = profileName; + `); + + db.pragma('user_version = 19'); + })(); + + logger.info('updateToSchemaVersion19: success!'); +} + +function updateToSchemaVersion20( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 20) { + return; + } + + logger.info('updateToSchemaVersion20: starting...'); + db.transaction(() => { + // The triggers on the messages table slow down this migration + // significantly, so we drop them and recreate them later. + // Drop triggers + const triggers = db + .prepare( + 'SELECT * FROM sqlite_master WHERE type = "trigger" AND tbl_name = "messages"' + ) + .all(); + + for (const trigger of triggers) { + db.exec(`DROP TRIGGER ${trigger.name}`); + } + + // Create new columns and indices + db.exec(` + ALTER TABLE conversations ADD COLUMN e164 TEXT; + ALTER TABLE conversations ADD COLUMN uuid TEXT; + ALTER TABLE conversations ADD COLUMN groupId TEXT; + ALTER TABLE messages ADD COLUMN sourceUuid TEXT; + ALTER TABLE sessions RENAME COLUMN number TO conversationId; + CREATE INDEX conversations_e164 ON conversations(e164); + CREATE INDEX conversations_uuid ON conversations(uuid); + CREATE INDEX conversations_groupId ON conversations(groupId); + CREATE INDEX messages_sourceUuid on messages(sourceUuid); + + -- Migrate existing IDs + UPDATE conversations SET e164 = '+' || id WHERE type = 'private'; + UPDATE conversations SET groupId = id WHERE type = 'group'; + `); + + // Drop invalid groups and any associated messages + const maybeInvalidGroups = db + .prepare( + "SELECT * FROM conversations WHERE type = 'group' AND members IS NULL;" + ) + .all(); + for (const group of maybeInvalidGroups) { + const json: { id: string; members: Array } = JSON.parse( + group.json + ); + if (!json.members || !json.members.length) { + db.prepare('DELETE FROM conversations WHERE id = $id;').run({ + id: json.id, + }); + db.prepare( + 'DELETE FROM messages WHERE conversationId = $id;' + ).run({ id: json.id }); + } + } + + // Generate new IDs and alter data + const allConversations = db + .prepare('SELECT * FROM conversations;') + .all(); + const allConversationsByOldId = keyBy(allConversations, 'id'); + + for (const row of allConversations) { + const oldId = row.id; + const newId = UUID.generate().toString(); + allConversationsByOldId[oldId].id = newId; + const patchObj: { id: string; e164?: string; groupId?: string } = { + id: newId, + }; + if (row.type === 'private') { + patchObj.e164 = `+${oldId}`; + } else if (row.type === 'group') { + patchObj.groupId = oldId; + } + const patch = JSON.stringify(patchObj); + + db.prepare( + ` + UPDATE conversations + SET id = $newId, json = JSON_PATCH(json, $patch) + WHERE id = $oldId + ` + ).run({ + newId, + oldId, + patch, + }); + const messagePatch = JSON.stringify({ conversationId: newId }); + db.prepare( + ` + UPDATE messages + SET conversationId = $newId, json = JSON_PATCH(json, $patch) + WHERE conversationId = $oldId + ` + ).run({ newId, oldId, patch: messagePatch }); + } + + const groupConversations: Array<{ + id: string; + members: string; + json: string; + }> = db + .prepare( + ` + SELECT id, members, json FROM conversations WHERE type = 'group'; + ` + ) + .all(); + + // Update group conversations, point members at new conversation ids + groupConversations.forEach(groupRow => { + const members = groupRow.members.split(/\s?\+/).filter(Boolean); + const newMembers = []; + for (const m of members) { + const memberRow = allConversationsByOldId[m]; + + if (memberRow) { + newMembers.push(memberRow.id); + } else { + // We didn't previously have a private conversation for this member, + // we need to create one + const id = UUID.generate().toString(); + const updatedConversation = { + id, + e164: m, + type: 'private', + version: 2, + unreadCount: 0, + verified: 0, + + // Not directly used by saveConversation, but are necessary + // for conversation model + inbox_position: 0, + isPinned: false, + lastMessageDeletedForEveryone: false, + markedUnread: false, + messageCount: 0, + sentMessageCount: 0, + profileSharing: false, + }; + + db.prepare( + ` + UPDATE conversations + SET + json = $json, + e164 = $e164, + type = $type, + WHERE + id = $id; + ` + ).run({ + id: updatedConversation.id, + json: objectToJSON(updatedConversation), + e164: updatedConversation.e164, + type: updatedConversation.type, + }); + + newMembers.push(id); + } + } + const json = { + ...jsonToObject>(groupRow.json), + members: newMembers, + }; + const newMembersValue = newMembers.join(' '); + db.prepare( + ` + UPDATE conversations + SET members = $newMembersValue, json = $newJsonValue + WHERE id = $id + ` + ).run({ + id: groupRow.id, + newMembersValue, + newJsonValue: objectToJSON(json), + }); + }); + + // Update sessions to stable IDs + const allSessions = db.prepare('SELECT * FROM sessions;').all(); + for (const session of allSessions) { + // Not using patch here so we can explicitly delete a property rather than + // implicitly delete via null + const newJson = JSON.parse(session.json); + const conversation = allConversationsByOldId[newJson.number.substr(1)]; + if (conversation) { + newJson.conversationId = conversation.id; + newJson.id = `${newJson.conversationId}.${newJson.deviceId}`; + } + delete newJson.number; + db.prepare( + ` + UPDATE sessions + SET id = $newId, json = $newJson, conversationId = $newConversationId + WHERE id = $oldId + ` + ).run({ + newId: newJson.id, + newJson: objectToJSON(newJson), + oldId: session.id, + newConversationId: newJson.conversationId, + }); + } + + // Update identity keys to stable IDs + const allIdentityKeys = db + .prepare('SELECT * FROM identityKeys;') + .all(); + for (const identityKey of allIdentityKeys) { + const newJson = JSON.parse(identityKey.json); + newJson.id = allConversationsByOldId[newJson.id]; + db.prepare( + ` + UPDATE identityKeys + SET id = $newId, json = $newJson + WHERE id = $oldId + ` + ).run({ + newId: newJson.id, + newJson: objectToJSON(newJson), + oldId: identityKey.id, + }); + } + + // Recreate triggers + for (const trigger of triggers) { + db.exec(trigger.sql); + } + + db.pragma('user_version = 20'); + })(); + logger.info('updateToSchemaVersion20: success!'); +} + +function updateToSchemaVersion21( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 21) { + return; + } + + db.transaction(() => { + db.exec(` + UPDATE conversations + SET json = json_set( + json, + '$.messageCount', + (SELECT count(*) FROM messages WHERE messages.conversationId = conversations.id) + ); + UPDATE conversations + SET json = json_set( + json, + '$.sentMessageCount', + (SELECT count(*) FROM messages WHERE messages.conversationId = conversations.id AND messages.type = 'outgoing') + ); + `); + db.pragma('user_version = 21'); + })(); + logger.info('updateToSchemaVersion21: success!'); +} + +function updateToSchemaVersion22( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 22) { + return; + } + + db.transaction(() => { + db.exec(` + ALTER TABLE unprocessed + ADD COLUMN sourceUuid STRING; + `); + + db.pragma('user_version = 22'); + })(); + logger.info('updateToSchemaVersion22: success!'); +} + +function updateToSchemaVersion23( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 23) { + return; + } + + db.transaction(() => { + db.exec(` + -- Remove triggers which keep full-text search up to date + DROP TRIGGER messages_on_insert; + DROP TRIGGER messages_on_update; + DROP TRIGGER messages_on_delete; + `); + + db.pragma('user_version = 23'); + })(); + logger.info('updateToSchemaVersion23: success!'); +} + +function updateToSchemaVersion24( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 24) { + return; + } + + db.transaction(() => { + db.exec(` + ALTER TABLE conversations + ADD COLUMN profileLastFetchedAt INTEGER; + `); + + db.pragma('user_version = 24'); + })(); + logger.info('updateToSchemaVersion24: success!'); +} + +function updateToSchemaVersion25( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 25) { + return; + } + + db.transaction(() => { + db.exec(` + ALTER TABLE messages + RENAME TO old_messages + `); + + const indicesToDrop = [ + 'messages_expires_at', + 'messages_receipt', + 'messages_schemaVersion', + 'messages_conversation', + 'messages_duplicate_check', + 'messages_hasAttachments', + 'messages_hasFileAttachments', + 'messages_hasVisualMediaAttachments', + 'messages_without_timer', + 'messages_unread', + 'messages_view_once', + 'messages_sourceUuid', + ]; + for (const index of indicesToDrop) { + db.exec(`DROP INDEX IF EXISTS ${index};`); + } + + db.exec(` + -- + -- Create a new table with a different primary key + -- + + CREATE TABLE messages( + rowid INTEGER PRIMARY KEY ASC, + id STRING UNIQUE, + json TEXT, + unread INTEGER, + expires_at INTEGER, + sent_at INTEGER, + schemaVersion INTEGER, + conversationId STRING, + received_at INTEGER, + source STRING, + sourceDevice STRING, + hasAttachments INTEGER, + hasFileAttachments INTEGER, + hasVisualMediaAttachments INTEGER, + expireTimer INTEGER, + expirationStartTimestamp INTEGER, + type STRING, + body TEXT, + messageTimer INTEGER, + messageTimerStart INTEGER, + messageTimerExpiresAt INTEGER, + isErased INTEGER, + isViewOnce INTEGER, + sourceUuid TEXT); + + -- Create index in lieu of old PRIMARY KEY + CREATE INDEX messages_id ON messages (id ASC); + + -- + -- Recreate indices + -- + + CREATE INDEX messages_expires_at ON messages (expires_at); + + CREATE INDEX messages_receipt ON messages (sent_at); + + CREATE INDEX messages_schemaVersion ON messages (schemaVersion); + + CREATE INDEX messages_conversation ON messages + (conversationId, received_at); + + CREATE INDEX messages_duplicate_check ON messages + (source, sourceDevice, sent_at); + + CREATE INDEX messages_hasAttachments ON messages + (conversationId, hasAttachments, received_at); + + CREATE INDEX messages_hasFileAttachments ON messages + (conversationId, hasFileAttachments, received_at); + + CREATE INDEX messages_hasVisualMediaAttachments ON messages + (conversationId, hasVisualMediaAttachments, received_at); + + CREATE INDEX messages_without_timer ON messages + (expireTimer, expires_at, type) + WHERE expires_at IS NULL AND expireTimer IS NOT NULL; + + CREATE INDEX messages_unread ON messages + (conversationId, unread) WHERE unread IS NOT NULL; + + CREATE INDEX messages_view_once ON messages + (isErased) WHERE isViewOnce = 1; + + CREATE INDEX messages_sourceUuid on messages(sourceUuid); + + -- New index for searchMessages + CREATE INDEX messages_searchOrder on messages(received_at, sent_at); + + -- + -- Re-create messages_fts and add triggers + -- + + DROP TABLE messages_fts; + + CREATE VIRTUAL TABLE messages_fts USING fts5(body); + + CREATE TRIGGER messages_on_insert AFTER INSERT ON messages + WHEN new.isViewOnce IS NULL OR new.isViewOnce != 1 + BEGIN + INSERT INTO messages_fts + (rowid, body) + VALUES + (new.rowid, new.body); + END; + + CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN + DELETE FROM messages_fts WHERE rowid = old.rowid; + END; + + CREATE TRIGGER messages_on_update AFTER UPDATE ON messages + WHEN new.isViewOnce IS NULL OR new.isViewOnce != 1 + BEGIN + DELETE FROM messages_fts WHERE rowid = old.rowid; + INSERT INTO messages_fts + (rowid, body) + VALUES + (new.rowid, new.body); + END; + + -- + -- Copy data over + -- + + INSERT INTO messages + ( + id, json, unread, expires_at, sent_at, schemaVersion, conversationId, + received_at, source, sourceDevice, hasAttachments, hasFileAttachments, + hasVisualMediaAttachments, expireTimer, expirationStartTimestamp, type, + body, messageTimer, messageTimerStart, messageTimerExpiresAt, isErased, + isViewOnce, sourceUuid + ) + SELECT + id, json, unread, expires_at, sent_at, schemaVersion, conversationId, + received_at, source, sourceDevice, hasAttachments, hasFileAttachments, + hasVisualMediaAttachments, expireTimer, expirationStartTimestamp, type, + body, messageTimer, messageTimerStart, messageTimerExpiresAt, isErased, + isViewOnce, sourceUuid + FROM old_messages; + + -- Drop old database + DROP TABLE old_messages; + `); + + db.pragma('user_version = 25'); + })(); + logger.info('updateToSchemaVersion25: success!'); +} + +function updateToSchemaVersion26( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 26) { + return; + } + + db.transaction(() => { + db.exec(` + DROP TRIGGER messages_on_insert; + DROP TRIGGER messages_on_update; + + CREATE TRIGGER messages_on_insert AFTER INSERT ON messages + WHEN new.isViewOnce IS NULL OR new.isViewOnce != 1 + BEGIN + INSERT INTO messages_fts + (rowid, body) + VALUES + (new.rowid, new.body); + END; + + CREATE TRIGGER messages_on_update AFTER UPDATE ON messages + WHEN new.body != old.body AND + (new.isViewOnce IS NULL OR new.isViewOnce != 1) + BEGIN + DELETE FROM messages_fts WHERE rowid = old.rowid; + INSERT INTO messages_fts + (rowid, body) + VALUES + (new.rowid, new.body); + END; + `); + + db.pragma('user_version = 26'); + })(); + logger.info('updateToSchemaVersion26: success!'); +} + +function updateToSchemaVersion27( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 27) { + return; + } + + db.transaction(() => { + db.exec(` + DELETE FROM messages_fts WHERE rowid IN + (SELECT rowid FROM messages WHERE body IS NULL); + + DROP TRIGGER messages_on_update; + + CREATE TRIGGER messages_on_update AFTER UPDATE ON messages + WHEN + new.body IS NULL OR + ((old.body IS NULL OR new.body != old.body) AND + (new.isViewOnce IS NULL OR new.isViewOnce != 1)) + BEGIN + DELETE FROM messages_fts WHERE rowid = old.rowid; + INSERT INTO messages_fts + (rowid, body) + VALUES + (new.rowid, new.body); + END; + + CREATE TRIGGER messages_on_view_once_update AFTER UPDATE ON messages + WHEN + new.body IS NOT NULL AND new.isViewOnce = 1 + BEGIN + DELETE FROM messages_fts WHERE rowid = old.rowid; + END; + `); + + db.pragma('user_version = 27'); + })(); + logger.info('updateToSchemaVersion27: success!'); +} + +function updateToSchemaVersion28( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 28) { + return; + } + + db.transaction(() => { + db.exec(` + CREATE TABLE jobs( + id TEXT PRIMARY KEY, + queueType TEXT STRING NOT NULL, + timestamp INTEGER NOT NULL, + data STRING TEXT + ); + + CREATE INDEX jobs_timestamp ON jobs (timestamp); + `); + + db.pragma('user_version = 28'); + })(); + logger.info('updateToSchemaVersion28: success!'); +} + +function updateToSchemaVersion29( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 29) { + return; + } + + db.transaction(() => { + db.exec(` + CREATE TABLE reactions( + conversationId STRING, + emoji STRING, + fromId STRING, + messageReceivedAt INTEGER, + targetAuthorUuid STRING, + targetTimestamp INTEGER, + unread INTEGER + ); + + CREATE INDEX reactions_unread ON reactions ( + unread, + conversationId + ); + + CREATE INDEX reaction_identifier ON reactions ( + emoji, + targetAuthorUuid, + targetTimestamp + ); + `); + + db.pragma('user_version = 29'); + })(); + logger.info('updateToSchemaVersion29: success!'); +} + +function updateToSchemaVersion30( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 30) { + return; + } + + db.transaction(() => { + db.exec(` + CREATE TABLE senderKeys( + id TEXT PRIMARY KEY NOT NULL, + senderId TEXT NOT NULL, + distributionId TEXT NOT NULL, + data BLOB NOT NULL, + lastUpdatedDate NUMBER NOT NULL + ); + `); + + db.pragma('user_version = 30'); + })(); + logger.info('updateToSchemaVersion30: success!'); +} + +function updateToSchemaVersion31( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 31) { + return; + } + logger.info('updateToSchemaVersion31: starting...'); + db.transaction(() => { + db.exec(` + DROP INDEX unprocessed_id; + DROP INDEX unprocessed_timestamp; + ALTER TABLE unprocessed RENAME TO unprocessed_old; + + CREATE TABLE unprocessed( + id STRING PRIMARY KEY ASC, + timestamp INTEGER, + version INTEGER, + attempts INTEGER, + envelope TEXT, + decrypted TEXT, + source TEXT, + sourceDevice TEXT, + serverTimestamp INTEGER, + sourceUuid STRING + ); + + CREATE INDEX unprocessed_timestamp ON unprocessed ( + timestamp + ); + + INSERT OR REPLACE INTO unprocessed + (id, timestamp, version, attempts, envelope, decrypted, source, + sourceDevice, serverTimestamp, sourceUuid) + SELECT + id, timestamp, version, attempts, envelope, decrypted, source, + sourceDevice, serverTimestamp, sourceUuid + FROM unprocessed_old; + + DROP TABLE unprocessed_old; + `); + + db.pragma('user_version = 31'); + })(); + logger.info('updateToSchemaVersion31: success!'); +} + +function updateToSchemaVersion32( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 32) { + return; + } + + db.transaction(() => { + db.exec(` + ALTER TABLE messages + ADD COLUMN serverGuid STRING NULL; + + ALTER TABLE unprocessed + ADD COLUMN serverGuid STRING NULL; + `); + + db.pragma('user_version = 32'); + })(); + logger.info('updateToSchemaVersion32: success!'); +} + +function updateToSchemaVersion33( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 33) { + return; + } + + db.transaction(() => { + db.exec(` + -- These indexes should exist, but we add "IF EXISTS" for safety. + DROP INDEX IF EXISTS messages_expires_at; + DROP INDEX IF EXISTS messages_without_timer; + + ALTER TABLE messages + ADD COLUMN + expiresAt INT + GENERATED ALWAYS + AS (expirationStartTimestamp + (expireTimer * 1000)); + + CREATE INDEX message_expires_at ON messages ( + expiresAt + ); + + CREATE INDEX outgoing_messages_without_expiration_start_timestamp ON messages ( + expireTimer, expirationStartTimestamp, type + ) + WHERE expireTimer IS NOT NULL AND expirationStartTimestamp IS NULL; + `); + + db.pragma('user_version = 33'); + })(); + logger.info('updateToSchemaVersion33: success!'); +} + +function updateToSchemaVersion34( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 34) { + return; + } + + db.transaction(() => { + db.exec(` + -- This index should exist, but we add "IF EXISTS" for safety. + DROP INDEX IF EXISTS outgoing_messages_without_expiration_start_timestamp; + + CREATE INDEX messages_unexpectedly_missing_expiration_start_timestamp ON messages ( + expireTimer, expirationStartTimestamp, type + ) + WHERE expireTimer IS NOT NULL AND expirationStartTimestamp IS NULL; + `); + + db.pragma('user_version = 34'); + })(); + logger.info('updateToSchemaVersion34: success!'); +} + +function updateToSchemaVersion35( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 35) { + return; + } + + db.transaction(() => { + db.exec(` + CREATE INDEX expiring_message_by_conversation_and_received_at + ON messages + ( + expirationStartTimestamp, + expireTimer, + conversationId, + received_at + ); + `); + + db.pragma('user_version = 35'); + })(); + logger.info('updateToSchemaVersion35: success!'); +} + +// Reverted +function updateToSchemaVersion36( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 36) { + return; + } + + db.pragma('user_version = 36'); + logger.info('updateToSchemaVersion36: success!'); +} + +function updateToSchemaVersion37( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 37) { + return; + } + + db.transaction(() => { + db.exec(` + -- Create send log primary table + + CREATE TABLE sendLogPayloads( + id INTEGER PRIMARY KEY ASC, + + timestamp INTEGER NOT NULL, + contentHint INTEGER NOT NULL, + proto BLOB NOT NULL + ); + + CREATE INDEX sendLogPayloadsByTimestamp ON sendLogPayloads (timestamp); + + -- Create send log recipients table with foreign key relationship to payloads + + CREATE TABLE sendLogRecipients( + payloadId INTEGER NOT NULL, + + recipientUuid STRING NOT NULL, + deviceId INTEGER NOT NULL, + + PRIMARY KEY (payloadId, recipientUuid, deviceId), + + CONSTRAINT sendLogRecipientsForeignKey + FOREIGN KEY (payloadId) + REFERENCES sendLogPayloads(id) + ON DELETE CASCADE + ); + + CREATE INDEX sendLogRecipientsByRecipient + ON sendLogRecipients (recipientUuid, deviceId); + + -- Create send log messages table with foreign key relationship to payloads + + CREATE TABLE sendLogMessageIds( + payloadId INTEGER NOT NULL, + + messageId STRING NOT NULL, + + PRIMARY KEY (payloadId, messageId), + + CONSTRAINT sendLogMessageIdsForeignKey + FOREIGN KEY (payloadId) + REFERENCES sendLogPayloads(id) + ON DELETE CASCADE + ); + + CREATE INDEX sendLogMessageIdsByMessage + ON sendLogMessageIds (messageId); + + -- Recreate messages table delete trigger with send log support + + DROP TRIGGER messages_on_delete; + + CREATE TRIGGER messages_on_delete AFTER DELETE ON messages BEGIN + DELETE FROM messages_fts WHERE rowid = old.rowid; + DELETE FROM sendLogPayloads WHERE id IN ( + SELECT payloadId FROM sendLogMessageIds + WHERE messageId = old.id + ); + END; + + --- Add messageId column to reactions table to properly track proto associations + + ALTER TABLE reactions ADD column messageId STRING; + `); + + db.pragma('user_version = 37'); + })(); + logger.info('updateToSchemaVersion37: success!'); +} + +function updateToSchemaVersion38( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 38) { + return; + } + + db.transaction(() => { + // TODO: Remove deprecated columns once sqlcipher is updated to support it + db.exec(` + DROP INDEX IF EXISTS messages_duplicate_check; + + ALTER TABLE messages + RENAME COLUMN sourceDevice TO deprecatedSourceDevice; + ALTER TABLE messages + ADD COLUMN sourceDevice INTEGER; + + UPDATE messages + SET + sourceDevice = CAST(deprecatedSourceDevice AS INTEGER), + deprecatedSourceDevice = NULL; + + ALTER TABLE unprocessed + RENAME COLUMN sourceDevice TO deprecatedSourceDevice; + ALTER TABLE unprocessed + ADD COLUMN sourceDevice INTEGER; + + UPDATE unprocessed + SET + sourceDevice = CAST(deprecatedSourceDevice AS INTEGER), + deprecatedSourceDevice = NULL; + `); + + db.pragma('user_version = 38'); + })(); + logger.info('updateToSchemaVersion38: success!'); +} + +function updateToSchemaVersion39( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 39) { + return; + } + + db.transaction(() => { + db.exec('ALTER TABLE messages RENAME COLUMN unread TO readStatus;'); + + db.pragma('user_version = 39'); + })(); + logger.info('updateToSchemaVersion39: success!'); +} + +function updateToSchemaVersion40( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 40) { + return; + } + + db.transaction(() => { + db.exec( + ` + CREATE TABLE groupCallRings( + ringId INTEGER PRIMARY KEY, + isActive INTEGER NOT NULL, + createdAt INTEGER NOT NULL + ); + ` + ); + + db.pragma('user_version = 40'); + })(); + logger.info('updateToSchemaVersion40: success!'); +} + +export const SCHEMA_VERSIONS = [ + updateToSchemaVersion1, + updateToSchemaVersion2, + updateToSchemaVersion3, + updateToSchemaVersion4, + (_v: number, _i: Database, _l: LoggerType): void => undefined, // version 5 was dropped + updateToSchemaVersion6, + updateToSchemaVersion7, + updateToSchemaVersion8, + updateToSchemaVersion9, + updateToSchemaVersion10, + updateToSchemaVersion11, + updateToSchemaVersion12, + updateToSchemaVersion13, + updateToSchemaVersion14, + updateToSchemaVersion15, + updateToSchemaVersion16, + updateToSchemaVersion17, + updateToSchemaVersion18, + updateToSchemaVersion19, + updateToSchemaVersion20, + updateToSchemaVersion21, + updateToSchemaVersion22, + updateToSchemaVersion23, + updateToSchemaVersion24, + updateToSchemaVersion25, + updateToSchemaVersion26, + updateToSchemaVersion27, + updateToSchemaVersion28, + updateToSchemaVersion29, + updateToSchemaVersion30, + updateToSchemaVersion31, + updateToSchemaVersion32, + updateToSchemaVersion33, + updateToSchemaVersion34, + updateToSchemaVersion35, + updateToSchemaVersion36, + updateToSchemaVersion37, + updateToSchemaVersion38, + updateToSchemaVersion39, + updateToSchemaVersion40, + updateToSchemaVersion41, + updateToSchemaVersion42, + updateToSchemaVersion43, +]; + +export function updateSchema(db: Database, logger: LoggerType): void { + const sqliteVersion = getSQLiteVersion(db); + const sqlcipherVersion = getSQLCipherVersion(db); + const userVersion = getUserVersion(db); + const maxUserVersion = SCHEMA_VERSIONS.length; + const schemaVersion = getSchemaVersion(db); + + logger.info( + 'updateSchema:\n', + ` Current user_version: ${userVersion};\n`, + ` Most recent db schema: ${maxUserVersion};\n`, + ` SQLite version: ${sqliteVersion};\n`, + ` SQLCipher version: ${sqlcipherVersion};\n`, + ` (deprecated) schema_version: ${schemaVersion};\n` + ); + + if (userVersion > maxUserVersion) { + throw new Error( + `SQL: User version is ${userVersion} but the expected maximum version ` + + `is ${maxUserVersion}. Did you try to start an old version of Signal?` + ); + } + + for (let index = 0; index < maxUserVersion; index += 1) { + const runSchemaUpdate = SCHEMA_VERSIONS[index]; + + runSchemaUpdate(userVersion, db, logger); + } +} diff --git a/ts/sql/util.ts b/ts/sql/util.ts new file mode 100644 index 000000000000..28008f93f1a9 --- /dev/null +++ b/ts/sql/util.ts @@ -0,0 +1,260 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from 'better-sqlite3'; +import { isNumber, last } from 'lodash'; + +export type EmptyQuery = []; +export type ArrayQuery = Array>; +export type Query = { [key: string]: null | number | bigint | string | Buffer }; +export type JSONRows = Array<{ readonly json: string }>; + +export type TableType = + | 'attachment_downloads' + | 'conversations' + | 'identityKeys' + | 'items' + | 'messages' + | 'preKeys' + | 'senderKeys' + | 'sessions' + | 'signedPreKeys' + | 'stickers' + | 'unprocessed'; + +// This value needs to be below SQLITE_MAX_VARIABLE_NUMBER. +const MAX_VARIABLE_COUNT = 100; + +export function objectToJSON(data: T): string { + return JSON.stringify(data); +} + +export function jsonToObject(json: string): T { + return JSON.parse(json); +} + +// +// Database helpers +// + +export function getSQLiteVersion(db: Database): string { + const { sqlite_version: version } = db + .prepare('select sqlite_version() AS sqlite_version') + .get(); + + return version; +} + +export function getSchemaVersion(db: Database): number { + return db.pragma('schema_version', { simple: true }); +} + +export function setUserVersion(db: Database, version: number): void { + if (!isNumber(version)) { + throw new Error(`setUserVersion: version ${version} is not a number`); + } + db.pragma(`user_version = ${version}`); +} + +export function getUserVersion(db: Database): number { + return db.pragma('user_version', { simple: true }); +} + +export function getSQLCipherVersion(db: Database): string | undefined { + return db.pragma('cipher_version', { simple: true }); +} + +// +// Various table helpers +// + +export function batchMultiVarQuery( + db: Database, + values: Array, + query: (batch: Array) => void +): []; +export function batchMultiVarQuery( + db: Database, + values: Array, + query: (batch: Array) => Array +): Array; + +export function batchMultiVarQuery( + db: Database, + values: Array, + query: + | ((batch: Array) => void) + | ((batch: Array) => Array) +): Array { + if (values.length > MAX_VARIABLE_COUNT) { + const result: Array = []; + db.transaction(() => { + for (let i = 0; i < values.length; i += MAX_VARIABLE_COUNT) { + const batch = values.slice(i, i + MAX_VARIABLE_COUNT); + const batchResult = query(batch); + if (Array.isArray(batchResult)) { + result.push(...batchResult); + } + } + })(); + return result; + } + + const result = query(values); + return Array.isArray(result) ? result : []; +} + +export function createOrUpdate( + db: Database, + table: TableType, + data: Record & { id: Key } +): void { + const { id } = data; + if (!id) { + throw new Error('createOrUpdate: Provided data did not have a truthy id'); + } + + db.prepare( + ` + INSERT OR REPLACE INTO ${table} ( + id, + json + ) values ( + $id, + $json + ) + ` + ).run({ + id, + json: objectToJSON(data), + }); +} + +export function bulkAdd( + db: Database, + table: TableType, + array: Array & { id: string | number }> +): void { + db.transaction(() => { + for (const data of array) { + createOrUpdate(db, table, data); + } + })(); +} + +export function getById( + db: Database, + table: TableType, + id: Key +): Result | undefined { + const row = db + .prepare( + ` + SELECT * + FROM ${table} + WHERE id = $id; + ` + ) + .get({ + id, + }); + + if (!row) { + return undefined; + } + + return jsonToObject(row.json); +} + +export function removeById( + db: Database, + table: TableType, + id: Key | Array +): void { + if (!Array.isArray(id)) { + db.prepare( + ` + DELETE FROM ${table} + WHERE id = $id; + ` + ).run({ id }); + return; + } + + if (!id.length) { + throw new Error('removeById: No ids to delete!'); + } + + const removeByIdsSync = (ids: Array): void => { + db.prepare( + ` + DELETE FROM ${table} + WHERE id IN ( ${id.map(() => '?').join(', ')} ); + ` + ).run(ids); + }; + + batchMultiVarQuery(db, id, removeByIdsSync); +} + +export function removeAllFromTable(db: Database, table: TableType): void { + db.prepare(`DELETE FROM ${table};`).run(); +} + +export function getAllFromTable(db: Database, table: TableType): Array { + const rows: JSONRows = db + .prepare(`SELECT json FROM ${table};`) + .all(); + + return rows.map(row => jsonToObject(row.json)); +} + +export function getCountFromTable(db: Database, table: TableType): number { + const result: null | number = db + .prepare(`SELECT count(*) from ${table};`) + .pluck(true) + .get(); + if (isNumber(result)) { + return result; + } + throw new Error(`getCountFromTable: Unable to get count from table ${table}`); +} + +export class TableIterator { + constructor( + private readonly db: Database, + private readonly table: TableType, + private readonly pageSize = 500 + ) {} + + *[Symbol.iterator](): Iterator { + const fetchObject = this.db.prepare( + ` + SELECT json FROM ${this.table} + WHERE id > $id + ORDER BY id ASC + LIMIT $pageSize; + ` + ); + + let complete = false; + let id = ''; + while (!complete) { + const rows: JSONRows = fetchObject.all({ + id, + pageSize: this.pageSize, + }); + + const messages: Array = rows.map(row => + jsonToObject(row.json) + ); + yield* messages; + + const lastMessage: ObjectType | undefined = last(messages); + if (lastMessage) { + ({ id } = lastMessage); + } + complete = messages.length < this.pageSize; + } + } +} diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index b3e91910732f..ab54db6d58e0 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -35,21 +35,22 @@ import { requestCameraPermissions } from '../../util/callingPermissions'; import { isGroupCallOutboundRingEnabled } from '../../util/isGroupCallOutboundRingEnabled'; import { sleep } from '../../util/sleep'; import { LatestQueue } from '../../util/LatestQueue'; +import type { UUIDStringType } from '../../types/UUID'; import type { ConversationChangedActionType } from './conversations'; import * as log from '../../logging/log'; // State export type GroupCallPeekInfoType = { - uuids: Array; - creatorUuid?: string; + uuids: Array; + creatorUuid?: UUIDStringType; eraId?: string; maxDevices: number; deviceCount: number; }; export type GroupCallParticipantInfoType = { - uuid: string; + uuid: UUIDStringType; demuxId: number; hasRemoteAudio: boolean; hasRemoteVideo: boolean; @@ -77,7 +78,7 @@ type GroupCallRingStateType = } | { ringId: bigint; - ringerUuid: string; + ringerUuid: UUIDStringType; }; export type GroupCallStateType = { @@ -99,7 +100,7 @@ export type ActiveCallStateType = { pip: boolean; presentingSource?: PresentedSource; presentingSourcesAvailable?: Array; - safetyNumberChangedUuids: Array; + safetyNumberChangedUuids: Array; settingsDialogOpen: boolean; showNeedsScreenRecordingPermissionsWarning?: boolean; showParticipantsList: boolean; @@ -153,7 +154,7 @@ type GroupCallStateChangeArgumentType = { }; type GroupCallStateChangeActionPayloadType = GroupCallStateChangeArgumentType & { - ourUuid: string; + ourUuid: UUIDStringType; }; export type HangUpType = { @@ -161,7 +162,7 @@ export type HangUpType = { }; type KeyChangedType = { - uuid: string; + uuid: UUIDStringType; }; export type KeyChangeOkType = { @@ -176,7 +177,7 @@ export type IncomingDirectCallType = { type IncomingGroupCallType = { conversationId: string; ringId: bigint; - ringerUuid: string; + ringerUuid: UUIDStringType; }; type PeekNotConnectedGroupCallType = { @@ -262,7 +263,7 @@ export const getActiveCall = ({ // support it for direct calls. export const getIncomingCall = ( callsByConversation: Readonly, - ourUuid: string + ourUuid: UUIDStringType ): undefined | DirectCallStateType | GroupCallStateType => Object.values(callsByConversation).find(call => { switch (call.callMode) { @@ -281,7 +282,7 @@ export const getIncomingCall = ( export const isAnybodyElseInGroupCall = ( { uuids }: Readonly, - ourUuid: string + ourUuid: UUIDStringType ): boolean => uuids.some(id => id !== ourUuid); const getGroupCallRingState = ( @@ -390,7 +391,7 @@ type IncomingGroupCallActionType = { type KeyChangedActionType = { type: 'calling/MARK_CALL_UNTRUSTED'; payload: { - safetyNumberChangedUuids: Array; + safetyNumberChangedUuids: Array; }; }; @@ -409,7 +410,7 @@ export type PeekNotConnectedGroupCallFulfilledActionType = { payload: { conversationId: string; peekInfo: GroupCallPeekInfoType; - ourConversationId: string; + ourUuid: UUIDStringType; }; }; @@ -895,6 +896,8 @@ function peekNotConnectedGroupCall( return; } + const { ourUuid } = state.user; + await calling.updateCallHistoryForGroupCall(conversationId, peekInfo); const formattedPeekInfo = calling.formatGroupCallPeekInfoForRedux( @@ -906,7 +909,7 @@ function peekNotConnectedGroupCall( payload: { conversationId, peekInfo: formattedPeekInfo, - ourConversationId: state.user.ourConversationId, + ourUuid, }, }); }); @@ -1661,7 +1664,7 @@ export function reducer( } if (action.type === PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED) { - const { conversationId, peekInfo, ourConversationId } = action.payload; + const { conversationId, peekInfo, ourUuid } = action.payload; const existingCall: GroupCallStateType = getGroupCall( conversationId, @@ -1693,7 +1696,7 @@ export function reducer( } if ( - !isAnybodyElseInGroupCall(peekInfo, ourConversationId) && + !isAnybodyElseInGroupCall(peekInfo, ourUuid) && !existingCall.ringerUuid ) { return removeConversationFromState(state, conversationId); diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 79000478a193..e43f31ad7d6b 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -39,6 +39,7 @@ import type { import type { BodyRangeType } from '../../types/Util'; import { CallMode } from '../../types/Calling'; import type { MediaItemType } from '../../types/MediaItem'; +import type { UUIDStringType } from '../../types/UUID'; import { getGroupSizeRecommendedLimit, getGroupSizeHardLimit, @@ -82,7 +83,7 @@ export type ConversationTypeType = typeof ConversationTypes[number]; export type ConversationType = { id: string; - uuid?: string; + uuid?: UUIDStringType; e164?: string; name?: string; familyName?: string; @@ -134,15 +135,15 @@ export type ConversationType = { announcementsOnlyReady?: boolean; expireTimer?: number; memberships?: Array<{ - conversationId: string; + uuid: UUIDStringType; isAdmin: boolean; }>; pendingMemberships?: Array<{ - conversationId: string; - addedByUserId?: string; + uuid: UUIDStringType; + addedByUserId?: UUIDStringType; }>; pendingApprovalMemberships?: Array<{ - conversationId: string; + uuid: UUIDStringType; }>; muteExpiresAt?: number; dontNotifyForMentionsIfMuted?: boolean; @@ -294,7 +295,7 @@ type ContactSpoofingReviewStateType = export type ConversationsStateType = { preJoinConversation?: PreJoinConversationType; - invitedConversationIdsForNewlyCreatedGroup?: Array; + invitedUuidsForNewlyCreatedGroup?: Array; conversationLookup: ConversationLookupType; conversationsByE164: ConversationLookupType; conversationsByUuid: ConversationLookupType; @@ -373,8 +374,8 @@ type CantAddContactToGroupActionType = { }; }; type ClearGroupCreationErrorActionType = { type: 'CLEAR_GROUP_CREATION_ERROR' }; -type ClearInvitedConversationsForNewlyCreatedGroupActionType = { - type: 'CLEAR_INVITED_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP'; +type ClearInvitedUuidsForNewlyCreatedGroupActionType = { + type: 'CLEAR_INVITED_UUIDS_FOR_NEWLY_CREATED_GROUP'; }; type CloseCantAddContactToGroupModalActionType = { type: 'CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL'; @@ -470,7 +471,7 @@ type CreateGroupPendingActionType = { type CreateGroupFulfilledActionType = { type: 'CREATE_GROUP_FULFILLED'; payload: { - invitedConversationIds: Array; + invitedUuids: Array; }; }; type CreateGroupRejectedActionType = { @@ -689,7 +690,7 @@ export type ConversationActionType = | CantAddContactToGroupActionType | ClearChangedMessagesActionType | ClearGroupCreationErrorActionType - | ClearInvitedConversationsForNewlyCreatedGroupActionType + | ClearInvitedUuidsForNewlyCreatedGroupActionType | ClearSelectedMessageActionType | ClearUnreadMetricsActionType | CloseCantAddContactToGroupModalActionType @@ -751,7 +752,7 @@ export const actions = { cantAddContactToGroup, clearChangedMessages, clearGroupCreationError, - clearInvitedConversationsForNewlyCreatedGroup, + clearInvitedUuidsForNewlyCreatedGroup, clearSelectedMessage, clearUnreadMetrics, closeCantAddContactToGroupModal, @@ -1320,9 +1321,9 @@ function createGroup(): ThunkAction< dispatch({ type: 'CREATE_GROUP_FULFILLED', payload: { - invitedConversationIds: ( - conversation.get('pendingMembersV2') || [] - ).map(member => member.conversationId), + invitedUuids: (conversation.get('pendingMembersV2') || []).map( + member => member.uuid + ), }, }); openConversationInternal({ @@ -1551,8 +1552,8 @@ function clearChangedMessages( }, }; } -function clearInvitedConversationsForNewlyCreatedGroup(): ClearInvitedConversationsForNewlyCreatedGroupActionType { - return { type: 'CLEAR_INVITED_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP' }; +function clearInvitedUuidsForNewlyCreatedGroup(): ClearInvitedUuidsForNewlyCreatedGroupActionType { + return { type: 'CLEAR_INVITED_UUIDS_FOR_NEWLY_CREATED_GROUP' }; } function clearGroupCreationError(): ClearGroupCreationErrorActionType { return { type: 'CLEAR_GROUP_CREATION_ERROR' }; @@ -1979,8 +1980,8 @@ export function reducer( }; } - if (action.type === 'CLEAR_INVITED_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP') { - return omit(state, 'invitedConversationIdsForNewlyCreatedGroup'); + if (action.type === 'CLEAR_INVITED_UUIDS_FOR_NEWLY_CREATED_GROUP') { + return omit(state, 'invitedUuidsForNewlyCreatedGroup'); } if (action.type === 'CLEAR_GROUP_CREATION_ERROR') { @@ -2169,8 +2170,7 @@ export function reducer( // the work. return { ...state, - invitedConversationIdsForNewlyCreatedGroup: - action.payload.invitedConversationIds, + invitedUuidsForNewlyCreatedGroup: action.payload.invitedUuids, }; } if (action.type === 'CREATE_GROUP_REJECTED') { diff --git a/ts/state/ducks/user.ts b/ts/state/ducks/user.ts index c7a587ad9cbe..58901a3704b7 100644 --- a/ts/state/ducks/user.ts +++ b/ts/state/ducks/user.ts @@ -6,6 +6,7 @@ import { trigger } from '../../shims/events'; import type { NoopActionType } from './noop'; import type { LocalizerType } from '../../types/Util'; import { ThemeType } from '../../types/Util'; +import type { UUIDStringType } from '../../types/UUID'; // State @@ -15,7 +16,7 @@ export type UserStateType = { tempPath: string; ourConversationId: string; ourDeviceId: number; - ourUuid: string; + ourUuid: UUIDStringType; ourNumber: string; platform: string; regionCode: string; @@ -31,7 +32,7 @@ type UserChangedActionType = { payload: { ourConversationId?: string; ourDeviceId?: number; - ourUuid?: string; + ourUuid?: UUIDStringType; ourNumber?: string; regionCode?: string; interactionMode?: 'mouse' | 'keyboard'; @@ -53,7 +54,7 @@ function userChanged(attributes: { ourConversationId?: string; ourDeviceId?: number; ourNumber?: string; - ourUuid?: string; + ourUuid?: UUIDStringType; regionCode?: string; theme?: ThemeType; }): UserChangedActionType { @@ -81,7 +82,7 @@ export function getEmptyState(): UserStateType { tempPath: 'missing', ourConversationId: 'missing', ourDeviceId: 0, - ourUuid: 'missing', + ourUuid: '00000000-0000-4000-8000-000000000000', ourNumber: 'missing', regionCode: 'missing', platform: 'missing', diff --git a/ts/state/selectors/calling.ts b/ts/state/selectors/calling.ts index 1b5e23c315ff..cdf52c8ca15d 100644 --- a/ts/state/selectors/calling.ts +++ b/ts/state/selectors/calling.ts @@ -13,6 +13,7 @@ import type { import { getIncomingCall as getIncomingCallHelper } from '../ducks/calling'; import { getUserUuid } from './user'; import { getOwn } from '../../util/getOwn'; +import type { UUIDStringType } from '../../types/UUID'; export type CallStateType = DirectCallStateType | GroupCallStateType; @@ -61,7 +62,7 @@ export const getIncomingCall = createSelector( getUserUuid, ( callsByConversation: CallsByConversationType, - ourUuid: string + ourUuid: UUIDStringType ): undefined | DirectCallStateType | GroupCallStateType => getIncomingCallHelper(callsByConversation, ourUuid) ); diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 2c73f17a7585..4515b7bf8f64 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -27,6 +27,7 @@ import { filterAndSortConversationsByTitle } from '../../util/filterAndSortConve import type { ContactNameColorType } from '../../types/Colors'; import { ContactNameColors } from '../../types/Colors'; import type { AvatarDataType } from '../../types/Avatar'; +import type { UUIDStringType } from '../../types/UUID'; import { isInSystemContacts } from '../../util/isInSystemContacts'; import { sortByTitle } from '../../util/sortByTitle'; import { @@ -668,6 +669,12 @@ export const getConversationByIdSelector = createSelector( getOwn(conversationLookup, id) ); +export const getConversationByUuidSelector = createSelector( + getConversationsByUuid, + conversationsByUuid => (uuid: UUIDStringType): undefined | ConversationType => + getOwn(conversationsByUuid, uuid) +); + // A little optimization to reset our selector cache whenever high-level application data // changes: regionCode and userNumber. export const getCachedSelectorForMessage = createSelector( @@ -766,7 +773,7 @@ export const getMessageSelector = createSelector( conversationSelector: GetConversationByIdType, regionCode: string, ourNumber: string, - ourUuid: string, + ourUuid: UUIDStringType, ourConversationId: string, callSelector: CallSelectorType, activeCall: undefined | CallStateType, @@ -903,16 +910,13 @@ export const getConversationMessagesSelector = createSelector( ); export const getInvitedContactsForNewlyCreatedGroup = createSelector( - getConversationLookup, + getConversationsByUuid, getConversations, ( conversationLookup, - { invitedConversationIdsForNewlyCreatedGroup = [] } + { invitedUuidsForNewlyCreatedGroup = [] } ): Array => - deconstructLookup( - conversationLookup, - invitedConversationIdsForNewlyCreatedGroup - ) + deconstructLookup(conversationLookup, invitedUuidsForNewlyCreatedGroup) ); export const getConversationsWithCustomColorSelector = createSelector( @@ -962,7 +966,7 @@ export const getGroupAdminsSelector = createSelector( const admins: Array = []; memberships.forEach(membership => { if (membership.isAdmin) { - const admin = conversationSelector(membership.conversationId); + const admin = conversationSelector(membership.uuid); admins.push(admin); } }); diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index ec003d653de4..7953e9d7c9fe 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -37,6 +37,7 @@ import type { PropsType as ProfileChangeNotificationPropsType } from '../../comp import type { QuotedAttachmentType } from '../../components/conversation/Quote'; import { getDomain, isStickerPack } from '../../types/LinkPreview'; +import type { UUIDStringType } from '../../types/UUID'; import type { EmbeddedContactType } from '../../types/EmbeddedContact'; import { embeddedContactSelector } from '../../types/EmbeddedContact'; @@ -99,7 +100,7 @@ export type GetPropsForBubbleOptions = Readonly<{ conversationSelector: GetConversationByIdType; ourConversationId: string; ourNumber?: string; - ourUuid?: string; + ourUuid: UUIDStringType; selectedMessageId?: string; selectedMessageCounter?: number; regionCode: string; @@ -772,7 +773,7 @@ export function isGroupV2Change(message: MessageAttributesType): boolean { function getPropsForGroupV2Change( message: MessageAttributesType, - { conversationSelector, ourConversationId }: GetPropsForBubbleOptions + { conversationSelector, ourUuid }: GetPropsForBubbleOptions ): GroupsV2Props { const change = message.groupV2Change; @@ -784,7 +785,7 @@ function getPropsForGroupV2Change( return { groupName: conversation?.type === 'group' ? conversation?.name : undefined, - ourConversationId, + ourUuid, change, }; } @@ -806,7 +807,7 @@ function getPropsForGroupV1Migration( const droppedGV2MemberIds = message.droppedGV2MemberIds || []; const invitedMembers = invitedGV2Members.map(item => - conversationSelector(item.conversationId) + conversationSelector(item.uuid) ); const droppedMembers = droppedGV2MemberIds.map(conversationId => conversationSelector(conversationId) @@ -825,7 +826,7 @@ function getPropsForGroupV1Migration( invitedMembers: rawInvitedMembers, } = migration; const invitedMembers = rawInvitedMembers.map(item => - conversationSelector(item.conversationId) + conversationSelector(item.uuid) ); const droppedMembers = droppedMemberIds.map(conversationId => conversationSelector(conversationId) diff --git a/ts/state/selectors/user.ts b/ts/state/selectors/user.ts index 2ef5c60cdd19..8172c3f7da16 100644 --- a/ts/state/selectors/user.ts +++ b/ts/state/selectors/user.ts @@ -4,6 +4,7 @@ import { createSelector } from 'reselect'; import type { LocalizerType, ThemeType } from '../../types/Util'; +import type { UUIDStringType } from '../../types/UUID'; import type { StateType } from '../reducer'; import type { UserStateType } from '../ducks/user'; @@ -32,7 +33,7 @@ export const getUserConversationId = createSelector( export const getUserUuid = createSelector( getUser, - (state: UserStateType): string => state.ourUuid + (state: UserStateType): UUIDStringType => state.ourUuid ); export const getIntl = createSelector( diff --git a/ts/state/smart/CallManager.tsx b/ts/state/smart/CallManager.tsx index cbd42647318a..20edc11d6a40 100644 --- a/ts/state/smart/CallManager.tsx +++ b/ts/state/smart/CallManager.tsx @@ -17,6 +17,7 @@ import type { ActiveCallType, GroupCallRemoteParticipantType, } from '../../types/Calling'; +import type { UUIDStringType } from '../../types/UUID'; import { CallMode, CallState } from '../../types/Calling'; import type { StateType } from '../reducer'; import { missingCaseError } from '../../util/missingCaseError'; @@ -117,7 +118,7 @@ const mapStateToActiveCallProp = ( } const conversationSelectorByUuid = memoize< - (uuid: string) => undefined | ConversationType + (uuid: UUIDStringType) => undefined | ConversationType >(uuid => { const conversationId = window.ConversationController.ensureContactIds({ uuid, @@ -175,9 +176,9 @@ const mapStateToActiveCallProp = ( const { memberships = [] } = conversation; for (let i = 0; i < memberships.length; i += 1) { - const { conversationId } = memberships[i]; - const member = conversationSelectorByUuid(conversationId); + const { uuid } = memberships[i]; + const member = conversationSelector(uuid); if (!member) { log.error('Group member has no corresponding conversation'); continue; diff --git a/ts/state/smart/ContactModal.tsx b/ts/state/smart/ContactModal.tsx index 222a7319f736..21ebfab7e451 100644 --- a/ts/state/smart/ContactModal.tsx +++ b/ts/state/smart/ContactModal.tsx @@ -26,7 +26,7 @@ const mapStateToProps = (state: StateType): PropsDataType => { let isAdmin = false; if (contact && currentConversation && currentConversation.memberships) { currentConversation.memberships.forEach(membership => { - if (membership.conversationId === contact.id) { + if (membership.uuid === contact.uuid) { isMember = true; isAdmin = membership.isAdmin; } diff --git a/ts/state/smart/ConversationDetails.tsx b/ts/state/smart/ConversationDetails.tsx index 0c9c0389bf2e..705520add28a 100644 --- a/ts/state/smart/ConversationDetails.tsx +++ b/ts/state/smart/ConversationDetails.tsx @@ -10,6 +10,7 @@ import { ConversationDetails } from '../../components/conversation/conversation- import { getCandidateContactsForNewGroup, getConversationByIdSelector, + getConversationByUuidSelector, } from '../selectors/conversations'; import { getGroupMemberships } from '../../util/getGroupMemberships'; import { getIntl } from '../selectors/user'; @@ -67,6 +68,7 @@ const mapStateToProps = ( Boolean(conversation.groupLink) && conversation.accessControlAddFromInviteLink !== ACCESS_ENUM.UNSATISFIABLE; + const conversationByUuidSelector = getConversationByUuidSelector(state); return { ...props, canEditGroupInfo, @@ -74,7 +76,7 @@ const mapStateToProps = ( conversation, i18n: getIntl(state), isAdmin, - ...getGroupMemberships(conversation, conversationSelector), + ...getGroupMemberships(conversation, conversationByUuidSelector), userAvatarData: conversation.avatars || [], hasGroupLink, isGroup: conversation.type === 'group', diff --git a/ts/state/smart/PendingInvites.tsx b/ts/state/smart/PendingInvites.tsx index 36dca28d3658..bd4dc8892ec0 100644 --- a/ts/state/smart/PendingInvites.tsx +++ b/ts/state/smart/PendingInvites.tsx @@ -8,7 +8,10 @@ import { PendingInvites } from '../../components/conversation/conversation-detai import type { StateType } from '../reducer'; import { getIntl } from '../selectors/user'; -import { getConversationByIdSelector } from '../selectors/conversations'; +import { + getConversationByIdSelector, + getConversationByUuidSelector, +} from '../selectors/conversations'; import { getGroupMemberships } from '../../util/getGroupMemberships'; import { assert } from '../../util/assert'; @@ -24,6 +27,7 @@ const mapStateToProps = ( props: SmartPendingInvitesProps ): PropsType => { const conversationSelector = getConversationByIdSelector(state); + const conversationByUuidSelector = getConversationByUuidSelector(state); const conversation = conversationSelector(props.conversationId); assert( @@ -33,7 +37,7 @@ const mapStateToProps = ( return { ...props, - ...getGroupMemberships(conversation, conversationSelector), + ...getGroupMemberships(conversation, conversationByUuidSelector), conversation, i18n: getIntl(state), }; diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index 58c118a26fd9..fa2ee47edfc8 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -20,7 +20,7 @@ import type { ConversationType } from '../ducks/conversations'; import { getIntl } from '../selectors/user'; import { - getConversationByIdSelector, + getConversationByUuidSelector, getConversationMessagesSelector, getConversationSelector, getConversationsByTitleSelector, @@ -208,11 +208,11 @@ const getWarning = ( return undefined; } - const getConversationById = getConversationByIdSelector(state); + const getConversationByUuid = getConversationByUuidSelector(state); const { memberships } = getGroupMemberships( conversation, - getConversationById + getConversationByUuid ); const groupNameCollisions = getCollisionsFromMemberships(memberships); const hasGroupMembersWithSameName = !isEmpty(groupNameCollisions); @@ -244,7 +244,7 @@ const getContactSpoofingReview = ( } const conversationSelector = getConversationSelector(state); - const getConversationById = getConversationByIdSelector(state); + const getConversationByUuid = getConversationByUuidSelector(state); const currentConversation = conversationSelector(selectedConversationId); @@ -260,7 +260,7 @@ const getContactSpoofingReview = ( case ContactSpoofingType.MultipleGroupMembersWithSameTitle: { const { memberships } = getGroupMemberships( currentConversation, - getConversationById + getConversationByUuid ); const groupNameCollisions = getCollisionsFromMemberships(memberships); diff --git a/ts/test-both/conversations/isConversationTooBigToRing_test.ts b/ts/test-both/conversations/isConversationTooBigToRing_test.ts index 2dd42c0a2dc9..709f65559f17 100644 --- a/ts/test-both/conversations/isConversationTooBigToRing_test.ts +++ b/ts/test-both/conversations/isConversationTooBigToRing_test.ts @@ -4,8 +4,8 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; import { times } from 'lodash'; -import { v4 as uuid } from 'uuid'; import * as remoteConfig from '../../RemoteConfig'; +import { UUID } from '../../types/UUID'; import { isConversationTooBigToRing } from '../../conversations/isConversationTooBigToRing'; @@ -23,7 +23,7 @@ describe('isConversationTooBigToRing', () => { }); const fakeMemberships = (count: number) => - times(count, () => ({ conversationId: uuid(), isAdmin: false })); + times(count, () => ({ uuid: UUID.generate().toString(), isAdmin: false })); afterEach(() => { sinonSandbox.restore(); diff --git a/ts/test-both/helpers/getDefaultConversation.ts b/ts/test-both/helpers/getDefaultConversation.ts index 21aebdad8e59..83f34a149b1b 100644 --- a/ts/test-both/helpers/getDefaultConversation.ts +++ b/ts/test-both/helpers/getDefaultConversation.ts @@ -4,6 +4,8 @@ import { v4 as generateUuid } from 'uuid'; import { sample } from 'lodash'; import type { ConversationType } from '../../state/ducks/conversations'; +import { UUID } from '../../types/UUID'; +import type { UUIDStringType } from '../../types/UUID'; import { getRandomColor } from './getRandomColor'; const FIRST_NAMES = [ @@ -334,7 +336,17 @@ export function getDefaultConversation( sharedGroupNames: [], title: `${firstName} ${lastName}`, type: 'direct' as const, - uuid: generateUuid(), + uuid: UUID.generate().toString(), ...overrideProps, }; } + +export function getDefaultConversationWithUuid( + overrideProps: Partial = {}, + uuid: UUIDStringType = UUID.generate().toString() +): ConversationType & { uuid: UUIDStringType } { + return { + ...getDefaultConversation(overrideProps), + uuid, + }; +} diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index 2f62235a0ee0..ccb3a6e3eb88 100644 --- a/ts/test-both/state/selectors/conversations_test.ts +++ b/ts/test-both/state/selectors/conversations_test.ts @@ -48,8 +48,13 @@ import { noopAction } from '../../../state/ducks/noop'; import type { StateType } from '../../../state/reducer'; import { reducer as rootReducer } from '../../../state/reducer'; import { setupI18n } from '../../../util/setupI18n'; +import { UUID } from '../../../types/UUID'; +import type { UUIDStringType } from '../../../types/UUID'; import enMessages from '../../../../_locales/en/messages.json'; -import { getDefaultConversation } from '../../helpers/getDefaultConversation'; +import { + getDefaultConversation, + getDefaultConversationWithUuid, +} from '../../helpers/getDefaultConversation'; import { defaultStartDirectConversationComposerState, defaultChooseGroupMembersComposerState, @@ -69,6 +74,19 @@ describe('both/state/selectors/conversations', () => { }); } + function makeConversationWithUuid( + id: string + ): ConversationType & { uuid: UUIDStringType } { + return getDefaultConversationWithUuid( + { + id, + searchableTitle: `${id} title`, + title: `${id} title`, + }, + UUID.fromPrefix(id).toString() + ); + } + const i18n = setupI18n('en', enMessages); describe('#getConversationByIdSelector', () => { @@ -374,15 +392,17 @@ describe('both/state/selectors/conversations', () => { }); it('returns "hydrated" invited contacts', () => { + const abc = makeConversationWithUuid('abc'); + const def = makeConversationWithUuid('def'); const state = { ...getEmptyRootState(), conversations: { ...getEmptyState(), - conversationLookup: { - abc: makeConversation('abc'), - def: makeConversation('def'), + conversationsByUuid: { + [abc.uuid]: abc, + [def.uuid]: def, }, - invitedConversationIdsForNewlyCreatedGroup: ['def', 'abc'], + invitedUuidsForNewlyCreatedGroup: [def.uuid, abc.uuid], }, }; const result = getInvitedContactsForNewlyCreatedGroup(state); @@ -1826,23 +1846,17 @@ describe('both/state/selectors/conversations', () => { }); describe('#getContactNameColorSelector', () => { - function makeConversationWithUuid(id: string): ConversationType { - const convo = makeConversation(id); - convo.uuid = id; - return convo; - } - it('returns the right color order sorted by UUID ASC', () => { const group = makeConversation('group'); group.type = 'group'; group.sortedGroupMembers = [ - makeConversationWithUuid('zyx'), - makeConversationWithUuid('vut'), - makeConversationWithUuid('srq'), - makeConversationWithUuid('pon'), - makeConversationWithUuid('mlk'), - makeConversationWithUuid('jih'), - makeConversationWithUuid('gfe'), + makeConversationWithUuid('fff'), + makeConversationWithUuid('f00'), + makeConversationWithUuid('e00'), + makeConversationWithUuid('d00'), + makeConversationWithUuid('c00'), + makeConversationWithUuid('b00'), + makeConversationWithUuid('a00'), ]; const state = { ...getEmptyRootState(), @@ -1856,13 +1870,13 @@ describe('both/state/selectors/conversations', () => { const contactNameColorSelector = getContactNameColorSelector(state); - assert.equal(contactNameColorSelector('group', 'gfe'), '200'); - assert.equal(contactNameColorSelector('group', 'jih'), '120'); - assert.equal(contactNameColorSelector('group', 'mlk'), '300'); - assert.equal(contactNameColorSelector('group', 'pon'), '010'); - assert.equal(contactNameColorSelector('group', 'srq'), '210'); - assert.equal(contactNameColorSelector('group', 'vut'), '330'); - assert.equal(contactNameColorSelector('group', 'zyx'), '230'); + assert.equal(contactNameColorSelector('group', 'a00'), '200'); + assert.equal(contactNameColorSelector('group', 'b00'), '120'); + assert.equal(contactNameColorSelector('group', 'c00'), '300'); + assert.equal(contactNameColorSelector('group', 'd00'), '010'); + assert.equal(contactNameColorSelector('group', 'e00'), '210'); + assert.equal(contactNameColorSelector('group', 'f00'), '330'); + assert.equal(contactNameColorSelector('group', 'fff'), '230'); }); it('returns the right colors for direct conversation', () => { diff --git a/ts/test-both/state/selectors/search_test.ts b/ts/test-both/state/selectors/search_test.ts index b2df81340439..25c6bf3260dc 100644 --- a/ts/test-both/state/selectors/search_test.ts +++ b/ts/test-both/state/selectors/search_test.ts @@ -19,7 +19,11 @@ import { getSearchResults, } from '../../../state/selectors/search'; import { makeLookup } from '../../../util/makeLookup'; -import { getDefaultConversation } from '../../helpers/getDefaultConversation'; +import { UUID } from '../../../types/UUID'; +import { + getDefaultConversation, + getDefaultConversationWithUuid, +} from '../../helpers/getDefaultConversation'; import { ReadStatus } from '../../../messages/MessageReadStatus'; import type { StateType } from '../../../state/reducer'; @@ -52,7 +56,7 @@ describe('both/state/selectors/search', () => { received_at: NOW, sent_at: NOW, source: 'source', - sourceUuid: 'sourceUuid', + sourceUuid: UUID.generate().toString(), timestamp: NOW, type: 'incoming' as const, readStatus: ReadStatus.Read, @@ -125,10 +129,9 @@ describe('both/state/selectors/search', () => { it('returns incoming message', () => { const searchId = 'search-id'; - const fromId = 'from-id'; const toId = 'to-id'; - const from = getDefaultConversation({ id: fromId }); + const from = getDefaultConversationWithUuid(); const to = getDefaultConversation({ id: toId }); const state = { @@ -136,9 +139,12 @@ describe('both/state/selectors/search', () => { conversations: { ...getEmptyConversationState(), conversationLookup: { - [fromId]: from, + [from.id]: from, [toId]: to, }, + conversationsByUuid: { + [from.uuid]: from, + }, }, search: { ...getEmptySearchState(), @@ -146,7 +152,7 @@ describe('both/state/selectors/search', () => { [searchId]: { ...getDefaultMessage(searchId), type: 'incoming' as const, - sourceUuid: fromId, + sourceUuid: from.uuid, conversationId: toId, snippet: 'snippet', body: 'snippet', @@ -178,11 +184,10 @@ describe('both/state/selectors/search', () => { it('returns the correct "from" and "to" when sent to me', () => { const searchId = 'search-id'; - const fromId = 'from-id'; - const toId = fromId; const myId = 'my-id'; - const from = getDefaultConversation({ id: fromId }); + const from = getDefaultConversationWithUuid(); + const toId = from.uuid; const meAsRecipient = getDefaultConversation({ id: myId }); const state = { @@ -190,9 +195,12 @@ describe('both/state/selectors/search', () => { conversations: { ...getEmptyConversationState(), conversationLookup: { - [fromId]: from, + [from.id]: from, [myId]: meAsRecipient, }, + conversationsByUuid: { + [from.uuid]: from, + }, }, ourConversationId: myId, search: { @@ -201,7 +209,7 @@ describe('both/state/selectors/search', () => { [searchId]: { ...getDefaultMessage(searchId), type: 'incoming' as const, - sourceUuid: fromId, + sourceUuid: from.uuid, conversationId: toId, snippet: 'snippet', body: 'snippet', @@ -223,24 +231,26 @@ describe('both/state/selectors/search', () => { it('returns outgoing message and caches appropriately', () => { const searchId = 'search-id'; - const fromId = 'from-id'; const toId = 'to-id'; - const from = getDefaultConversation({ id: fromId }); + const from = getDefaultConversationWithUuid(); const to = getDefaultConversation({ id: toId }); const state = { ...getEmptyRootState(), user: { ...getEmptyUserState(), - ourConversationId: fromId, + ourConversationId: from.id, }, conversations: { ...getEmptyConversationState(), conversationLookup: { - [fromId]: from, + [from.id]: from, [toId]: to, }, + conversationsByUuid: { + [from.uuid]: from, + }, }, search: { ...getEmptySearchState(), @@ -293,9 +303,9 @@ describe('both/state/selectors/search', () => { ...state, conversations: { ...state.conversations, - conversationLookup: { - ...state.conversations.conversationLookup, - [fromId]: { + conversationsByUuid: { + ...state.conversations.conversationsByUuid, + [from.uuid]: { ...from, name: 'new-name', }, diff --git a/ts/test-both/types/UUID_test.ts b/ts/test-both/types/UUID_test.ts new file mode 100644 index 000000000000..0bb24b99dd0d --- /dev/null +++ b/ts/test-both/types/UUID_test.ts @@ -0,0 +1,33 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { isValidUuid } from '../../types/UUID'; + +describe('isValidUuid', () => { + const LOWERCASE_V4_UUID = '9cb737ce-2bb3-4c21-9fe0-d286caa0ca68'; + + it('returns false for non-strings', () => { + assert.isFalse(isValidUuid(undefined)); + assert.isFalse(isValidUuid(null)); + assert.isFalse(isValidUuid(1234)); + }); + + it('returns false for non-UUID strings', () => { + assert.isFalse(isValidUuid('')); + assert.isFalse(isValidUuid('hello world')); + assert.isFalse(isValidUuid(` ${LOWERCASE_V4_UUID}`)); + assert.isFalse(isValidUuid(`${LOWERCASE_V4_UUID} `)); + }); + + it("returns false for UUIDs that aren't version 4", () => { + assert.isFalse(isValidUuid('a200a6e0-d2d9-11eb-bda7-dd5936a30ddf')); + assert.isFalse(isValidUuid('2adb8b83-4f2c-55ca-a481-7f98b716e615')); + }); + + it('returns true for v4 UUIDs', () => { + assert.isTrue(isValidUuid(LOWERCASE_V4_UUID)); + assert.isTrue(isValidUuid(LOWERCASE_V4_UUID.toUpperCase())); + }); +}); diff --git a/ts/test-both/util/getGroupMemberships_test.ts b/ts/test-both/util/getGroupMemberships_test.ts index 8b7d2748562c..0d56a371e49f 100644 --- a/ts/test-both/util/getGroupMemberships_test.ts +++ b/ts/test-both/util/getGroupMemberships_test.ts @@ -3,30 +3,34 @@ import { assert } from 'chai'; import type { ConversationType } from '../../state/ducks/conversations'; -import { getDefaultConversation } from '../helpers/getDefaultConversation'; +import { UUID } from '../../types/UUID'; +import type { UUIDStringType } from '../../types/UUID'; +import { getDefaultConversationWithUuid } from '../helpers/getDefaultConversation'; import { getGroupMemberships } from '../../util/getGroupMemberships'; describe('getGroupMemberships', () => { - const normalConversation1 = getDefaultConversation(); - const normalConversation2 = getDefaultConversation(); - const unregisteredConversation = getDefaultConversation({ + const normalConversation1 = getDefaultConversationWithUuid(); + const normalConversation2 = getDefaultConversationWithUuid(); + const unregisteredConversation = getDefaultConversationWithUuid({ discoveredUnregisteredAt: Date.now(), }); - function getConversationById(id: string): undefined | ConversationType { + function getConversationByUuid( + uuid: UUIDStringType + ): undefined | ConversationType { return [ normalConversation1, normalConversation2, unregisteredConversation, - ].find(conversation => conversation.id === id); + ].find(conversation => conversation.uuid === uuid); } describe('memberships', () => { it('returns an empty array if passed undefined', () => { const conversation = {}; - const result = getGroupMemberships(conversation, getConversationById) + const result = getGroupMemberships(conversation, getConversationByUuid) .memberships; assert.isEmpty(result); @@ -35,7 +39,7 @@ describe('getGroupMemberships', () => { it('returns an empty array if passed an empty array', () => { const conversation = { memberships: [] }; - const result = getGroupMemberships(conversation, getConversationById) + const result = getGroupMemberships(conversation, getConversationByUuid) .memberships; assert.isEmpty(result); @@ -45,13 +49,13 @@ describe('getGroupMemberships', () => { const conversation = { memberships: [ { - conversationId: 'garbage', + uuid: UUID.generate().toString(), isAdmin: true, }, ], }; - const result = getGroupMemberships(conversation, getConversationById) + const result = getGroupMemberships(conversation, getConversationByUuid) .memberships; assert.isEmpty(result); @@ -61,13 +65,13 @@ describe('getGroupMemberships', () => { const conversation = { memberships: [ { - conversationId: unregisteredConversation.id, + uuid: unregisteredConversation.uuid, isAdmin: true, }, ], }; - const result = getGroupMemberships(conversation, getConversationById) + const result = getGroupMemberships(conversation, getConversationByUuid) .memberships; assert.lengthOf(result, 1); @@ -81,17 +85,17 @@ describe('getGroupMemberships', () => { const conversation = { memberships: [ { - conversationId: normalConversation2.id, + uuid: normalConversation2.uuid, isAdmin: false, }, { - conversationId: normalConversation1.id, + uuid: normalConversation1.uuid, isAdmin: true, }, ], }; - const result = getGroupMemberships(conversation, getConversationById) + const result = getGroupMemberships(conversation, getConversationByUuid) .memberships; assert.lengthOf(result, 2); @@ -110,7 +114,7 @@ describe('getGroupMemberships', () => { it('returns an empty array if passed undefined', () => { const conversation = {}; - const result = getGroupMemberships(conversation, getConversationById) + const result = getGroupMemberships(conversation, getConversationByUuid) .pendingApprovalMemberships; assert.isEmpty(result); @@ -119,7 +123,7 @@ describe('getGroupMemberships', () => { it('returns an empty array if passed an empty array', () => { const conversation = { pendingApprovalMemberships: [] }; - const result = getGroupMemberships(conversation, getConversationById) + const result = getGroupMemberships(conversation, getConversationByUuid) .pendingApprovalMemberships; assert.isEmpty(result); @@ -127,10 +131,10 @@ describe('getGroupMemberships', () => { it("filters out conversation IDs that don't exist", () => { const conversation = { - pendingApprovalMemberships: [{ conversationId: 'garbage' }], + pendingApprovalMemberships: [{ uuid: UUID.generate().toString() }], }; - const result = getGroupMemberships(conversation, getConversationById) + const result = getGroupMemberships(conversation, getConversationByUuid) .pendingApprovalMemberships; assert.isEmpty(result); @@ -138,12 +142,10 @@ describe('getGroupMemberships', () => { it('filters out unregistered conversations', () => { const conversation = { - pendingApprovalMemberships: [ - { conversationId: unregisteredConversation.id }, - ], + pendingApprovalMemberships: [{ uuid: unregisteredConversation.uuid }], }; - const result = getGroupMemberships(conversation, getConversationById) + const result = getGroupMemberships(conversation, getConversationByUuid) .pendingApprovalMemberships; assert.isEmpty(result); @@ -152,12 +154,12 @@ describe('getGroupMemberships', () => { it('hydrates pending-approval memberships', () => { const conversation = { pendingApprovalMemberships: [ - { conversationId: normalConversation2.id }, - { conversationId: normalConversation1.id }, + { uuid: normalConversation2.uuid }, + { uuid: normalConversation1.uuid }, ], }; - const result = getGroupMemberships(conversation, getConversationById) + const result = getGroupMemberships(conversation, getConversationByUuid) .pendingApprovalMemberships; assert.lengthOf(result, 2); @@ -170,7 +172,7 @@ describe('getGroupMemberships', () => { it('returns an empty array if passed undefined', () => { const conversation = {}; - const result = getGroupMemberships(conversation, getConversationById) + const result = getGroupMemberships(conversation, getConversationByUuid) .pendingMemberships; assert.isEmpty(result); @@ -179,7 +181,7 @@ describe('getGroupMemberships', () => { it('returns an empty array if passed an empty array', () => { const conversation = { pendingMemberships: [] }; - const result = getGroupMemberships(conversation, getConversationById) + const result = getGroupMemberships(conversation, getConversationByUuid) .pendingMemberships; assert.isEmpty(result); @@ -188,11 +190,14 @@ describe('getGroupMemberships', () => { it("filters out conversation IDs that don't exist", () => { const conversation = { pendingMemberships: [ - { conversationId: 'garbage', addedByUserId: normalConversation1.id }, + { + uuid: UUID.generate().toString(), + addedByUserId: normalConversation1.uuid, + }, ], }; - const result = getGroupMemberships(conversation, getConversationById) + const result = getGroupMemberships(conversation, getConversationByUuid) .pendingMemberships; assert.isEmpty(result); @@ -202,37 +207,40 @@ describe('getGroupMemberships', () => { const conversation = { pendingMemberships: [ { - conversationId: unregisteredConversation.id, - addedByUserId: normalConversation1.id, + uuid: unregisteredConversation.uuid, + addedByUserId: normalConversation1.uuid, }, ], }; - const result = getGroupMemberships(conversation, getConversationById) + const result = getGroupMemberships(conversation, getConversationByUuid) .pendingMemberships; assert.isEmpty(result); }); it('hydrates pending memberships', () => { + const abc = UUID.generate().toString(); + const xyz = UUID.generate().toString(); + const conversation = { pendingMemberships: [ - { conversationId: normalConversation2.id, addedByUserId: 'abc' }, - { conversationId: normalConversation1.id, addedByUserId: 'xyz' }, + { uuid: normalConversation2.uuid, addedByUserId: abc }, + { uuid: normalConversation1.uuid, addedByUserId: xyz }, ], }; - const result = getGroupMemberships(conversation, getConversationById) + const result = getGroupMemberships(conversation, getConversationByUuid) .pendingMemberships; assert.lengthOf(result, 2); assert.deepEqual(result[0], { member: normalConversation2, - metadata: { addedByUserId: 'abc' }, + metadata: { addedByUserId: abc }, }); assert.deepEqual(result[1], { member: normalConversation1, - metadata: { addedByUserId: 'xyz' }, + metadata: { addedByUserId: xyz }, }); }); }); diff --git a/ts/test-both/util/isValidGuid_test.ts b/ts/test-both/util/isValidGuid_test.ts deleted file mode 100644 index db2e56517708..000000000000 --- a/ts/test-both/util/isValidGuid_test.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { assert } from 'chai'; - -import { isValidGuid } from '../../util/isValidGuid'; - -describe('isValidGuid', () => { - const LOWERCASE_V4_UUID = '9cb737ce-2bb3-4c21-9fe0-d286caa0ca68'; - - it('returns false for non-strings', () => { - assert.isFalse(isValidGuid(undefined)); - assert.isFalse(isValidGuid(null)); - assert.isFalse(isValidGuid(1234)); - }); - - it('returns false for non-UUID strings', () => { - assert.isFalse(isValidGuid('')); - assert.isFalse(isValidGuid('hello world')); - assert.isFalse(isValidGuid(` ${LOWERCASE_V4_UUID}`)); - assert.isFalse(isValidGuid(`${LOWERCASE_V4_UUID} `)); - }); - - it("returns false for UUIDs that aren't version 4", () => { - assert.isFalse(isValidGuid('a200a6e0-d2d9-11eb-bda7-dd5936a30ddf')); - assert.isFalse(isValidGuid('2adb8b83-4f2c-55ca-a481-7f98b716e615')); - }); - - it('returns true for v4 UUIDs', () => { - assert.isTrue(isValidGuid(LOWERCASE_V4_UUID)); - assert.isTrue(isValidGuid(LOWERCASE_V4_UUID.toUpperCase())); - }); -}); diff --git a/ts/test-electron/SignalProtocolStore_test.ts b/ts/test-electron/SignalProtocolStore_test.ts index e6d65074675e..38312217a093 100644 --- a/ts/test-electron/SignalProtocolStore_test.ts +++ b/ts/test-electron/SignalProtocolStore_test.ts @@ -10,7 +10,6 @@ import { SenderKeyRecord, SessionRecord, } from '@signalapp/signal-client'; -import { v4 as getGuid } from 'uuid'; import { signal } from '../protobuf/compiled'; import { sessionStructureToBytes } from '../util/sessionTranslation'; @@ -36,8 +35,8 @@ const { } = signal.proto.storage; describe('SignalProtocolStore', () => { - const ourUuid = new UUID(getGuid()); - const theirUuid = new UUID(getGuid()); + const ourUuid = UUID.generate(); + const theirUuid = UUID.generate(); let store: SignalProtocolStore; let identityKey: KeyPairType; let testKey: KeyPairType; @@ -170,7 +169,7 @@ describe('SignalProtocolStore', () => { describe('senderKeys', () => { it('roundtrips in memory', async () => { - const distributionId = window.getGuid(); + const distributionId = UUID.generate().toString(); const expected = getSenderKeyRecord(); const deviceId = 1; @@ -200,7 +199,7 @@ describe('SignalProtocolStore', () => { }); it('roundtrips through database', async () => { - const distributionId = window.getGuid(); + const distributionId = UUID.generate().toString(); const expected = getSenderKeyRecord(); const deviceId = 1; diff --git a/ts/test-electron/models/conversations_test.ts b/ts/test-electron/models/conversations_test.ts index 7368b0b24387..05d3ee956b33 100644 --- a/ts/test-electron/models/conversations_test.ts +++ b/ts/test-electron/models/conversations_test.ts @@ -3,6 +3,7 @@ import { assert } from 'chai'; import { SendStatus } from '../../messages/MessageSendState'; +import { UUID } from '../../types/UUID'; describe('Conversations', () => { async function resetConversationController(): Promise { @@ -16,14 +17,14 @@ describe('Conversations', () => { it('updates lastMessage even in race conditions with db', async () => { const ourNumber = '+15550000000'; - const ourUuid = window.getGuid(); + const ourUuid = UUID.generate().toString(); // Creating a fake conversation const conversation = new window.Whisper.Conversation({ avatars: [], - id: window.getGuid(), + id: UUID.generate().toString(), e164: '+15551234567', - uuid: window.getGuid(), + uuid: UUID.generate().toString(), type: 'private', inbox_position: 0, isPinned: false, @@ -56,7 +57,7 @@ describe('Conversations', () => { hasAttachments: false, hasFileAttachments: false, hasVisualMediaAttachments: false, - id: window.getGuid(), + id: UUID.generate().toString(), received_at: now, sent_at: now, timestamp: now, diff --git a/ts/test-electron/models/messages_test.ts b/ts/test-electron/models/messages_test.ts index 7b19da134c05..9407183c1bf3 100644 --- a/ts/test-electron/models/messages_test.ts +++ b/ts/test-electron/models/messages_test.ts @@ -10,6 +10,7 @@ import MessageSender from '../../textsecure/SendMessage'; import type { WebAPIType } from '../../textsecure/WebAPI'; import type { CallbackResultType } from '../../textsecure/Types.d'; import type { StorageAccessType } from '../../types/Storage.d'; +import { UUID } from '../../types/UUID'; import { SignalService as Proto } from '../../protobuf'; describe('Message', () => { @@ -32,7 +33,7 @@ describe('Message', () => { const source = '+1 415-555-5555'; const me = '+14155555556'; - const ourUuid = window.getGuid(); + const ourUuid = UUID.generate().toString(); function createMessage(attrs: { [key: string]: unknown }) { const messages = new window.Whisper.MessageCollection(); @@ -138,7 +139,7 @@ describe('Message', () => { const fakeDataMessage = new Uint8Array(0); const conversation1Uuid = conversation1.get('uuid'); - const ignoredUuid = window.getGuid(); + const ignoredUuid = UUID.generate().toString(); if (!conversation1Uuid) { throw new Error('Test setup failed: conversation1 should have a UUID'); diff --git a/ts/test-electron/quill/mentions/completion_test.tsx b/ts/test-electron/quill/mentions/completion_test.tsx index c0dcbc9c1ae1..3e05db137b2f 100644 --- a/ts/test-electron/quill/mentions/completion_test.tsx +++ b/ts/test-electron/quill/mentions/completion_test.tsx @@ -12,11 +12,10 @@ import type { MentionCompletionOptions } from '../../../quill/mentions/completio import { MentionCompletion } from '../../../quill/mentions/completion'; import type { ConversationType } from '../../../state/ducks/conversations'; import { MemberRepository } from '../../../quill/memberRepository'; -import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; +import { getDefaultConversationWithUuid } from '../../../test-both/helpers/getDefaultConversation'; -const me: ConversationType = getDefaultConversation({ +const me: ConversationType = getDefaultConversationWithUuid({ id: '666777', - uuid: 'pqrstuv', title: 'Fred Savage', firstName: 'Fred', profileName: 'Fred S.', @@ -28,9 +27,8 @@ const me: ConversationType = getDefaultConversation({ }); const members: Array = [ - getDefaultConversation({ + getDefaultConversationWithUuid({ id: '555444', - uuid: 'abcdefg', title: 'Mahershala Ali', firstName: 'Mahershala', profileName: 'Mahershala A.', @@ -39,9 +37,8 @@ const members: Array = [ markedUnread: false, areWeAdmin: false, }), - getDefaultConversation({ + getDefaultConversationWithUuid({ id: '333222', - uuid: 'hijklmno', title: 'Shia LaBeouf', firstName: 'Shia', profileName: 'Shia L.', diff --git a/ts/test-electron/routineProfileRefresh_test.ts b/ts/test-electron/routineProfileRefresh_test.ts index 3ae119b1d1b6..efe1334fb853 100644 --- a/ts/test-electron/routineProfileRefresh_test.ts +++ b/ts/test-electron/routineProfileRefresh_test.ts @@ -2,10 +2,10 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as sinon from 'sinon'; -import { v4 as uuid } from 'uuid'; import { times } from 'lodash'; import { ConversationModel } from '../models/conversations'; import type { ConversationAttributesType } from '../model-types.d'; +import { UUID } from '../types/UUID'; import { routineProfileRefresh } from '../routineProfileRefresh'; import * as getProfileStub from '../util/getProfile'; @@ -26,12 +26,12 @@ describe('routineProfileRefresh', () => { overrideAttributes: Partial = {} ): ConversationModel { const result = new ConversationModel({ - accessKey: uuid(), + accessKey: UUID.generate().toString(), active_at: Date.now(), draftAttachments: [], draftBodyRanges: [], draftTimestamp: null, - id: uuid(), + id: UUID.generate().toString(), inbox_position: 0, isPinned: false, lastMessageDeletedForEveryone: false, @@ -43,7 +43,7 @@ describe('routineProfileRefresh', () => { messageRequestResponseType: 0, muteExpiresAt: 0, profileAvatar: undefined, - profileKeyCredential: uuid(), + profileKeyCredential: UUID.generate().toString(), profileKeyVersion: '', profileSharing: true, quotedMessageId: null, @@ -52,7 +52,7 @@ describe('routineProfileRefresh', () => { sharedGroupNames: [], timestamp: Date.now(), type: 'private', - uuid: uuid(), + uuid: UUID.generate().toString(), version: 2, ...overrideAttributes, }); @@ -85,7 +85,7 @@ describe('routineProfileRefresh', () => { await routineProfileRefresh({ allConversations: [conversation1, conversation2], - ourConversationId: uuid(), + ourConversationId: UUID.generate().toString(), storage, }); @@ -99,7 +99,7 @@ describe('routineProfileRefresh', () => { await routineProfileRefresh({ allConversations: [conversation1, conversation2], - ourConversationId: uuid(), + ourConversationId: UUID.generate().toString(), storage: makeStorage(), }); @@ -124,7 +124,7 @@ describe('routineProfileRefresh', () => { await routineProfileRefresh({ allConversations: [recentlyActive, inactive, neverActive], - ourConversationId: uuid(), + ourConversationId: UUID.generate().toString(), storage: makeStorage(), }); @@ -176,7 +176,7 @@ describe('routineProfileRefresh', () => { await routineProfileRefresh({ allConversations: [neverRefreshed, recentlyFetched], - ourConversationId: uuid(), + ourConversationId: UUID.generate().toString(), storage: makeStorage(), }); @@ -218,7 +218,7 @@ describe('routineProfileRefresh', () => { memberWhoHasRecentlyRefreshed, groupConversation, ], - ourConversationId: uuid(), + ourConversationId: UUID.generate().toString(), storage: makeStorage(), }); diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index ce0a989b3c21..c896e9c471c5 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -23,6 +23,8 @@ import { GroupCallConnectionState, GroupCallJoinState, } from '../../../types/Calling'; +import { UUID } from '../../../types/UUID'; +import type { UUIDStringType } from '../../../types/UUID'; describe('calling duck', () => { const stateWithDirectCall: CallingStateType = { @@ -68,6 +70,11 @@ describe('calling duck', () => { }, }; + const creatorUuid = UUID.generate().toString(); + const differentCreatorUuid = UUID.generate().toString(); + const remoteUuid = UUID.generate().toString(); + const ringerUuid = UUID.generate().toString(); + const stateWithGroupCall = { ...getEmptyState(), callsByConversation: { @@ -77,15 +84,15 @@ describe('calling duck', () => { connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, peekInfo: { - uuids: ['456'], - creatorUuid: '456', + uuids: [creatorUuid], + creatorUuid, eraId: 'xyz', maxDevices: 16, deviceCount: 1, }, remoteParticipants: [ { - uuid: '123', + uuid: remoteUuid, demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, @@ -107,7 +114,7 @@ describe('calling duck', () => { 'fake-group-call-conversation-id' ], ringId: BigInt(123), - ringerUuid: '789', + ringerUuid: UUID.generate().toString(), }, }, }; @@ -127,7 +134,7 @@ describe('calling duck', () => { }, }; - const ourUuid = 'ebf5fd79-9344-4ec1-b5c9-af463572caf5'; + const ourUuid = UUID.generate().toString(); const getEmptyRootState = () => { const rootState = rootReducer(undefined, noopAction()); @@ -607,7 +614,7 @@ describe('calling duck', () => { ], connectionState: GroupCallConnectionState.NotConnected, ringId: BigInt(123), - ringerUuid: '789', + ringerUuid: UUID.generate().toString(), }, }, }; @@ -899,15 +906,15 @@ describe('calling duck', () => { hasLocalAudio: true, hasLocalVideo: false, peekInfo: { - uuids: ['456'], - creatorUuid: '456', + uuids: [creatorUuid], + creatorUuid, eraId: 'xyz', maxDevices: 16, deviceCount: 1, }, remoteParticipants: [ { - uuid: '123', + uuid: remoteUuid, demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, @@ -927,15 +934,15 @@ describe('calling duck', () => { connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.Joining, peekInfo: { - uuids: ['456'], - creatorUuid: '456', + uuids: [creatorUuid], + creatorUuid, eraId: 'xyz', maxDevices: 16, deviceCount: 1, }, remoteParticipants: [ { - uuid: '123', + uuid: remoteUuid, demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, @@ -1022,7 +1029,7 @@ describe('calling duck', () => { }, remoteParticipants: [ { - uuid: '123', + uuid: remoteUuid, demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, @@ -1048,7 +1055,7 @@ describe('calling duck', () => { }, remoteParticipants: [ { - uuid: '123', + uuid: remoteUuid, demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, @@ -1071,7 +1078,7 @@ describe('calling duck', () => { 'fake-group-call-conversation-id' ], ringId: BigInt(456), - ringerUuid: '55addfd8-09ed-4f5b-b42e-01058898d13b', + ringerUuid, }, }, }; @@ -1090,7 +1097,7 @@ describe('calling duck', () => { }, remoteParticipants: [ { - uuid: '123', + uuid: remoteUuid, demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, @@ -1107,7 +1114,7 @@ describe('calling duck', () => { { callMode: CallMode.Group, ringId: BigInt(456), - ringerUuid: '55addfd8-09ed-4f5b-b42e-01058898d13b', + ringerUuid, } ); }); @@ -1122,7 +1129,7 @@ describe('calling duck', () => { 'fake-group-call-conversation-id' ], ringId: BigInt(456), - ringerUuid: '55addfd8-09ed-4f5b-b42e-01058898d13b', + ringerUuid, }, }, }; @@ -1141,7 +1148,7 @@ describe('calling duck', () => { }, remoteParticipants: [ { - uuid: '123', + uuid: remoteUuid, demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, @@ -1179,7 +1186,7 @@ describe('calling duck', () => { }, remoteParticipants: [ { - uuid: '123', + uuid: remoteUuid, demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, @@ -1210,7 +1217,7 @@ describe('calling duck', () => { }, remoteParticipants: [ { - uuid: '123', + uuid: remoteUuid, demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, @@ -1251,7 +1258,7 @@ describe('calling duck', () => { }, remoteParticipants: [ { - uuid: '123', + uuid: remoteUuid, demuxId: 456, hasRemoteAudio: false, hasRemoteVideo: true, @@ -1423,7 +1430,7 @@ describe('calling duck', () => { const action = receiveIncomingGroupCall({ conversationId: 'fake-group-call-conversation-id', ringId: BigInt(456), - ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96', + ringerUuid, }); const result = reducer(stateWithIncomingGroupCall, action); @@ -1446,7 +1453,7 @@ describe('calling duck', () => { const action = receiveIncomingGroupCall({ conversationId: 'fake-group-call-conversation-id', ringId: BigInt(456), - ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96', + ringerUuid, }); const result = reducer(state, action); @@ -1457,7 +1464,7 @@ describe('calling duck', () => { const action = receiveIncomingGroupCall({ conversationId: 'fake-group-call-conversation-id', ringId: BigInt(456), - ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96', + ringerUuid, }); const result = reducer(getEmptyState(), action); @@ -1475,7 +1482,7 @@ describe('calling duck', () => { }, remoteParticipants: [], ringId: BigInt(456), - ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96', + ringerUuid, } ); }); @@ -1484,7 +1491,7 @@ describe('calling duck', () => { const action = receiveIncomingGroupCall({ conversationId: 'fake-group-call-conversation-id', ringId: BigInt(456), - ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96', + ringerUuid, }); const result = reducer(stateWithGroupCall, action); @@ -1492,7 +1499,7 @@ describe('calling duck', () => { result.callsByConversation['fake-group-call-conversation-id'], { ringId: BigInt(456), - ringerUuid: '208b8ce6-3a73-48ee-9c8a-32e6196f6e96', + ringerUuid, } ); }); @@ -1644,15 +1651,15 @@ describe('calling duck', () => { connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, peekInfo: { - uuids: ['456'], - creatorUuid: '456', + uuids: [creatorUuid], + creatorUuid, eraId: 'xyz', maxDevices: 16, deviceCount: 1, }, remoteParticipants: [ { - uuid: '123', + uuid: remoteUuid, demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, @@ -1670,15 +1677,15 @@ describe('calling duck', () => { connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, peekInfo: { - uuids: ['456'], - creatorUuid: '456', + uuids: [creatorUuid], + creatorUuid, eraId: 'xyz', maxDevices: 16, deviceCount: 1, }, remoteParticipants: [ { - uuid: '123', + uuid: remoteUuid, demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, @@ -1734,7 +1741,7 @@ describe('calling duck', () => { peekInfo: undefined, remoteParticipants: [ { - uuid: '123', + uuid: remoteUuid, demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, @@ -1749,8 +1756,8 @@ describe('calling duck', () => { const call = result.callsByConversation['fake-group-call-conversation-id']; assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { - uuids: ['456'], - creatorUuid: '456', + uuids: [creatorUuid], + creatorUuid, eraId: 'xyz', maxDevices: 16, deviceCount: 1, @@ -1769,15 +1776,15 @@ describe('calling duck', () => { connectionState: GroupCallConnectionState.Connected, joinState: GroupCallJoinState.NotJoined, peekInfo: { - uuids: ['999'], - creatorUuid: '999', + uuids: [differentCreatorUuid], + creatorUuid: differentCreatorUuid, eraId: 'abc', maxDevices: 5, deviceCount: 1, }, remoteParticipants: [ { - uuid: '123', + uuid: remoteUuid, demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, @@ -1792,8 +1799,8 @@ describe('calling duck', () => { const call = result.callsByConversation['fake-group-call-conversation-id']; assert.deepEqual(call?.callMode === CallMode.Group && call.peekInfo, { - uuids: ['999'], - creatorUuid: '999', + uuids: [differentCreatorUuid], + creatorUuid: differentCreatorUuid, eraId: 'abc', maxDevices: 5, deviceCount: 1, @@ -1810,7 +1817,7 @@ describe('calling duck', () => { 'fake-group-call-conversation-id' ], ringId: BigInt(987), - ringerUuid: 'd59f05f7-3be8-4d44-a1e8-0d7cb5677ed8', + ringerUuid, }, }, }, @@ -1825,7 +1832,7 @@ describe('calling duck', () => { peekInfo: undefined, remoteParticipants: [ { - uuid: '123', + uuid: remoteUuid, demuxId: 123, hasRemoteAudio: true, hasRemoteVideo: true, @@ -1844,10 +1851,7 @@ describe('calling duck', () => { } assert.strictEqual(call.ringId, BigInt(987)); - assert.strictEqual( - call.ringerUuid, - 'd59f05f7-3be8-4d44-a1e8-0d7cb5677ed8' - ); + assert.strictEqual(call.ringerUuid, ringerUuid); }); }); @@ -2036,47 +2040,33 @@ describe('calling duck', () => { }); describe('isAnybodyElseInGroupCall', () => { - const fakePeekInfo = (uuids: Array) => ({ + const fakePeekInfo = (uuids: Array) => ({ uuids, maxDevices: 5, deviceCount: uuids.length, }); it('returns false if the peek info has no participants', () => { - assert.isFalse( - isAnybodyElseInGroupCall( - fakePeekInfo([]), - '2cd7b14c-3433-4b3c-9685-1ef1e2d26db2' - ) - ); + assert.isFalse(isAnybodyElseInGroupCall(fakePeekInfo([]), remoteUuid)); }); it('returns false if the peek info has one participant, you', () => { assert.isFalse( - isAnybodyElseInGroupCall( - fakePeekInfo(['2cd7b14c-3433-4b3c-9685-1ef1e2d26db2']), - '2cd7b14c-3433-4b3c-9685-1ef1e2d26db2' - ) + isAnybodyElseInGroupCall(fakePeekInfo([creatorUuid]), creatorUuid) ); }); it('returns true if the peek info has one participant, someone else', () => { assert.isTrue( - isAnybodyElseInGroupCall( - fakePeekInfo(['ca0ae16c-2936-4c68-86b1-a6f82e8fe67f']), - '2cd7b14c-3433-4b3c-9685-1ef1e2d26db2' - ) + isAnybodyElseInGroupCall(fakePeekInfo([creatorUuid]), remoteUuid) ); }); it('returns true if the peek info has two participants, you and someone else', () => { assert.isTrue( isAnybodyElseInGroupCall( - fakePeekInfo([ - 'ca0ae16c-2936-4c68-86b1-a6f82e8fe67f', - '2cd7b14c-3433-4b3c-9685-1ef1e2d26db2', - ]), - '2cd7b14c-3433-4b3c-9685-1ef1e2d26db2' + fakePeekInfo([creatorUuid, remoteUuid]), + remoteUuid ) ); }); diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 68e51d797f94..927d52bec19e 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -28,8 +28,12 @@ import { import { ReadStatus } from '../../../messages/MessageReadStatus'; import { ContactSpoofingType } from '../../../util/contactSpoofing'; import { CallMode } from '../../../types/Calling'; +import { UUID } from '../../../types/UUID'; import * as groups from '../../../groups'; -import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; +import { + getDefaultConversation, + getDefaultConversationWithUuid, +} from '../../../test-both/helpers/getDefaultConversation'; import { getDefaultAvatars } from '../../../types/Avatar'; import { defaultStartDirectConversationComposerState, @@ -40,7 +44,7 @@ import { const { cantAddContactToGroup, clearGroupCreationError, - clearInvitedConversationsForNewlyCreatedGroup, + clearInvitedUuidsForNewlyCreatedGroup, closeCantAddContactToGroupModal, closeContactSpoofingReview, closeMaximumGroupSizeModal, @@ -225,26 +229,24 @@ describe('both/state/ducks/conversations', () => { }); it('adds and removes uuid-only contact', () => { - const removed = getDefaultConversation({ + const removed = getDefaultConversationWithUuid({ id: 'id-removed', - uuid: 'uuid-removed', e164: undefined, }); const state = { ...getEmptyState(), conversationsByuuid: { - 'uuid-removed': removed, + [removed.uuid]: removed, }, }; - const added = getDefaultConversation({ + const added = getDefaultConversationWithUuid({ id: 'id-added', - uuid: 'uuid-added', e164: undefined, }); const expected = { - 'uuid-added': added, + [added.uuid]: added, }; const actual = updateConversationLookups(added, removed, state); @@ -307,6 +309,7 @@ describe('both/state/ducks/conversations', () => { const messageId = 'message-guid-1'; const messageIdTwo = 'message-guid-2'; const messageIdThree = 'message-guid-3'; + const sourceUuid = UUID.generate().toString(); function getDefaultMessage(id: string): MessageType { return { @@ -316,7 +319,7 @@ describe('both/state/ducks/conversations', () => { received_at: previousTime, sent_at: previousTime, source: 'source', - sourceUuid: 'sourceUuid', + sourceUuid, timestamp: previousTime, type: 'incoming' as const, readStatus: ReadStatus.Read, @@ -491,16 +494,19 @@ describe('both/state/ducks/conversations', () => { }); }); - describe('CLEAR_INVITED_CONVERSATIONS_FOR_NEWLY_CREATED_GROUP', () => { - it('clears the list of invited conversation IDs', () => { + describe('CLEAR_INVITED_UUIDS_FOR_NEWLY_CREATED_GROUP', () => { + it('clears the list of invited conversation UUIDs', () => { const state = { ...getEmptyState(), - invitedConversationIdsForNewlyCreatedGroup: ['abc123', 'def456'], + invitedUuidsForNewlyCreatedGroup: [ + UUID.generate().toString(), + UUID.generate().toString(), + ], }; - const action = clearInvitedConversationsForNewlyCreatedGroup(); + const action = clearInvitedUuidsForNewlyCreatedGroup(); const result = reducer(state, action); - assert.isUndefined(result.invitedConversationIdsForNewlyCreatedGroup); + assert.isUndefined(result.invitedUuidsForNewlyCreatedGroup); }); }); @@ -775,13 +781,14 @@ describe('both/state/ducks/conversations', () => { }); it('dispatches a CREATE_GROUP_FULFILLED event (which updates the newly-created conversation IDs), triggers a showConversation event and switches to the associated conversation on success', async () => { + const abc = UUID.fromPrefix('abc').toString(); createGroupStub.resolves({ id: '9876', get: (key: string) => { if (key !== 'pendingMembersV2') { throw new Error('This getter is not set up for this test'); } - return [{ conversationId: 'xyz999' }]; + return [{ uuid: abc }]; }, }); @@ -805,14 +812,12 @@ describe('both/state/ducks/conversations', () => { sinon.assert.calledWith(dispatch, { type: 'CREATE_GROUP_FULFILLED', - payload: { invitedConversationIds: ['xyz999'] }, + payload: { invitedUuids: [abc] }, }); const fulfilledAction = dispatch.getCall(1).args[0]; const result = reducer(conversationsState, fulfilledAction); - assert.deepEqual(result.invitedConversationIdsForNewlyCreatedGroup, [ - 'xyz999', - ]); + assert.deepEqual(result.invitedUuidsForNewlyCreatedGroup, [abc]); sinon.assert.calledWith(dispatch, { type: 'SWITCH_TO_ASSOCIATED_VIEW', @@ -1765,14 +1770,12 @@ describe('both/state/ducks/conversations', () => { }); describe('COLORS_CHANGED', () => { - const abc = getDefaultConversation({ + const abc = getDefaultConversationWithUuid({ id: 'abc', - uuid: 'abc', conversationColor: 'wintergreen', }); - const def = getDefaultConversation({ + const def = getDefaultConversationWithUuid({ id: 'def', - uuid: 'def', conversationColor: 'infrared', }); const ghi = getDefaultConversation({ @@ -1820,8 +1823,12 @@ describe('both/state/ducks/conversations', () => { assert.isUndefined(nextState.conversationLookup.def.conversationColor); assert.isUndefined(nextState.conversationLookup.ghi.conversationColor); assert.isUndefined(nextState.conversationLookup.jkl.conversationColor); - assert.isUndefined(nextState.conversationsByUuid.abc.conversationColor); - assert.isUndefined(nextState.conversationsByUuid.def.conversationColor); + assert.isUndefined( + nextState.conversationsByUuid[abc.uuid].conversationColor + ); + assert.isUndefined( + nextState.conversationsByUuid[def.uuid].conversationColor + ); assert.isUndefined(nextState.conversationsByE164.ghi.conversationColor); assert.isUndefined( nextState.conversationsByGroupId.jkl.conversationColor diff --git a/ts/test-electron/updateConversationsWithUuidLookup_test.ts b/ts/test-electron/updateConversationsWithUuidLookup_test.ts index 85da1a4f8310..c94cd53d12db 100644 --- a/ts/test-electron/updateConversationsWithUuidLookup_test.ts +++ b/ts/test-electron/updateConversationsWithUuidLookup_test.ts @@ -5,10 +5,10 @@ import { assert } from 'chai'; import sinon from 'sinon'; -import { v4 as uuid } from 'uuid'; import { ConversationModel } from '../models/conversations'; import type { ConversationAttributesType } from '../model-types.d'; import type SendMessage from '../textsecure/SendMessage'; +import { UUID } from '../types/UUID'; import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup'; @@ -41,7 +41,7 @@ describe('updateConversationsWithUuidLookup', () => { 'FakeConversationController is not set up for this case (E164 must be provided)' ); assert( - uuid, + uuidFromServer, 'FakeConversationController is not set up for this case (UUID must be provided)' ); assert( @@ -81,7 +81,7 @@ describe('updateConversationsWithUuidLookup', () => { attributes: Readonly> = {} ): ConversationModel { return new ConversationModel({ - id: uuid(), + id: UUID.generate().toString(), inbox_position: 0, isPinned: false, lastMessageDeletedForEveryone: false, @@ -128,7 +128,7 @@ describe('updateConversationsWithUuidLookup', () => { conversationController: new FakeConversationController(), conversations: [ createConversation(), - createConversation({ uuid: uuid() }), + createConversation({ uuid: UUID.generate().toString() }), ], messaging: fakeMessaging, }); @@ -140,11 +140,11 @@ describe('updateConversationsWithUuidLookup', () => { const conversation1 = createConversation({ e164: '+13215559876' }); const conversation2 = createConversation({ e164: '+16545559876', - uuid: 'should be overwritten', + uuid: UUID.generate().toString(), // should be overwritten }); - const uuid1 = uuid(); - const uuid2 = uuid(); + const uuid1 = UUID.generate().toString(); + const uuid2 = UUID.generate().toString(); fakeGetUuidsForE164s.resolves({ '+13215559876': uuid1, @@ -187,7 +187,7 @@ describe('updateConversationsWithUuidLookup', () => { }); it("doesn't mark conversations unregistered if we already had a UUID for them, even if the server doesn't return one", async () => { - const existingUuid = uuid(); + const existingUuid = UUID.generate().toString(); const conversation = createConversation({ e164: '+13215559876', uuid: existingUuid, diff --git a/ts/test-electron/util/encryptProfileData_test.ts b/ts/test-electron/util/encryptProfileData_test.ts index 88018643cbbb..f406bcdd79f0 100644 --- a/ts/test-electron/util/encryptProfileData_test.ts +++ b/ts/test-electron/util/encryptProfileData_test.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; -import { v4 as uuid } from 'uuid'; import * as Bytes from '../../Bytes'; import { @@ -11,6 +10,7 @@ import { decryptProfileName, decryptProfile, } from '../../Crypto'; +import { UUID } from '../../types/UUID'; import { encryptProfileData } from '../../util/encryptProfileData'; describe('encryptProfileData', () => { @@ -22,7 +22,7 @@ describe('encryptProfileData', () => { familyName: 'Kid', firstName: 'Zombie', profileKey: Bytes.toBase64(keyBuffer), - uuid: uuid(), + uuid: UUID.generate().toString(), // To satisfy TS acceptedMessageRequest: true, diff --git a/ts/test-node/quill/memberRepository_test.ts b/ts/test-node/quill/memberRepository_test.ts index 35fba40c96f0..944f64089260 100644 --- a/ts/test-node/quill/memberRepository_test.ts +++ b/ts/test-node/quill/memberRepository_test.ts @@ -5,11 +5,10 @@ import { assert } from 'chai'; import type { ConversationType } from '../../state/ducks/conversations'; import { MemberRepository } from '../../quill/memberRepository'; -import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; +import { getDefaultConversationWithUuid } from '../../test-both/helpers/getDefaultConversation'; -const memberMahershala: ConversationType = getDefaultConversation({ +const memberMahershala: ConversationType = getDefaultConversationWithUuid({ id: '555444', - uuid: 'abcdefg', title: 'Pal', firstName: 'Mahershala', profileName: 'Mr Ali', @@ -20,9 +19,8 @@ const memberMahershala: ConversationType = getDefaultConversation({ areWeAdmin: false, }); -const memberShia: ConversationType = getDefaultConversation({ +const memberShia: ConversationType = getDefaultConversationWithUuid({ id: '333222', - uuid: 'hijklmno', title: 'Buddy', firstName: 'Shia', profileName: 'Sr LaBeouf', @@ -35,9 +33,8 @@ const memberShia: ConversationType = getDefaultConversation({ const members: Array = [memberMahershala, memberShia]; -const singleMember: ConversationType = getDefaultConversation({ +const singleMember: ConversationType = getDefaultConversationWithUuid({ id: '666777', - uuid: 'pqrstuv', title: 'The Guy', firstName: 'Jeff', profileName: 'Jr Klaus', @@ -85,7 +82,7 @@ describe('MemberRepository', () => { it('returns a matched member', () => { const memberRepository = new MemberRepository(members); - assert.isDefined(memberRepository.getMemberByUuid('abcdefg')); + assert.isDefined(memberRepository.getMemberByUuid(memberMahershala.uuid)); }); it('returns undefined when it does not have the member', () => { diff --git a/ts/test-node/quill/mentions/matchers_test.ts b/ts/test-node/quill/mentions/matchers_test.ts index 07bf8994734c..2ead13b2ebcc 100644 --- a/ts/test-node/quill/mentions/matchers_test.ts +++ b/ts/test-node/quill/mentions/matchers_test.ts @@ -8,7 +8,7 @@ import Delta from 'quill-delta'; import { matchMention } from '../../../quill/mentions/matchers'; import { MemberRepository } from '../../../quill/memberRepository'; import type { ConversationType } from '../../../state/ducks/conversations'; -import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; +import { getDefaultConversationWithUuid } from '../../../test-both/helpers/getDefaultConversation'; class FakeTokenList extends Array { constructor(elements: Array) { @@ -38,9 +38,8 @@ const createMockMentionBlotElement = ( dataset: Record ): HTMLElement => createMockElement('mention-blot', dataset); -const memberMahershala: ConversationType = getDefaultConversation({ +const memberMahershala: ConversationType = getDefaultConversationWithUuid({ id: '555444', - uuid: 'abcdefg', title: 'Mahershala Ali', firstName: 'Mahershala', profileName: 'Mahershala A.', @@ -50,9 +49,8 @@ const memberMahershala: ConversationType = getDefaultConversation({ areWeAdmin: false, }); -const memberShia: ConversationType = getDefaultConversation({ +const memberShia: ConversationType = getDefaultConversationWithUuid({ id: '333222', - uuid: 'hijklmno', title: 'Shia LaBeouf', firstName: 'Shia', profileName: 'Shia L.', diff --git a/ts/test-node/sql_migrations_test.ts b/ts/test-node/sql_migrations_test.ts index fa47e9c1a2de..83b2eb1f7360 100644 --- a/ts/test-node/sql_migrations_test.ts +++ b/ts/test-node/sql_migrations_test.ts @@ -6,7 +6,8 @@ import type { Database } from 'better-sqlite3'; import SQL from 'better-sqlite3'; import { v4 as generateGuid } from 'uuid'; -import { SCHEMA_VERSIONS } from '../sql/Server'; +import { SCHEMA_VERSIONS } from '../sql/migrations'; +import { consoleLogger } from '../util/consoleLogger'; const OUR_UUID = generateGuid(); @@ -17,7 +18,7 @@ describe('SQL migrations test', () => { const startVersion = db.pragma('user_version', { simple: true }); for (const run of SCHEMA_VERSIONS) { - run(startVersion, db); + run(startVersion, db, consoleLogger); const currentVersion = db.pragma('user_version', { simple: true }); @@ -616,4 +617,157 @@ describe('SQL migrations test', () => { assert.sameDeepMembers(reactionMessageIds, [MESSAGE_ID_2, MESSAGE_ID_3]); }); }); + + describe('updateToSchemaVersion43', () => { + it('remaps conversation ids to UUIDs in groups and messages', () => { + updateToVersion(42); + + const UUID_A = generateGuid(); + const UUID_B = generateGuid(); + const UUID_C = generateGuid(); + + const rawConvoC = { + id: 'c', + uuid: UUID_C, + membersV2: [ + { conversationId: 'a', joinedAtVersion: 1 }, + { conversationId: 'b', joinedAtVersion: 2 }, + { conversationId: 'z', joinedAtVersion: 3 }, + ], + pendingMembersV2: [ + { conversationId: 'a', addedByUserId: 'b', timestamp: 4 }, + { conversationId: 'b', addedByUserId: UUID_A, timestamp: 5 }, + { conversationId: 'z', timestamp: 6 }, + ], + pendingAdminApprovalV2: [ + { conversationId: 'a', timestamp: 6 }, + { conversationId: 'b', timestamp: 7 }, + { conversationId: 'z', timestamp: 8 }, + ], + }; + + const CHANGE_TYPES = [ + 'member-add', + 'member-add-from-link', + 'member-add-from-admin-approval', + 'member-privilege', + 'member-remove', + 'pending-add-one', + 'admin-approval-add-one', + ]; + + const CHANGE_TYPES_WITH_INVITER = [ + 'member-add-from-invite', + 'pending-remove-one', + 'pending-remove-many', + 'admin-approval-remove-one', + ]; + + db.exec( + ` + INSERT INTO conversations + (id, uuid, json) + VALUES + ('a', '${UUID_A}', '${JSON.stringify({ id: 'a', uuid: UUID_A })}'), + ('b', '${UUID_B}', '${JSON.stringify({ id: 'b', uuid: UUID_B })}'), + ('c', '${UUID_C}', '${JSON.stringify(rawConvoC)}'); + + INSERT INTO messages + (id, json) + VALUES + ('m', '${JSON.stringify({ + id: 'm', + groupV2Change: { + from: 'a', + details: [ + ...CHANGE_TYPES.map(type => ({ type, conversationId: 'b' })), + ...CHANGE_TYPES_WITH_INVITER.map(type => { + return { type, conversationId: 'c', inviter: 'a' }; + }), + ], + }, + sourceUuid: 'a', + invitedGV2Members: [ + { + conversationId: 'b', + addedByUserId: 'c', + }, + ], + })}'), + ('n', '${JSON.stringify({ + id: 'n', + groupV2Change: { + from: 'not-found', + details: [], + }, + sourceUuid: 'a', + })}'); + ` + ); + + updateToVersion(43); + + const { members, json: convoJSON } = db + .prepare('SELECT members, json FROM conversations WHERE id = "c"') + .get(); + + assert.strictEqual(members, `${UUID_A} ${UUID_B}`); + assert.deepStrictEqual(JSON.parse(convoJSON), { + id: 'c', + uuid: UUID_C, + membersV2: [ + { uuid: UUID_A, joinedAtVersion: 1 }, + { uuid: UUID_B, joinedAtVersion: 2 }, + ], + pendingMembersV2: [ + { uuid: UUID_A, addedByUserId: UUID_B, timestamp: 4 }, + { uuid: UUID_B, addedByUserId: UUID_A, timestamp: 5 }, + ], + pendingAdminApprovalV2: [ + { uuid: UUID_A, timestamp: 6 }, + { uuid: UUID_B, timestamp: 7 }, + ], + }); + + const { json: messageMJSON } = db + .prepare('SELECT json FROM messages WHERE id = "m"') + .get(); + + assert.deepStrictEqual(JSON.parse(messageMJSON), { + id: 'm', + groupV2Change: { + from: UUID_A, + details: [ + ...CHANGE_TYPES.map(type => ({ type, uuid: UUID_B })), + ...CHANGE_TYPES_WITH_INVITER.map(type => { + return { + type, + uuid: UUID_C, + inviter: UUID_A, + }; + }), + ], + }, + sourceUuid: UUID_A, + invitedGV2Members: [ + { + uuid: UUID_B, + addedByUserId: UUID_C, + }, + ], + }); + + const { json: messageNJSON } = db + .prepare('SELECT json FROM messages WHERE id = "n"') + .get(); + + assert.deepStrictEqual(JSON.parse(messageNJSON), { + id: 'n', + groupV2Change: { + details: [], + }, + sourceUuid: UUID_A, + }); + }); + }); }); diff --git a/ts/textsecure/KeyChangeListener.ts b/ts/textsecure/KeyChangeListener.ts index 9e7996b05a1d..ddc6e9544b65 100644 --- a/ts/textsecure/KeyChangeListener.ts +++ b/ts/textsecure/KeyChangeListener.ts @@ -14,8 +14,8 @@ export function init(signalProtocolStore: SignalProtocolStore): void { ); conversation.addKeyChange(uuid); - const groups = await window.ConversationController.getAllGroupsInvolvingId( - conversation.id + const groups = await window.ConversationController.getAllGroupsInvolvingUuid( + uuid ); for (const group of groups) { group.addKeyChange(uuid); diff --git a/ts/textsecure/OutgoingMessage.ts b/ts/textsecure/OutgoingMessage.ts index 98ba9c2c660c..0a8782421ceb 100644 --- a/ts/textsecure/OutgoingMessage.ts +++ b/ts/textsecure/OutgoingMessage.ts @@ -37,7 +37,7 @@ import type { CallbackResultType, CustomError } from './Types.d'; import { isValidNumber } from '../types/PhoneNumber'; import { Address } from '../types/Address'; import { QualifiedAddress } from '../types/QualifiedAddress'; -import { UUID } from '../types/UUID'; +import { UUID, isValidUuid } from '../types/UUID'; import { Sessions, IdentityKeys } from '../LibSignalStores'; import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup'; import { getKeysForIdentifier } from './getKeysForIdentifier'; @@ -656,7 +656,7 @@ export default class OutgoingMessage { async sendToIdentifier(providedIdentifier: string): Promise { let identifier = providedIdentifier; try { - if (window.isValidGuid(identifier)) { + if (isValidUuid(identifier)) { // We're good! } else if (isValidNumber(identifier)) { if (!window.textsecure.messaging) { diff --git a/ts/types/UUID.ts b/ts/types/UUID.ts index 1f1efd330663..b88c4773aeb8 100644 --- a/ts/types/UUID.ts +++ b/ts/types/UUID.ts @@ -1,14 +1,21 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { v4 as generateUUID } from 'uuid'; + import { strictAssert } from '../util/assert'; -import { isValidGuid } from '../util/isValidGuid'; export type UUIDStringType = `${string}-${string}-${string}-${string}-${string}`; +export const isValidUuid = (value: unknown): value is UUIDStringType => + typeof value === 'string' && + /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test( + value + ); + export class UUID { constructor(protected readonly value: string) { - strictAssert(isValidGuid(value), `Invalid UUID: ${value}`); + strictAssert(isValidUuid(value), `Invalid UUID: ${value}`); } public toString(): UUIDStringType { @@ -41,4 +48,24 @@ export class UUID { ); return uuid; } + + public static generate(): UUID { + return new UUID(generateUUID()); + } + + public static cast(value: UUIDStringType): never; + public static cast(value: string): UUIDStringType; + + public static cast(value: string): UUIDStringType { + return new UUID(value.toLowerCase()).toString(); + } + + // For testing + public static fromPrefix(value: string): UUID { + let padded = value; + while (padded.length < 8) { + padded += '0'; + } + return new UUID(`${padded}-0000-4000-8000-${'0'.repeat(12)}`); + } } diff --git a/ts/util/assert.ts b/ts/util/assert.ts index a6fb957d21a0..2206de0c481b 100644 --- a/ts/util/assert.ts +++ b/ts/util/assert.ts @@ -31,3 +31,11 @@ export function strictAssert( throw new Error(message); } } + +/** + * Asserts that the type of value is not a promise. + * (Useful for database modules) + */ +export function assertSync(value: T extends Promise ? never : T): T { + return value; +} diff --git a/ts/util/getConversationMembers.ts b/ts/util/getConversationMembers.ts index 364702c8cdce..536444d3d057 100644 --- a/ts/util/getConversationMembers.ts +++ b/ts/util/getConversationMembers.ts @@ -3,6 +3,7 @@ import { compact } from 'lodash'; import type { ConversationAttributesType } from '../model-types.d'; +import type { UUIDStringType } from '../types/UUID'; import { isDirectConversation } from './whatTypeOfConversation'; export function getConversationMembers( @@ -15,7 +16,7 @@ export function getConversationMembers( if (conversationAttrs.membersV2) { const { includePendingMembers } = options; - const members: Array<{ conversationId: string }> = includePendingMembers + const members: Array<{ uuid: UUIDStringType }> = includePendingMembers ? [ ...(conversationAttrs.membersV2 || []), ...(conversationAttrs.pendingMembersV2 || []), @@ -24,9 +25,7 @@ export function getConversationMembers( return compact( members.map(member => { - const conversation = window.ConversationController.get( - member.conversationId - ); + const conversation = window.ConversationController.get(member.uuid); // In groups we won't sent to contacts we believe are unregistered if (conversation && conversation.isUnregistered()) { diff --git a/ts/util/getGroupMemberships.ts b/ts/util/getGroupMemberships.ts index 8cd2cd780b63..9de4627fea4c 100644 --- a/ts/util/getGroupMemberships.ts +++ b/ts/util/getGroupMemberships.ts @@ -7,6 +7,7 @@ import type { GroupV2RequestingMembership, } from '../components/conversation/conversation-details/PendingInvites'; import type { ConversationType } from '../state/ducks/conversations'; +import type { UUIDStringType } from '../types/UUID'; import { isConversationUnregistered } from './isConversationUnregistered'; export const getGroupMemberships = ( @@ -20,7 +21,7 @@ export const getGroupMemberships = ( 'memberships' | 'pendingApprovalMemberships' | 'pendingMemberships' > >, - getConversationById: (conversationId: string) => undefined | ConversationType + getConversationByUuid: (uuid: UUIDStringType) => undefined | ConversationType ): { memberships: Array; pendingApprovalMemberships: Array; @@ -28,7 +29,7 @@ export const getGroupMemberships = ( } => ({ memberships: memberships.reduce( (result: Array, membership) => { - const member = getConversationById(membership.conversationId); + const member = getConversationByUuid(membership.uuid); if (!member) { return result; } @@ -38,7 +39,7 @@ export const getGroupMemberships = ( ), pendingApprovalMemberships: pendingApprovalMemberships.reduce( (result: Array, membership) => { - const member = getConversationById(membership.conversationId); + const member = getConversationByUuid(membership.uuid); if (!member || isConversationUnregistered(member)) { return result; } @@ -48,7 +49,7 @@ export const getGroupMemberships = ( ), pendingMemberships: pendingMemberships.reduce( (result: Array, membership) => { - const member = getConversationById(membership.conversationId); + const member = getConversationByUuid(membership.uuid); if (!member || isConversationUnregistered(member)) { return result; } diff --git a/ts/util/isValidGuid.ts b/ts/util/isValidGuid.ts deleted file mode 100644 index 6009e296909a..000000000000 --- a/ts/util/isValidGuid.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Copyright 2017-2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -export const isValidGuid = (value: unknown): value is string => - typeof value === 'string' && - /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test( - value - ); diff --git a/ts/util/normalizeUuid.ts b/ts/util/normalizeUuid.ts index 263836cfc33b..5cef63511f62 100644 --- a/ts/util/normalizeUuid.ts +++ b/ts/util/normalizeUuid.ts @@ -1,12 +1,12 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { isValidUuid } from '../types/UUID'; import { assert } from './assert'; -import { isValidGuid } from './isValidGuid'; export function normalizeUuid(uuid: string, context: string): string { assert( - isValidGuid(uuid), + isValidUuid(uuid), `Normalizing invalid uuid: ${uuid} in context "${context}"` ); diff --git a/ts/util/sendToGroup.ts b/ts/util/sendToGroup.ts index 96c325e0b1c8..b6862d29d9a6 100644 --- a/ts/util/sendToGroup.ts +++ b/ts/util/sendToGroup.ts @@ -273,7 +273,7 @@ export async function sendToGroupViaSenderKey(options: { conversation.set({ senderKeyInfo: { createdAtDate: Date.now(), - distributionId: window.getGuid(), + distributionId: UUID.generate().toString(), memberDevices: [], }, }); diff --git a/ts/util/zkgroup.ts b/ts/util/zkgroup.ts index cd27dbcc8461..63568f743c5e 100644 --- a/ts/util/zkgroup.ts +++ b/ts/util/zkgroup.ts @@ -22,6 +22,8 @@ import { UuidCiphertext, } from 'zkgroup'; import * as Bytes from '../Bytes'; +import { UUID } from '../types/UUID'; +import type { UUIDStringType } from '../types/UUID'; export * from 'zkgroup'; @@ -63,7 +65,7 @@ export function decryptGroupBlob( export function decryptProfileKeyCredentialPresentation( clientZkGroupCipher: ClientZkGroupCipher, presentationBuffer: Uint8Array -): { profileKey: Uint8Array; uuid: string } { +): { profileKey: Uint8Array; uuid: UUIDStringType } { const presentation = new ProfileKeyCredentialPresentation( uint8ArrayToCompatArray(presentationBuffer) ); @@ -79,14 +81,14 @@ export function decryptProfileKeyCredentialPresentation( return { profileKey: compatArrayToUint8Array(profileKey.serialize()), - uuid, + uuid: UUID.cast(uuid), }; } export function decryptProfileKey( clientZkGroupCipher: ClientZkGroupCipher, profileKeyCiphertextBuffer: Uint8Array, - uuid: string + uuid: UUIDStringType ): Uint8Array { const profileKeyCiphertext = new ProfileKeyCiphertext( uint8ArrayToCompatArray(profileKeyCiphertextBuffer) @@ -113,7 +115,7 @@ export function decryptUuid( export function deriveProfileKeyVersion( profileKeyBase64: string, - uuid: string + uuid: UUIDStringType ): string { const profileKeyArray = base64ToCompatArray(profileKeyBase64); const profileKey = new ProfileKey(profileKeyArray); @@ -167,7 +169,7 @@ export function encryptGroupBlob( export function encryptUuid( clientZkGroupCipher: ClientZkGroupCipher, - uuidPlaintext: string + uuidPlaintext: UUIDStringType ): Uint8Array { const uuidCiphertext = clientZkGroupCipher.encryptUuid(uuidPlaintext); @@ -176,7 +178,7 @@ export function encryptUuid( export function generateProfileKeyCredentialRequest( clientZkProfileCipher: ClientZkProfileOperations, - uuid: string, + uuid: UUIDStringType, profileKeyBase64: string ): { context: ProfileKeyCredentialRequestContext; requestHex: string } { const profileKeyArray = base64ToCompatArray(profileKeyBase64); @@ -287,7 +289,7 @@ export function handleProfileKeyCredential( export function deriveProfileKeyCommitment( profileKeyBase64: string, - uuid: string + uuid: UUIDStringType ): string { const profileKeyArray = base64ToCompatArray(profileKeyBase64); const profileKey = new ProfileKey(profileKeyArray); diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 3c1bdae5f92a..178a2f823a2e 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -1258,7 +1258,7 @@ export class ConversationView extends window.Backbone.View { }); const invitedMemberIds = pendingMembersV2.map( - (item: GroupV2PendingMemberType) => item.conversationId + (item: GroupV2PendingMemberType) => item.uuid ); this.migrationDialog = new Whisper.ReactWrapperView({ diff --git a/ts/window.d.ts b/ts/window.d.ts index 59143440e472..93f3672fb158 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -102,7 +102,6 @@ import { SocketStatus } from './types/SocketStatus'; import SyncRequest from './textsecure/SyncRequest'; import { ConversationColorType, CustomColorType } from './types/Colors'; import { MessageController } from './util/MessageController'; -import { isValidGuid } from './util/isValidGuid'; import { StateType } from './state/reducer'; import { SystemTraySetting } from './types/SystemTraySetting'; import { UUID } from './types/UUID'; @@ -199,7 +198,6 @@ declare global { getBuildCreation: () => number; getEnvironment: typeof getEnvironment; getExpiration: () => string; - getGuid: () => string; getHostName: () => string; getInboxCollection: () => ConversationModelCollectionType; getInteractionMode: () => 'mouse' | 'keyboard'; @@ -219,7 +217,6 @@ declare global { isAfterVersion: (version: string, anotherVersion: string) => boolean; isBeforeVersion: (version: string, anotherVersion: string) => boolean; isFullScreen: () => boolean; - isValidGuid: typeof isValidGuid; libphonenumber: { util: { getRegionCodeForNumber: (number: string) => string;