From fe42ba093fe0123b68b8debc5bd4c509e9927004 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Wed, 22 May 2024 12:10:53 -0500 Subject: [PATCH] Export/import simple update messages Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> --- _locales/en/messages.json | 4 + protos/Backups.proto | 273 +++++++-- .../JoinedSignalNotification.stories.tsx | 19 + .../conversation/JoinedSignalNotification.tsx | 37 ++ ts/components/conversation/TimelineItem.tsx | 15 + ts/model-types.d.ts | 1 + ts/services/backups/export.ts | 529 +++++++++++------- ts/services/backups/import.ts | 417 ++++++++++++-- ts/state/selectors/message.ts | 29 +- .../backup_groupv2_notifications_test.ts | 100 +--- ts/test-electron/backup/helpers.ts | 148 +++++ ts/test-electron/backup/non_bubble_test.ts | 410 ++++++++++++++ ts/types/Payment.ts | 5 + ts/types/Stickers.ts | 3 + 14 files changed, 1604 insertions(+), 386 deletions(-) create mode 100644 ts/components/conversation/JoinedSignalNotification.stories.tsx create mode 100644 ts/components/conversation/JoinedSignalNotification.tsx create mode 100644 ts/test-electron/backup/helpers.ts create mode 100644 ts/test-electron/backup/non_bubble_test.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 7898a91357..5d6020a959 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1378,6 +1378,10 @@ "messageformat": "{sender} changed their phone number", "description": "Shown in timeline when a member of a conversation changes their phone number" }, + "icu:JoinedSignal--notification": { + "messageformat": "Contact joined Signal", + "description": "Shown in timeline when a contact joins Signal" + }, "icu:ConversationMerge--notification": { "messageformat": "{obsoleteConversationTitle} and {conversationTitle} are the same account. Your message history for both chats are here.", "description": "Shown when we've discovered that two local conversations are the same remote account in an unusual way" diff --git a/protos/Backups.proto b/protos/Backups.proto index bb5dfadcb9..261a8c2022 100644 --- a/protos/Backups.proto +++ b/protos/Backups.proto @@ -20,7 +20,7 @@ message BackupInfo { // 3. All ChatItems must appear in global Chat rendering order. // (The order in which they were received by the client.) // -// Recipients, Chats, Ad-hoc Calls, & StickerPacks can be in any order. +// Recipients, Chats, StickerPacks, and AdHocCalls can be in any order. // (But must respect rule 2.) // For example, Chats may all be together at the beginning, // or may each immediately precede its first ChatItem. @@ -31,6 +31,7 @@ message Frame { Chat chat = 3; ChatItem chatItem = 4; StickerPack stickerPack = 5; + AdHocCall adHocCall = 6; } } @@ -98,6 +99,7 @@ message Recipient { DistributionList distributionList = 4; Self self = 5; ReleaseNotes releaseNotes = 6; + CallLink callLink = 7; } } @@ -134,7 +136,83 @@ message Group { bool whitelisted = 2; bool hideStory = 3; StorySendMode storySendMode = 4; - string name = 5; + GroupSnapshot snapshot = 5; + + // These are simply plaintext copies of the groups proto from Groups.proto. + // They should be kept completely in-sync with Groups.proto. + // These exist to allow us to have the latest snapshot of a group during restoration without having to hit the network. + // We would use Groups.proto if we could, but we want a plaintext version to improve export readability. + // For documentation, defer to Groups.proto. The only name change is Group -> GroupSnapshot to avoid the naming conflict. + message GroupSnapshot { + bytes publicKey = 1; + GroupAttributeBlob title = 2; + GroupAttributeBlob description = 11; + string avatarUrl = 3; + GroupAttributeBlob disappearingMessagesTimer = 4; + AccessControl accessControl = 5; + uint32 version = 6; + repeated Member members = 7; + repeated MemberPendingProfileKey membersPendingProfileKey = 8; + repeated MemberPendingAdminApproval membersPendingAdminApproval = 9; + bytes inviteLinkPassword = 10; + bool announcements_only = 12; + repeated MemberBanned members_banned = 13; + } + + message GroupAttributeBlob { + oneof content { + string title = 1; + bytes avatar = 2; + uint32 disappearingMessagesDuration = 3; + string descriptionText = 4; + } + } + + message Member { + enum Role { + UNKNOWN = 0; + DEFAULT = 1; + ADMINISTRATOR = 2; + } + + bytes userId = 1; + Role role = 2; + bytes profileKey = 3; + reserved /*presentation*/ 4; // The field is deprecated in the context of static group state + uint32 joinedAtVersion = 5; + } + + message MemberPendingProfileKey { + Member member = 1; + bytes addedByUserId = 2; + uint64 timestamp = 3; + } + + message MemberPendingAdminApproval { + bytes userId = 1; + bytes profileKey = 2; + reserved /*presentation*/ 3; // The field is deprecated in the context of static group state + uint64 timestamp = 4; + } + + message MemberBanned { + bytes userId = 1; + uint64 timestamp = 2; + } + + message AccessControl { + enum AccessRequired { + UNKNOWN = 0; + ANY = 1; + MEMBER = 2; + ADMINISTRATOR = 3; + UNSATISFIABLE = 4; + } + + AccessRequired attributes = 1; + AccessRequired members = 2; + AccessRequired addFromInviteLink = 3; + } } message Self {} @@ -153,6 +231,41 @@ message Chat { FilePointer wallpaper = 9; } +/** + * Call Links have some associated data including a call, but unlike other recipients + * are not tied to threads because they do not have messages associated with them. + * + * note: + * - room id can be derived from the root key + * - the presence of an admin key means this user is a call admin + */ +message CallLink { + enum Restrictions { + UNKNOWN = 0; + NONE = 1; + ADMIN_APPROVAL = 2; + } + + bytes rootKey = 1; + optional bytes adminKey = 2; // Only present if the user is an admin + string name = 3; + Restrictions restrictions = 4; + uint64 expirationMs = 5; +} + +message AdHocCall { + enum State { + UNKNOWN_STATE = 0; + GENERIC = 1; + } + + uint64 callId = 1; + // Refers to a `CallLink` recipient. + uint64 recipientId = 2; + State state = 3; + uint64 callTimestamp = 4; +} + message DistributionList { enum PrivacyMode { UNKNOWN = 0; @@ -196,8 +309,8 @@ message ChatItem { uint64 chatId = 1; // conversation id uint64 authorId = 2; // recipient id uint64 dateSent = 3; - optional uint64 expireStartDate = 4; // timestamp of when expiration timer started ticking down - optional uint64 expiresInMs = 5; // how long timer of message is (ms) + uint64 expireStartDate = 4; // timestamp of when expiration timer started ticking down + uint64 expiresInMs = 5; // how long timer of message is (ms) repeated ChatItem revisions = 6; // ordered from oldest to newest bool sms = 7; @@ -213,6 +326,7 @@ message ChatItem { StickerMessage stickerMessage = 13; RemoteDeletedMessage remoteDeletedMessage = 14; ChatUpdateMessage updateMessage = 15; + PaymentNotification paymentNotification = 16; } } @@ -255,6 +369,54 @@ message ContactMessage { repeated Reaction reactions = 2; } +message PaymentNotification { + message TransactionDetails { + message MobileCoinTxoIdentification { // Used to map to payments on the ledger + repeated bytes publicKey = 1; // for received transactions + repeated bytes keyImages = 2; // for sent transactions + } + + message FailedTransaction { // Failed payments can't be synced from the ledger + enum FailureReason { + GENERIC = 0; + NETWORK = 1; + INSUFFICIENT_FUNDS = 2; + } + FailureReason reason = 1; + } + + message Transaction { + enum Status { + INITIAL = 0; + SUBMITTED = 1; + SUCCESSFUL = 2; + } + Status status = 1; + + // This identification is used to map the payment table to the ledger + // and is likely required otherwise we may have issues reconciling with + // the ledger + MobileCoinTxoIdentification mobileCoinIdentification = 2; + optional uint64 timestamp = 3; + optional uint64 blockIndex = 4; + optional uint64 blockTimestamp = 5; + optional bytes transaction = 6; // mobile coin blobs + optional bytes receipt = 7; // mobile coin blobs + } + + oneof payment { + Transaction transaction = 1; + FailedTransaction failedTransaction = 2; + } + } + + optional string amountMob = 1; // stored as a decimal string, e.g. 1.00001 + optional string feeMob = 2; // stored as a decimal string, e.g. 1.00001 + optional string note = 3; + TransactionDetails transactionDetails = 4; + +} + message ContactAttachment { message Name { optional string givenName = 1; @@ -481,73 +643,72 @@ message ChatUpdateMessage { ProfileChangeChatUpdate profileChange = 4; ThreadMergeChatUpdate threadMerge = 5; SessionSwitchoverChatUpdate sessionSwitchover = 6; - CallChatUpdate callingMessage = 7; + IndividualCall individualCall = 7; + GroupCall groupCall = 8; } } -message CallChatUpdate{ - Call call = 1; - - oneof chatUpdate { - IndividualCallChatUpdate callMessage = 2; - GroupCallChatUpdate groupCall = 3; - } -} - -message Call { +message IndividualCall { enum Type { UNKNOWN_TYPE = 0; AUDIO_CALL = 1; VIDEO_CALL = 2; - GROUP_CALL = 3; - AD_HOC_CALL = 4; + } + + enum Direction { + UNKNOWN_DIRECTION = 0; + INCOMING = 1; + OUTGOING = 2; } enum State { - UNKNOWN_EVENT = 0; - COMPLETED = 1; // A call that was successfully completed or was accepted and in-progress at the time of the backup. - DECLINED_BY_USER = 2; // An incoming call that was manually declined by the user. - DECLINED_BY_NOTIFICATION_PROFILE = 3; // An incoming call that was automatically declined by an active notification profile. - MISSED = 4; // An incoming call that either expired, was cancelled by the sender, or was auto-rejected due to already being in a different call. + UNKNOWN_STATE = 0; + ACCEPTED = 1; + NOT_ACCEPTED = 2; + // An incoming call that is no longer ongoing, which we neither accepted + // not actively declined. For example, it expired, was canceled by the + // sender, or was rejected due to being in another call. + MISSED = 3; + // We auto-declined an incoming call due to a notification profile. + MISSED_NOTIFICATION_PROFILE = 4; } - uint64 callId = 1; - uint64 conversationRecipientId = 2; - Type type = 3; - bool outgoing = 4; - uint64 timestamp = 5; - optional uint64 ringerRecipientId = 6; - State state = 7; + optional uint64 callId = 1; + Type type = 2; + Direction direction = 3; + State state = 4; + uint64 startedCallTimestamp = 5; } -message IndividualCallChatUpdate { - enum Type { - UNKNOWN = 0; - INCOMING_AUDIO_CALL = 1; - INCOMING_VIDEO_CALL = 2; - OUTGOING_AUDIO_CALL = 3; - OUTGOING_VIDEO_CALL = 4; - MISSED_INCOMING_AUDIO_CALL = 5; - MISSED_INCOMING_VIDEO_CALL = 6; - UNANSWERED_OUTGOING_AUDIO_CALL = 7; - UNANSWERED_OUTGOING_VIDEO_CALL = 8; +message GroupCall { + enum State { + UNKNOWN_STATE = 0; + // A group call was started without ringing. + GENERIC = 1; + // We joined a group call that was started without ringing. + JOINED = 2; + // An incoming group call is actively ringing. + RINGING = 3; + // We accepted an incoming group ring. + ACCEPTED = 4; + // We declined an incoming group ring. + DECLINED = 5; + // We missed an incoming group ring, for example because it expired. + MISSED = 6; + // We auto-declined an incoming group ring due to a notification profile. + MISSED_NOTIFICATION_PROFILE = 7; + // An outgoing ring was started. We don't track any state for outgoing rings + // beyond that they started. + OUTGOING_RING = 8; } - Type type = 1; -} - -message GroupCallChatUpdate { - enum LocalUserJoined { - UNKNOWN = 0; - JOINED = 1; - DID_NOT_JOIN = 2; - } - - optional bytes startedCallAci = 1; - uint64 startedCallTimestamp = 2; - repeated bytes inCallAcis = 3; - uint64 endedCallTimestamp = 4; // 0 indicates we do not know - LocalUserJoined localUserJoined = 5; + optional uint64 callId = 1; + State state = 2; + optional uint64 ringerRecipientId = 3; + optional uint64 startedCallRecipientId = 4; + uint64 startedCallTimestamp = 5; + // The time the call ended. 0 indicates an unknown time. + uint64 endedCallTimestamp = 6; } message SimpleChatUpdate { @@ -846,4 +1007,4 @@ message StickerPack { message StickerPackSticker { string emoji = 1; uint32 id = 2; -} \ No newline at end of file +} diff --git a/ts/components/conversation/JoinedSignalNotification.stories.tsx b/ts/components/conversation/JoinedSignalNotification.stories.tsx new file mode 100644 index 0000000000..956cde44c4 --- /dev/null +++ b/ts/components/conversation/JoinedSignalNotification.stories.tsx @@ -0,0 +1,19 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import type { Meta } from '@storybook/react'; +import { setupI18n } from '../../util/setupI18n'; +import enMessages from '../../../_locales/en/messages.json'; +import type { Props } from './JoinedSignalNotification'; +import { JoinedSignalNotification } from './JoinedSignalNotification'; + +export default { + title: 'Components/Conversation/JoinedSignalNotification', +} satisfies Meta; + +const i18n = setupI18n('en', enMessages); + +export function Default(): JSX.Element { + return ; +} diff --git a/ts/components/conversation/JoinedSignalNotification.tsx b/ts/components/conversation/JoinedSignalNotification.tsx new file mode 100644 index 0000000000..198eecb68a --- /dev/null +++ b/ts/components/conversation/JoinedSignalNotification.tsx @@ -0,0 +1,37 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import type { LocalizerType } from '../../types/Util'; +import { I18n } from '../I18n'; + +import { SystemMessage } from './SystemMessage'; +import { MessageTimestamp } from './MessageTimestamp'; + +export type PropsData = { + timestamp: number; +}; + +export type PropsHousekeeping = { + i18n: LocalizerType; +}; + +export type Props = PropsData & PropsHousekeeping; + +export function JoinedSignalNotification(props: Props): JSX.Element { + const { i18n, timestamp } = props; + + return ( + + +  ยท  + + + } + icon="info" + /> + ); +} diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index 4883a8f13e..235c4c88cf 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -20,6 +20,8 @@ import type { PropsDataType as DeliveryIssueProps } from './DeliveryIssueNotific import { DeliveryIssueNotification } from './DeliveryIssueNotification'; import type { PropsData as ChangeNumberNotificationProps } from './ChangeNumberNotification'; import { ChangeNumberNotification } from './ChangeNumberNotification'; +import type { PropsData as JoinedSignalNotificationProps } from './JoinedSignalNotification'; +import { JoinedSignalNotification } from './JoinedSignalNotification'; import type { PropsData as TitleTransitionNotificationProps } from './TitleTransitionNotification'; import { TitleTransitionNotification } from './TitleTransitionNotification'; import type { CallingNotificationType } from '../../util/callingNotification'; @@ -98,6 +100,10 @@ type ChangeNumberNotificationType = { type: 'changeNumberNotification'; data: ChangeNumberNotificationProps; }; +type JoinedSignalNotificationType = { + type: 'joinedSignalNotification'; + data: JoinedSignalNotificationProps; +}; type TitleTransitionNotificationType = { type: 'titleTransitionNotification'; data: TitleTransitionNotificationProps; @@ -156,6 +162,7 @@ export type TimelineItemType = ( | GroupNotificationType | GroupV1MigrationType | GroupV2ChangeType + | JoinedSignalNotificationType | MessageType | PhoneNumberDiscoveryNotificationType | ProfileChangeNotificationType @@ -321,6 +328,14 @@ export const TimelineItem = memo(function TimelineItem({ i18n={i18n} /> ); + } else if (item.type === 'joinedSignalNotification') { + notification = ( + + ); } else if (item.type === 'titleTransitionNotification') { notification = ( ; +type ToChatItemOptionsType = Readonly<{ + aboutMe: AboutMe; + callHistoryByCallId: Record; + backupLevel: BackupLevel; +}>; + +type NonBubbleOptionsType = Pick< + ToChatItemOptionsType, + 'aboutMe' | 'callHistoryByCallId' +> & + Readonly<{ + authorId: Long | undefined; + message: MessageAttributesType; + }>; + +enum NonBubbleResultKind { + Directed = 'Directed', + Directionless = 'Directionless', + Drop = 'Drop', +} + +type NonBubbleResultType = Readonly< + | { + kind: NonBubbleResultKind.Drop; + patch?: undefined; + } + | { + kind: NonBubbleResultKind.Directed | NonBubbleResultKind.Directionless; + patch: Backups.IChatItem; + } +>; + export class BackupExportStream extends Readable { private readonly backupTimeMs = getSafeLongFromTimestamp(Date.now()); private readonly convoIdToRecipientId = new Map(); @@ -572,11 +607,7 @@ export class BackupExportStream extends Readable { private async toChatItem( message: MessageAttributesType, - options: { - aboutMe: AboutMe; - callHistoryByCallId: Record; - backupLevel: BackupLevel; - } + { aboutMe, callHistoryByCallId, backupLevel }: ToChatItemOptionsType ): Promise { const chatId = this.getRecipientId({ id: message.conversationId }); if (chatId === undefined) { @@ -590,10 +621,8 @@ export class BackupExportStream extends Readable { const isIncoming = message.type === 'incoming'; if (isOutgoing) { - const ourAci = window.storage.user.getCheckedAci(); - authorId = this.getOrPushPrivateRecipient({ - serviceId: ourAci, + serviceId: aboutMe.aci, }); // Pacify typescript } else if (message.sourceServiceId) { @@ -636,136 +665,78 @@ export class BackupExportStream extends Readable { }; if (!isNormalBubble(message)) { - result.directionless = {}; - return this.toChatItemFromNonBubble(result, message, options); - } + const { patch, kind } = await this.toChatItemFromNonBubble({ + authorId, + message, + aboutMe, + callHistoryByCallId, + }); - // TODO (DESKTOP-6964): put incoming/outgoing fields below onto non-bubble messages - result.standardMessage = { - quote: await this.toQuote(message.quote), - attachments: message.attachments - ? await Promise.all( - message.attachments.map(attachment => { - return this.processMessageAttachment({ - attachment, - backupLevel: options.backupLevel, - }); - }) - ) - : undefined, - text: { - // Note that we store full text on the message model so we have to - // trim it before serializing. - body: message.body?.slice(0, LONG_ATTACHMENT_LIMIT), - bodyRanges: message.bodyRanges?.map(range => this.toBodyRange(range)), - }, - - linkPreview: message.preview?.map(preview => { - return { - url: preview.url, - title: preview.title, - description: preview.description, - date: getSafeLongFromTimestamp(preview.date), - }; - }), - reactions: message.reactions?.map(reaction => { - return { - emoji: reaction.emoji, - authorId: this.getOrPushPrivateRecipient({ - id: reaction.fromId, - }), - sentTimestamp: getSafeLongFromTimestamp(reaction.timestamp), - receivedTimestamp: getSafeLongFromTimestamp( - reaction.receivedAtDate ?? reaction.timestamp - ), - }; - }), - }; - - if (isOutgoing) { - const BackupSendStatus = Backups.SendStatus.Status; - - const sendStatus = new Array(); - const { sendStateByConversationId = {} } = message; - for (const [id, entry] of Object.entries(sendStateByConversationId)) { - const target = window.ConversationController.get(id); - if (!target) { - log.warn(`backups: no send target for a message ${message.sent_at}`); - continue; - } - - let deliveryStatus: Backups.SendStatus.Status; - switch (entry.status) { - case SendStatus.Pending: - deliveryStatus = BackupSendStatus.PENDING; - break; - case SendStatus.Sent: - deliveryStatus = BackupSendStatus.SENT; - break; - case SendStatus.Delivered: - deliveryStatus = BackupSendStatus.DELIVERED; - break; - case SendStatus.Read: - deliveryStatus = BackupSendStatus.READ; - break; - case SendStatus.Viewed: - deliveryStatus = BackupSendStatus.VIEWED; - break; - case SendStatus.Failed: - deliveryStatus = BackupSendStatus.FAILED; - break; - default: - throw missingCaseError(entry.status); - } - - sendStatus.push({ - recipientId: this.getOrPushPrivateRecipient(target.attributes), - lastStatusUpdateTimestamp: - entry.updatedAt != null - ? getSafeLongFromTimestamp(entry.updatedAt) - : null, - deliveryStatus, - }); + if (kind === NonBubbleResultKind.Drop) { + return undefined; } - result.outgoing = { - sendStatus, - }; - } else { - result.incoming = { - dateReceived: - message.received_at_ms != null - ? getSafeLongFromTimestamp(message.received_at_ms) - : null, - dateServerSent: - message.serverTimestamp != null - ? getSafeLongFromTimestamp(message.serverTimestamp) - : null, - read: Boolean(message.readAt), - }; + + if (kind === NonBubbleResultKind.Directed) { + strictAssert( + authorId, + 'Incoming/outgoing non-bubble messages require an author' + ); + const me = this.getOrPushPrivateRecipient({ + serviceId: aboutMe.aci, + }); + + if (authorId === me) { + result.outgoing = this.getOutgoingMessageDetails(message); + } else { + result.incoming = this.getIncomingMessageDetails(message); + } + } else if (kind === NonBubbleResultKind.Directionless) { + result.directionless = {}; + } else { + throw missingCaseError(kind); + } + + return { ...result, ...patch }; } - return result; - } - - // TODO(indutny): convert to bytes - private aciToBytes(aci: AciString | string): Uint8Array { - return Aci.parseFromServiceIdString(aci).getRawUuidBytes(); - } - private serviceIdToBytes(serviceId: ServiceIdString): Uint8Array { - return ServiceId.parseFromServiceIdString(serviceId).getRawUuidBytes(); - } - - private async toChatItemFromNonBubble( - chatItem: Backups.IChatItem, - message: MessageAttributesType, - options: { - aboutMe: AboutMe; - callHistoryByCallId: Record; - } - ): Promise { const { contact, sticker } = message; - - if (contact && contact[0]) { + if (message.isErased) { + result.remoteDeletedMessage = {}; + } else if (messageHasPaymentEvent(message)) { + const { payment } = message; + switch (payment.kind) { + case PaymentEventKind.ActivationRequest: { + result.updateMessage = { + simpleUpdate: { + type: Backups.SimpleChatUpdate.Type.PAYMENT_ACTIVATION_REQUEST, + }, + }; + break; + } + case PaymentEventKind.Activation: { + result.updateMessage = { + simpleUpdate: { + type: Backups.SimpleChatUpdate.Type.PAYMENTS_ACTIVATED, + }, + }; + break; + } + case PaymentEventKind.Notification: + result.paymentNotification = { + note: payment.note || undefined, + amountMob: payment.amountMob, + feeMob: payment.feeMob, + transactionDetails: payment.transactionDetailsBase64 + ? Backups.PaymentNotification.TransactionDetails.decode( + Bytes.fromBase64(payment.transactionDetailsBase64) + ) + : undefined, + }; + break; + default: + throw missingCaseError(payment); + } + } else if (contact && contact[0]) { const contactMessage = new Backups.ContactMessage(); // TODO (DESKTOP-6845): properly handle avatarUrlPath @@ -786,22 +757,13 @@ export class BackupExportStream extends Readable { })), })); - // TODO (DESKTOP-6964): add reactions - - // eslint-disable-next-line no-param-reassign - chatItem.contactMessage = contactMessage; - return chatItem; - } - - if (message.isErased) { - // eslint-disable-next-line no-param-reassign - chatItem.remoteDeletedMessage = new Backups.RemoteDeletedMessage(); - return chatItem; - } - - if (sticker) { - const stickerMessage = new Backups.StickerMessage(); + const reactions = this.getMessageReactions(message); + if (reactions != null) { + contactMessage.reactions = reactions; + } + result.contactMessage = contactMessage; + } else if (sticker) { const stickerProto = new Backups.Sticker(); stickerProto.emoji = sticker.emoji; stickerProto.packId = Bytes.fromHex(sticker.packId); @@ -809,31 +771,75 @@ export class BackupExportStream extends Readable { stickerProto.stickerId = sticker.stickerId; // TODO (DESKTOP-6845): properly handle data FilePointer - // TODO (DESKTOP-6964): add reactions + result.stickerMessage = { + sticker: stickerProto, + reactions: this.getMessageReactions(message), + }; + } else { + result.standardMessage = { + quote: await this.toQuote(message.quote), + attachments: message.attachments + ? await Promise.all( + message.attachments.map(attachment => { + return this.processMessageAttachment({ + attachment, + backupLevel, + }); + }) + ) + : undefined, + text: { + // Note that we store full text on the message model so we have to + // trim it before serializing. + body: message.body?.slice(0, LONG_ATTACHMENT_LIMIT), + bodyRanges: message.bodyRanges?.map(range => this.toBodyRange(range)), + }, - stickerMessage.sticker = stickerProto; - // eslint-disable-next-line no-param-reassign - chatItem.stickerMessage = stickerMessage; - - return chatItem; + linkPreview: message.preview?.map(preview => { + return { + url: preview.url, + title: preview.title, + description: preview.description, + date: getSafeLongFromTimestamp(preview.date), + }; + }), + reactions: this.getMessageReactions(message), + }; } - return this.toChatItemUpdate(chatItem, message, options); + if (isOutgoing) { + result.outgoing = this.getOutgoingMessageDetails(message); + } else { + result.incoming = this.getIncomingMessageDetails(message); + } + + return result; + } + + private aciToBytes(aci: AciString | string): Uint8Array { + return Aci.parseFromServiceIdString(aci).getRawUuidBytes(); + } + private serviceIdToBytes(serviceId: ServiceIdString): Uint8Array { + return ServiceId.parseFromServiceIdString(serviceId).getRawUuidBytes(); + } + + private async toChatItemFromNonBubble( + options: NonBubbleOptionsType + ): Promise { + return this.toChatItemUpdate(options); } async toChatItemUpdate( - chatItem: Backups.IChatItem, - message: MessageAttributesType, - options: { - aboutMe: AboutMe; - callHistoryByCallId: Record; - } - ): Promise { + options: NonBubbleOptionsType + ): Promise { + const { authorId, message } = options; const logId = `toChatItemUpdate(${getMessageIdForLogging(message)})`; const updateMessage = new Backups.ChatUpdateMessage(); - // eslint-disable-next-line no-param-reassign - chatItem.updateMessage = updateMessage; + + const patch: Backups.IChatItem = { + updateMessage, + }; if (isCallHistory(message)) { // TODO (DESKTOP-6964) @@ -956,15 +962,14 @@ export class BackupExportStream extends Readable { updateMessage.groupChange = groupChatUpdate; - return chatItem; + return { kind: NonBubbleResultKind.Directionless, patch }; } const source = message.expirationTimerUpdate?.sourceServiceId || message.expirationTimerUpdate?.source; - if (source && !chatItem.authorId) { - // eslint-disable-next-line no-param-reassign - chatItem.authorId = this.getOrPushPrivateRecipient({ + if (source && !authorId) { + patch.authorId = this.getOrPushPrivateRecipient({ id: source, }); } @@ -974,28 +979,42 @@ export class BackupExportStream extends Readable { updateMessage.expirationTimerChange = expirationTimerChange; - return chatItem; + return { kind: NonBubbleResultKind.Directionless, patch }; } if (isGroupV2Change(message)) { updateMessage.groupChange = await this.toGroupV2Update(message, options); - return chatItem; + return { kind: NonBubbleResultKind.Directionless, patch }; } if (isKeyChange(message)) { const simpleUpdate = new Backups.SimpleChatUpdate(); simpleUpdate.type = Backups.SimpleChatUpdate.Type.IDENTITY_UPDATE; + if (message.key_changed) { + // This will override authorId on the original chatItem + patch.authorId = this.getOrPushPrivateRecipient({ + id: message.key_changed, + }); + } + updateMessage.simpleUpdate = simpleUpdate; - return chatItem; + return { kind: NonBubbleResultKind.Directionless, patch }; } if (isProfileChange(message)) { const profileChange = new Backups.ProfileChangeChatUpdate(); if (!message.profileChange) { - return undefined; + return { kind: NonBubbleResultKind.Drop }; + } + + if (message.changedId) { + // This will override authorId on the original chatItem + patch.authorId = this.getOrPushPrivateRecipient({ + id: message.changedId, + }); } const { newName, oldName } = message.profileChange; @@ -1004,31 +1023,68 @@ export class BackupExportStream extends Readable { updateMessage.profileChange = profileChange; - return chatItem; + return { kind: NonBubbleResultKind.Directionless, patch }; } if (isVerifiedChange(message)) { - // TODO (DESKTOP-6964)): it can't be this simple if we show this in groups, right? + if (!message.verifiedChanged) { + throw new Error( + `${logId}: Message was verifiedChange, but missing verifiedChange!` + ); + } const simpleUpdate = new Backups.SimpleChatUpdate(); - simpleUpdate.type = Backups.SimpleChatUpdate.Type.IDENTITY_VERIFIED; + simpleUpdate.type = message.verified + ? Backups.SimpleChatUpdate.Type.IDENTITY_VERIFIED + : Backups.SimpleChatUpdate.Type.IDENTITY_DEFAULT; updateMessage.simpleUpdate = simpleUpdate; - return chatItem; + if (message.verifiedChanged) { + // This will override authorId on the original chatItem + patch.authorId = this.getOrPushPrivateRecipient({ + id: message.verifiedChanged, + }); + } + + return { kind: NonBubbleResultKind.Directionless, patch }; + } + + if (isChangeNumberNotification(message)) { + updateMessage.simpleUpdate = { + type: Backups.SimpleChatUpdate.Type.CHANGE_NUMBER, + }; + + return { kind: NonBubbleResultKind.Directionless, patch }; + } + + if (isJoinedSignalNotification(message)) { + updateMessage.simpleUpdate = { + type: Backups.SimpleChatUpdate.Type.JOINED_SIGNAL, + }; + + return { kind: NonBubbleResultKind.Directionless, patch }; + } + + if (isDeliveryIssue(message)) { + updateMessage.simpleUpdate = { + type: Backups.SimpleChatUpdate.Type.BAD_DECRYPT, + }; + + return { kind: NonBubbleResultKind.Directionless, patch }; } if (isConversationMerge(message)) { const threadMerge = new Backups.ThreadMergeChatUpdate(); const e164 = message.conversationMerge?.renderInfo.e164; if (!e164) { - return undefined; + return { kind: NonBubbleResultKind.Drop }; } threadMerge.previousE164 = Long.fromString(e164); updateMessage.threadMerge = threadMerge; - return chatItem; + return { kind: NonBubbleResultKind.Directionless, patch }; } if (isPhoneNumberDiscovery(message)) { @@ -1036,22 +1092,16 @@ export class BackupExportStream extends Readable { } if (isUniversalTimerNotification(message)) { - // TODO (DESKTOP-6964): need to add to protos + // Transient, drop it + return { kind: NonBubbleResultKind.Drop }; } if (isContactRemovedNotification(message)) { // TODO (DESKTOP-6964): this doesn't appear to be in the protos at all } - if (messageHasPaymentEvent(message)) { - // TODO (DESKTOP-6964): are these enough? - // SimpleChatUpdate - // PAYMENTS_ACTIVATED - // PAYMENT_ACTIVATION_REQUEST; - } - if (isGiftBadge(message)) { - // TODO (DESKTOP-6964) + // TODO (DESKTOP-6964): reuse quote's handling } if (isGroupUpdate(message)) { @@ -1060,6 +1110,12 @@ export class BackupExportStream extends Readable { // still want to render them } + if (isUnsupportedMessage(message)) { + // TODO (DESKTOP-6964): need to add to protos + } + + // TODO (DESKTOP-6964): session switchover + if (isGroupV1Migration(message)) { const { groupMigration } = message; @@ -1112,11 +1168,7 @@ export class BackupExportStream extends Readable { updateMessage.groupChange = groupChatUpdate; - return chatItem; - } - - if (isDeliveryIssue(message)) { - // TODO (DESKTOP-6964) + return { kind: NonBubbleResultKind.Directionless, patch }; } if (isEndSession(message)) { @@ -1125,7 +1177,7 @@ export class BackupExportStream extends Readable { updateMessage.simpleUpdate = simpleUpdate; - return chatItem; + return { kind: NonBubbleResultKind.Directionless, patch }; } if (isChatSessionRefreshed(message)) { @@ -1134,11 +1186,7 @@ export class BackupExportStream extends Readable { updateMessage.simpleUpdate = simpleUpdate; - return chatItem; - } - - if (isUnsupportedMessage(message)) { - // TODO (DESKTOP-6964): need to add to protos + return { kind: NonBubbleResultKind.Directionless, patch }; } throw new Error( @@ -1660,6 +1708,91 @@ export class BackupExportStream extends Readable { }); return filePointer; } + + private getMessageReactions({ + reactions, + }: MessageAttributesType): Array | undefined { + return reactions?.map(reaction => { + return { + emoji: reaction.emoji, + authorId: this.getOrPushPrivateRecipient({ + id: reaction.fromId, + }), + sentTimestamp: getSafeLongFromTimestamp(reaction.timestamp), + receivedTimestamp: getSafeLongFromTimestamp( + reaction.receivedAtDate ?? reaction.timestamp + ), + }; + }); + } + + private getIncomingMessageDetails({ + received_at_ms: receivedAtMs, + serverTimestamp, + readAt, + }: MessageAttributesType): Backups.ChatItem.IIncomingMessageDetails { + return { + dateReceived: + receivedAtMs != null ? getSafeLongFromTimestamp(receivedAtMs) : null, + dateServerSent: + serverTimestamp != null + ? getSafeLongFromTimestamp(serverTimestamp) + : null, + read: Boolean(readAt), + }; + } + + private getOutgoingMessageDetails({ + sent_at: sentAt, + sendStateByConversationId = {}, + }: MessageAttributesType): Backups.ChatItem.IOutgoingMessageDetails { + const BackupSendStatus = Backups.SendStatus.Status; + + const sendStatus = new Array(); + for (const [id, entry] of Object.entries(sendStateByConversationId)) { + const target = window.ConversationController.get(id); + if (!target) { + log.warn(`backups: no send target for a message ${sentAt}`); + continue; + } + + let deliveryStatus: Backups.SendStatus.Status; + switch (entry.status) { + case SendStatus.Pending: + deliveryStatus = BackupSendStatus.PENDING; + break; + case SendStatus.Sent: + deliveryStatus = BackupSendStatus.SENT; + break; + case SendStatus.Delivered: + deliveryStatus = BackupSendStatus.DELIVERED; + break; + case SendStatus.Read: + deliveryStatus = BackupSendStatus.READ; + break; + case SendStatus.Viewed: + deliveryStatus = BackupSendStatus.VIEWED; + break; + case SendStatus.Failed: + deliveryStatus = BackupSendStatus.FAILED; + break; + default: + throw missingCaseError(entry.status); + } + + sendStatus.push({ + recipientId: this.getOrPushPrivateRecipient(target.attributes), + lastStatusUpdateTimestamp: + entry.updatedAt != null + ? getSafeLongFromTimestamp(entry.updatedAt) + : null, + deliveryStatus, + }); + } + return { + sendStatus, + }; + } } function checkServiceIdEquivalence( diff --git a/ts/services/backups/import.ts b/ts/services/backups/import.ts index be8c1c5169..3f4729acc7 100644 --- a/ts/services/backups/import.ts +++ b/ts/services/backups/import.ts @@ -15,9 +15,19 @@ import type { ServiceIdString } from '../../types/ServiceId'; import { fromAciObject, fromPniObject } from '../../types/ServiceId'; import { isStoryDistributionId } from '../../types/StoryDistributionId'; import * as Errors from '../../types/errors'; +import { PaymentEventKind } from '../../types/Payment'; +import { + ContactFormType, + AddressType as ContactAddressType, +} from '../../types/EmbeddedContact'; +import { + STICKERPACK_ID_BYTE_LEN, + STICKERPACK_KEY_BYTE_LEN, +} from '../../types/Stickers'; import type { ConversationAttributesType, MessageAttributesType, + MessageReactionType, } from '../../model-types.d'; import { assertDev, strictAssert } from '../../util/assert'; import { getTimestampFromLong } from '../../util/timestampLongUtils'; @@ -46,6 +56,7 @@ import type { GroupV2ChangeDetailType } from '../../groups'; import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads'; import { drop } from '../../util/drop'; import { isNotNil } from '../../util/isNotNil'; +import { isGroup } from '../../util/whatTypeOfConversation'; import { convertFilePointerToAttachment } from './util/filePointers'; const MAX_CONCURRENCY = 10; @@ -80,6 +91,70 @@ async function processConversationOpBatch( await Data.updateConversations(updates); } +function phoneToContactFormType( + type: Backups.ContactAttachment.Phone.Type | null | undefined +): ContactFormType { + const { Type } = Backups.ContactAttachment.Phone; + switch (type) { + case Type.HOME: + return ContactFormType.HOME; + case Type.MOBILE: + return ContactFormType.MOBILE; + case Type.WORK: + return ContactFormType.WORK; + case Type.CUSTOM: + return ContactFormType.CUSTOM; + case undefined: + case null: + case Type.UNKNOWN: + return ContactFormType.HOME; + default: + throw missingCaseError(type); + } +} + +function emailToContactFormType( + type: Backups.ContactAttachment.Email.Type | null | undefined +): ContactFormType { + const { Type } = Backups.ContactAttachment.Email; + switch (type) { + case Type.HOME: + return ContactFormType.HOME; + case Type.MOBILE: + return ContactFormType.MOBILE; + case Type.WORK: + return ContactFormType.WORK; + case Type.CUSTOM: + return ContactFormType.CUSTOM; + case undefined: + case null: + case Type.UNKNOWN: + return ContactFormType.HOME; + default: + throw missingCaseError(type); + } +} + +function addressToContactAddressType( + type: Backups.ContactAttachment.PostalAddress.Type | null | undefined +): ContactAddressType { + const { Type } = Backups.ContactAttachment.PostalAddress; + switch (type) { + case Type.HOME: + return ContactAddressType.HOME; + case Type.WORK: + return ContactAddressType.WORK; + case Type.CUSTOM: + return ContactAddressType.CUSTOM; + case undefined: + case null: + case Type.UNKNOWN: + return ContactAddressType.HOME; + default: + throw missingCaseError(type); + } +} + export class BackupImportStream extends Writable { private parsedBackupInfo = false; private logId = 'BackupImportStream(unknown)'; @@ -740,6 +815,7 @@ export class BackupImportStream extends Writable { if (item.standardMessage) { // TODO (DESKTOP-6964): add revisions to editHistory + // gift badge attributes = { ...attributes, @@ -804,37 +880,43 @@ export class BackupImportStream extends Writable { return convertFilePointerToAttachment(attachment.pointer); }) .filter(isNotNil), - reactions: data.reactions?.map( - ({ emoji, authorId, sentTimestamp, receivedTimestamp }) => { - strictAssert(emoji != null, 'reaction must have an emoji'); - strictAssert(authorId != null, 'reaction must have authorId'); - strictAssert( - sentTimestamp != null, - 'reaction must have a sentTimestamp' - ); - strictAssert( - receivedTimestamp != null, - 'reaction must have a receivedTimestamp' - ); - - const authorConvo = this.recipientIdToConvo.get(authorId.toNumber()); - strictAssert( - authorConvo !== undefined, - 'author conversation not found' - ); - - return { - emoji, - fromId: authorConvo.id, - targetTimestamp: getTimestampFromLong(sentTimestamp), - receivedAtDate: getTimestampFromLong(receivedTimestamp), - timestamp: getTimestampFromLong(sentTimestamp), - }; - } - ), + reactions: this.fromReactions(data.reactions), }; } + private fromReactions( + reactions: ReadonlyArray | null | undefined + ): Array | undefined { + return reactions?.map( + ({ emoji, authorId, sentTimestamp, receivedTimestamp }) => { + strictAssert(emoji != null, 'reaction must have an emoji'); + strictAssert(authorId != null, 'reaction must have authorId'); + strictAssert( + sentTimestamp != null, + 'reaction must have a sentTimestamp' + ); + strictAssert( + receivedTimestamp != null, + 'reaction must have a receivedTimestamp' + ); + + const authorConvo = this.recipientIdToConvo.get(authorId.toNumber()); + strictAssert( + authorConvo !== undefined, + 'author conversation not found' + ); + + return { + emoji, + fromId: authorConvo.id, + targetTimestamp: getTimestampFromLong(sentTimestamp), + receivedAtDate: getTimestampFromLong(receivedTimestamp), + timestamp: getTimestampFromLong(sentTimestamp), + }; + } + ); + } + private async fromNonBubbleChatItem( chatItem: Backups.IChatItem, options: { @@ -851,23 +933,162 @@ export class BackupImportStream extends Writable { throw new Error(`${logId}: Got chat item with standardMessage set!`); } if (chatItem.contactMessage) { - // TODO (DESKTOP-6964) - } else if (chatItem.remoteDeletedMessage) { + return { + message: { + contact: (chatItem.contactMessage.contact ?? []).map(details => { + const { + name, + number, + email, + address, + // TODO (DESKTOP-6845): properly handle avatarUrlPath + organization, + } = details; + + return { + name: name + ? { + givenName: dropNull(name.givenName), + familyName: dropNull(name.familyName), + prefix: dropNull(name.prefix), + suffix: dropNull(name.suffix), + middleName: dropNull(name.middleName), + displayName: dropNull(name.displayName), + } + : undefined, + number: number?.length + ? number + .map(({ value, type, label }) => { + if (!value) { + return undefined; + } + + return { + value, + type: phoneToContactFormType(type), + label: dropNull(label), + }; + }) + .filter(isNotNil) + : undefined, + email: email?.length + ? email + .map(({ value, type, label }) => { + if (!value) { + return undefined; + } + + return { + value, + type: emailToContactFormType(type), + label: dropNull(label), + }; + }) + .filter(isNotNil) + : undefined, + address: address?.length + ? address.map(addr => { + const { + type, + label, + street, + pobox, + neighborhood, + city, + region, + postcode, + country, + } = addr; + + return { + type: addressToContactAddressType(type), + label: dropNull(label), + street: dropNull(street), + pobox: dropNull(pobox), + neighborhood: dropNull(neighborhood), + city: dropNull(city), + region: dropNull(region), + postcode: dropNull(postcode), + country: dropNull(country), + }; + }) + : undefined, + organization: dropNull(organization), + }; + }), + reactions: this.fromReactions(chatItem.contactMessage.reactions), + }, + additionalMessages: [], + }; + } + if (chatItem.remoteDeletedMessage) { return { message: { isErased: true, }, additionalMessages: [], }; - } else if (chatItem.stickerMessage) { - // TODO (DESKTOP-6964) - } else if (chatItem.updateMessage) { + } + if (chatItem.stickerMessage) { + strictAssert( + chatItem.stickerMessage.sticker != null, + 'stickerMessage must have a sticker' + ); + const { + stickerMessage: { + sticker: { emoji, packId, packKey, stickerId }, + }, + } = chatItem; + strictAssert(emoji != null, 'stickerMessage must have an emoji'); + strictAssert( + packId?.length === STICKERPACK_ID_BYTE_LEN, + 'stickerMessage must have a valid pack id' + ); + strictAssert( + packKey?.length === STICKERPACK_KEY_BYTE_LEN, + 'stickerMessage must have a valid pack key' + ); + strictAssert(stickerId != null, 'stickerMessage must have a sticker id'); + + return { + message: { + sticker: { + emoji, + packId: Bytes.toHex(packId), + packKey: Bytes.toBase64(packKey), + stickerId, + }, + reactions: this.fromReactions(chatItem.stickerMessage.reactions), + }, + additionalMessages: [], + }; + } + if (chatItem.paymentNotification) { + const { paymentNotification: notification } = chatItem; + return { + message: { + payment: { + kind: PaymentEventKind.Notification, + amountMob: dropNull(notification.amountMob), + feeMob: dropNull(notification.feeMob), + note: notification.note ?? null, + transactionDetailsBase64: notification.transactionDetails + ? Bytes.toBase64( + Backups.PaymentNotification.TransactionDetails.encode( + notification.transactionDetails + ).finish() + ) + : undefined, + }, + }, + additionalMessages: [], + }; + } + if (chatItem.updateMessage) { return this.fromChatItemUpdateMessage(chatItem.updateMessage, options); - } else { - throw new Error(`${logId}: Message was missing all five message types`); } - return undefined; + throw new Error(`${logId}: Message was missing all five message types`); } private async fromChatItemUpdateMessage( @@ -907,10 +1128,57 @@ export class BackupImportStream extends Writable { }; } + if (updateMessage.simpleUpdate) { + const message = await this.fromSimpleUpdateMessage( + updateMessage.simpleUpdate, + options + ); + if (!message) { + return undefined; + } + + return { + message, + additionalMessages: [], + }; + } + + if (updateMessage.profileChange) { + const { newName, previousName: oldName } = updateMessage.profileChange; + strictAssert(newName != null, 'profileChange must have a new name'); + strictAssert(oldName != null, 'profileChange must have an old name'); + return { + message: { + type: 'profile-change', + changedId: author?.id, + profileChange: { + type: 'name', + oldName, + newName, + }, + }, + additionalMessages: [], + }; + } + + if (updateMessage.threadMerge) { + const { previousE164 } = updateMessage.threadMerge; + strictAssert(previousE164 != null, 'threadMerge must have an old e164'); + return { + message: { + type: 'conversation-merge', + conversationMerge: { + renderInfo: { + type: 'private', + e164: `+${previousE164}`, + }, + }, + }, + additionalMessages: [], + }; + } + // TODO (DESKTOP-6964): check these fields - // updateMessage.simpleUpdate - // updateMessage.profileChange - // updateMessage.threadMerge // updateMessage.sessionSwitchover // updateMessage.callingMessage @@ -1506,4 +1774,75 @@ export class BackupImportStream extends Writable { additionalMessages, }; } + + private async fromSimpleUpdateMessage( + simpleUpdate: Backups.ISimpleChatUpdate, + { + author, + conversation, + }: { + author?: ConversationAttributesType; + conversation: ConversationAttributesType; + } + ): Promise | undefined> { + const { Type } = Backups.SimpleChatUpdate; + switch (simpleUpdate.type) { + case Type.END_SESSION: + return { + flags: SignalService.DataMessage.Flags.END_SESSION, + }; + case Type.CHAT_SESSION_REFRESH: + return { + type: 'chat-session-refreshed', + }; + case Type.IDENTITY_UPDATE: + return { + type: 'keychange', + key_changed: isGroup(conversation) ? author?.id : undefined, + }; + case Type.IDENTITY_VERIFIED: + strictAssert(author != null, 'IDENTITY_VERIFIED must have an author'); + return { + type: 'verified-change', + verifiedChanged: author.id, + verified: true, + }; + case Type.IDENTITY_DEFAULT: + strictAssert(author != null, 'IDENTITY_UNVERIFIED must have an author'); + return { + type: 'verified-change', + verifiedChanged: author.id, + verified: false, + }; + case Type.CHANGE_NUMBER: + return { + type: 'change-number-notification', + }; + case Type.JOINED_SIGNAL: + return { + type: 'joined-signal-notification', + }; + case Type.BAD_DECRYPT: + return { + type: 'delivery-issue', + }; + case Type.BOOST_REQUEST: + log.warn('backups: dropping boost request from release notes'); + return undefined; + case Type.PAYMENTS_ACTIVATED: + return { + payment: { + kind: PaymentEventKind.Activation, + }, + }; + case Type.PAYMENT_ACTIVATION_REQUEST: + return { + payment: { + kind: PaymentEventKind.ActivationRequest, + }, + }; + default: + throw new Error('Not implemented'); + } + } } diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 352502514a..0cebf1c2e4 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -26,6 +26,7 @@ import type { PropsData as TimelineMessagePropsData } from '../../components/con import { TextDirection } from '../../components/conversation/Message'; import type { PropsData as TimerNotificationProps } from '../../components/conversation/TimerNotification'; import type { PropsData as ChangeNumberNotificationProps } from '../../components/conversation/ChangeNumberNotification'; +import type { PropsData as JoinedSignalNotificationProps } from '../../components/conversation/JoinedSignalNotification'; import type { PropsData as SafetyNumberNotificationProps } from '../../components/conversation/SafetyNumberNotification'; import type { PropsData as VerificationNotificationProps } from '../../components/conversation/VerificationNotification'; import type { PropsData as TitleTransitionNotificationProps } from '../../components/conversation/TitleTransitionNotification'; @@ -925,6 +926,13 @@ export function getPropsForBubble( timestamp, }; } + if (isJoinedSignalNotification(message)) { + return { + type: 'joinedSignalNotification', + data: getPropsForJoinedSignalNotification(message), + timestamp, + }; + } if (isTitleTransitionNotification(message)) { return { type: 'titleTransitionNotification', @@ -1006,7 +1014,10 @@ export function isNormalBubble(message: MessageWithUIFieldsType): boolean { !isProfileChange(message) && !isUniversalTimerNotification(message) && !isUnsupportedMessage(message) && - !isVerifiedChange(message) + !isVerifiedChange(message) && + !isChangeNumberNotification(message) && + !isJoinedSignalNotification(message) && + !isDeliveryIssue(message) ); } @@ -1560,6 +1571,22 @@ function getPropsForChangeNumberNotification( }; } +// Joined Signal Notification + +export function isJoinedSignalNotification( + message: MessageWithUIFieldsType +): boolean { + return message.type === 'joined-signal-notification'; +} + +function getPropsForJoinedSignalNotification( + message: MessageWithUIFieldsType +): JoinedSignalNotificationProps { + return { + timestamp: message.sent_at, + }; +} + // Title Transition Notification export function isTitleTransitionNotification( diff --git a/ts/test-electron/backup/backup_groupv2_notifications_test.ts b/ts/test-electron/backup/backup_groupv2_notifications_test.ts index 439a8b8e3f..62c656f306 100644 --- a/ts/test-electron/backup/backup_groupv2_notifications_test.ts +++ b/ts/test-electron/backup/backup_groupv2_notifications_test.ts @@ -1,20 +1,12 @@ // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import path from 'path'; -import { tmpdir } from 'os'; -import { createReadStream } from 'fs'; -import { mkdtemp, rm } from 'fs/promises'; - import { v4 as generateGuid } from 'uuid'; -import { assert } from 'chai'; -import { pick, sortBy } from 'lodash'; import Data from '../../sql/Client'; -import { backupsService } from '../../services/backups'; -import { generateAci, generatePni } from '../../types/ServiceId'; import { SignalService as Proto } from '../../protobuf'; +import { generateAci, generatePni } from '../../types/ServiceId'; import type { MessageAttributesType } from '../../model-types'; import type { GroupV2ChangeType } from '../../groups'; import { getRandomBytes } from '../../Crypto'; @@ -22,6 +14,13 @@ import * as Bytes from '../../Bytes'; import { loadCallsHistory } from '../../services/callHistoryLoader'; import { strictAssert } from '../../util/assert'; import { DurationInSeconds } from '../../util/durations'; +import { + OUR_ACI, + OUR_PNI, + setupBasics, + asymmetricRoundtripHarness, + symmetricRoundtripHarness, +} from './helpers'; // Note: this should be kept up to date with GroupV2Change.stories.tsx, to // maintain the comprehensive set of GroupV2 notifications we need to handle @@ -30,8 +29,6 @@ const AccessControlEnum = Proto.AccessControl.AccessRequired; const RoleEnum = Proto.Member.Role; const EXPIRATION_TIMER_FLAG = Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE; -const OUR_ACI = generateAci(); -const OUR_PNI = generatePni(); const CONTACT_A = generateAci(); const CONTACT_A_PNI = generatePni(); const CONTACT_B = generateAci(); @@ -40,82 +37,6 @@ const ADMIN_A = generateAci(); const INVITEE_A = generateAci(); const GROUP_ID = Bytes.toBase64(getRandomBytes(32)); -const MASTER_KEY = Bytes.toBase64(getRandomBytes(32)); -const PROFILEKEY = getRandomBytes(32); - -// We need to eliminate fields that won't stay stable through import/export -function sortAndNormalize( - messages: Array -): Array> { - return sortBy(messages, 'sent_at').map(message => - pick( - message, - 'droppedGV2MemberIds', - 'expirationTimerUpdate', - 'groupMigration', - 'groupV2Change', - 'invitedGV2Members', - 'sent_at', - 'timestamp', - 'type' - ) - ); -} - -async function symmetricRoundtripHarness( - messages: Array -) { - return asymmetricRoundtripHarness(messages, messages); -} - -async function asymmetricRoundtripHarness( - before: Array, - after: Array -) { - const outDir = await mkdtemp(path.join(tmpdir(), 'signal-temp-')); - try { - const targetOutputFile = path.join(outDir, 'backup.bin'); - - await Data.saveMessages(before, { forceSave: true, ourAci: OUR_ACI }); - - await backupsService.exportToDisk(targetOutputFile); - - await clearData(); - - await backupsService.importBackup(() => createReadStream(targetOutputFile)); - - const messagesFromDatabase = await Data._getAllMessages(); - - const expected = sortAndNormalize(after); - const actual = sortAndNormalize(messagesFromDatabase); - assert.deepEqual(expected, actual); - } finally { - await rm(outDir, { recursive: true }); - } -} - -async function clearData() { - await Data._removeAllMessages(); - await Data._removeAllConversations(); - await Data.removeAllItems(); - window.storage.reset(); - window.ConversationController.reset(); - - await setupBasics(); -} - -async function setupBasics() { - await window.storage.put('uuid_id', `${OUR_ACI}.2`); - await window.storage.put('pni', OUR_PNI); - await window.storage.put('masterKey', MASTER_KEY); - await window.storage.put('profileKey', PROFILEKEY); - - await window.ConversationController.getOrCreateAndWait(OUR_ACI, 'private', { - pni: OUR_PNI, - systemGivenName: 'ME', - profileKey: Bytes.toBase64(PROFILEKEY), - }); -} let counter = 0; @@ -182,11 +103,6 @@ describe('backup/groupv2/notifications', () => { }); await loadCallsHistory(); - window.Events = { - ...window.Events, - getTypingIndicatorSetting: () => false, - getLinkPreviewSetting: () => false, - }; }); describe('roundtrips given groupv2 notifications with', () => { diff --git a/ts/test-electron/backup/helpers.ts b/ts/test-electron/backup/helpers.ts new file mode 100644 index 0000000000..e15b3bf03e --- /dev/null +++ b/ts/test-electron/backup/helpers.ts @@ -0,0 +1,148 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import path from 'path'; +import { tmpdir } from 'os'; +import { pick, sortBy } from 'lodash'; +import { createReadStream } from 'fs'; +import { mkdtemp, rm } from 'fs/promises'; + +import type { MessageAttributesType } from '../../model-types'; + +import { backupsService } from '../../services/backups'; +import { generateAci, generatePni } from '../../types/ServiceId'; +import Data from '../../sql/Client'; +import { getRandomBytes } from '../../Crypto'; +import * as Bytes from '../../Bytes'; + +export const OUR_ACI = generateAci(); +export const OUR_PNI = generatePni(); +export const MASTER_KEY = Bytes.toBase64(getRandomBytes(32)); +export const PROFILE_KEY = getRandomBytes(32); + +// This is preserved across data erasure +const CONVO_ID_TO_STABLE_ID = new Map(); + +function mapConvoId(id?: string | null): string | undefined | null { + if (id == null) { + return id; + } + + return CONVO_ID_TO_STABLE_ID.get(id) ?? id; +} + +// We need to eliminate fields that won't stay stable through import/export +function sortAndNormalize( + messages: Array +): Array { + return sortBy(messages, 'sent_at').map(message => { + const shallow = pick( + message, + 'contact', + 'conversationMerge', + 'droppedGV2MemberIds', + 'expirationTimerUpdate', + 'flags', + 'groupMigration', + 'groupV2Change', + 'invitedGV2Members', + 'isErased', + 'payment', + 'profileChange', + 'sent_at', + 'sticker', + 'timestamp', + 'type', + 'verified' + ); + + return { + ...shallow, + reactions: message.reactions?.map(({ fromId, ...rest }) => { + return { + from: mapConvoId(fromId), + ...rest, + }; + }), + changedId: mapConvoId(message.changedId), + key_changed: mapConvoId(message.key_changed), + verifiedChanged: mapConvoId(message.verifiedChanged), + }; + }); +} + +export async function symmetricRoundtripHarness( + messages: Array +): Promise { + return asymmetricRoundtripHarness(messages, messages); +} + +async function updateConvoIdToTitle() { + const all = await Data.getAllConversations(); + for (const convo of all) { + CONVO_ID_TO_STABLE_ID.set( + convo.id, + convo.serviceId ?? convo.e164 ?? convo.id + ); + } +} + +export async function asymmetricRoundtripHarness( + before: Array, + after: Array +): Promise { + const outDir = await mkdtemp(path.join(tmpdir(), 'signal-temp-')); + try { + const targetOutputFile = path.join(outDir, 'backup.bin'); + + await Data.saveMessages(before, { forceSave: true, ourAci: OUR_ACI }); + + await backupsService.exportToDisk(targetOutputFile); + + await updateConvoIdToTitle(); + + await clearData(); + + await backupsService.importBackup(() => createReadStream(targetOutputFile)); + + const messagesFromDatabase = await Data._getAllMessages(); + + await updateConvoIdToTitle(); + + const expected = sortAndNormalize(after); + const actual = sortAndNormalize(messagesFromDatabase); + assert.deepEqual(expected, actual); + } finally { + await rm(outDir, { recursive: true }); + } +} + +async function clearData() { + await Data._removeAllMessages(); + await Data._removeAllConversations(); + await Data.removeAllItems(); + window.storage.reset(); + window.ConversationController.reset(); + + await setupBasics(); +} + +export async function setupBasics(): Promise { + await window.storage.put('uuid_id', `${OUR_ACI}.2`); + await window.storage.put('pni', OUR_PNI); + await window.storage.put('masterKey', MASTER_KEY); + await window.storage.put('profileKey', PROFILE_KEY); + + await window.ConversationController.getOrCreateAndWait(OUR_ACI, 'private', { + pni: OUR_PNI, + systemGivenName: 'ME', + profileKey: Bytes.toBase64(PROFILE_KEY), + }); + + window.Events = { + ...window.Events, + getTypingIndicatorSetting: () => false, + getLinkPreviewSetting: () => false, + }; +} diff --git a/ts/test-electron/backup/non_bubble_test.ts b/ts/test-electron/backup/non_bubble_test.ts new file mode 100644 index 0000000000..d3c492304c --- /dev/null +++ b/ts/test-electron/backup/non_bubble_test.ts @@ -0,0 +1,410 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { v4 as generateGuid } from 'uuid'; +import Long from 'long'; + +import type { ConversationModel } from '../../models/conversations'; + +import { getRandomBytes } from '../../Crypto'; +import * as Bytes from '../../Bytes'; +import { SignalService as Proto, Backups } from '../../protobuf'; +import Data from '../../sql/Client'; +import { generateAci } from '../../types/ServiceId'; +import { PaymentEventKind } from '../../types/Payment'; +import { ContactFormType } from '../../types/EmbeddedContact'; +import { DurationInSeconds } from '../../util/durations'; +import { loadCallsHistory } from '../../services/callHistoryLoader'; +import { setupBasics, symmetricRoundtripHarness } from './helpers'; + +const CONTACT_A = generateAci(); +const GROUP_ID = Bytes.toBase64(getRandomBytes(32)); + +describe('backup/non-bubble messages', () => { + let contactA: ConversationModel; + let group: ConversationModel; + + beforeEach(async () => { + await Data._removeAllMessages(); + await Data._removeAllConversations(); + window.storage.reset(); + + await setupBasics(); + + contactA = await window.ConversationController.getOrCreateAndWait( + CONTACT_A, + 'private', + { systemGivenName: 'CONTACT_A' } + ); + + group = await window.ConversationController.getOrCreateAndWait( + GROUP_ID, + 'group', + { + groupVersion: 2, + masterKey: Bytes.toBase64(getRandomBytes(32)), + name: 'Rock Enthusiasts', + } + ); + + await loadCallsHistory(); + }); + + it('roundtrips END_SESSION simple update', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + received_at: 1, + sent_at: 1, + timestamp: 1, + sourceServiceId: CONTACT_A, + sourceDevice: 1, + flags: Proto.DataMessage.Flags.END_SESSION, + }, + ]); + }); + + it('roundtrips CHAT_SESSION_REFRESH simple update', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'chat-session-refreshed', + received_at: 1, + sent_at: 1, + timestamp: 1, + }, + ]); + }); + + it('roundtrips IDENTITY_CHANGE update in direct convos', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'keychange', + received_at: 1, + sent_at: 1, + timestamp: 1, + }, + ]); + }); + + it('roundtrips IDENTITY_CHANGE update in groups', async () => { + await symmetricRoundtripHarness([ + { + conversationId: group.id, + id: generateGuid(), + type: 'keychange', + key_changed: contactA.id, + received_at: 1, + sent_at: 1, + timestamp: 1, + }, + ]); + }); + + it('roundtrips IDENTITY_DEFAULT simple update', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'verified-change', + verifiedChanged: contactA.id, + verified: false, + received_at: 1, + sent_at: 1, + timestamp: 1, + }, + ]); + }); + + it('roundtrips IDENTITY_VERIFIED simple update', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'verified-change', + verifiedChanged: contactA.id, + verified: true, + received_at: 1, + sent_at: 1, + timestamp: 1, + }, + ]); + }); + + it('roundtrips CHANGE_NUMBER simple update', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'change-number-notification', + sourceServiceId: CONTACT_A, + received_at: 1, + sent_at: 1, + timestamp: 1, + }, + ]); + }); + + it('roundtrips JOINED_SIGNAL simple update', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'joined-signal-notification', + received_at: 1, + sent_at: 1, + timestamp: 1, + }, + ]); + }); + + it('roundtrips BAD_DECRYPT simple update', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'delivery-issue', + sourceServiceId: CONTACT_A, + received_at: 1, + sent_at: 1, + timestamp: 1, + }, + ]); + }); + + it('roundtrips PAYMENTS_ACTIVATED simple update', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + sourceServiceId: CONTACT_A, + payment: { + kind: PaymentEventKind.Activation, + }, + received_at: 1, + sent_at: 1, + timestamp: 1, + }, + ]); + }); + + it('roundtrips PAYMENT_ACTIVATION_REQUEST simple update', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + sourceServiceId: CONTACT_A, + payment: { + kind: PaymentEventKind.ActivationRequest, + }, + received_at: 1, + sent_at: 1, + timestamp: 1, + }, + ]); + }); + + // TODO: DESKTOP-7122 + it.skip('roundtrips bare payments notification', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + received_at: 1, + sent_at: 1, + timestamp: 1, + sourceServiceId: CONTACT_A, + sourceDevice: 1, + payment: { + kind: PaymentEventKind.Notification, + note: 'note with text', + }, + }, + ]); + }); + + // TODO: DESKTOP-7122 + it.skip('roundtrips full payments notification', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + received_at: 1, + sent_at: 1, + timestamp: 1, + sourceServiceId: CONTACT_A, + sourceDevice: 1, + payment: { + kind: PaymentEventKind.Notification, + note: 'note with text', + amountMob: '1.01', + feeMob: '0.01', + transactionDetailsBase64: Bytes.toBase64( + Backups.PaymentNotification.TransactionDetails.encode({ + transaction: { + timestamp: Long.fromNumber(Date.now()), + }, + }).finish() + ), + }, + }, + ]); + }); + + it('roundtrips embedded contact', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + received_at: 1, + sent_at: 1, + timestamp: 1, + sourceServiceId: CONTACT_A, + sourceDevice: 1, + contact: [ + { + name: { + givenName: 'Alice', + familyName: 'Smith', + }, + number: [ + { + type: ContactFormType.MOBILE, + value: '+121255501234', + }, + ], + organization: 'Signal', + }, + ], + reactions: [ + { + emoji: '๐Ÿ‘', + fromId: contactA.id, + targetTimestamp: 1, + timestamp: 1, + receivedAtDate: 1, + }, + ], + }, + ]); + }); + + it('roundtrips sticker', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + received_at: 1, + sent_at: 1, + timestamp: 1, + sourceServiceId: CONTACT_A, + sourceDevice: 1, + // TODO (DESKTOP-6845): properly handle data FilePointer + sticker: { + emoji: '๐Ÿ‘', + packId: Bytes.toHex(getRandomBytes(16)), + stickerId: 1, + packKey: Bytes.toBase64(getRandomBytes(32)), + }, + reactions: [ + { + emoji: '๐Ÿ‘', + fromId: contactA.id, + targetTimestamp: 1, + timestamp: 1, + receivedAtDate: 1, + }, + ], + }, + ]); + }); + + it('roundtrips remote deleted message', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'incoming', + received_at: 1, + sent_at: 1, + timestamp: 1, + sourceServiceId: CONTACT_A, + sourceDevice: 1, + isErased: true, + }, + ]); + }); + + it('roundtrips timer notification in direct convos', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'timer-notification', + received_at: 1, + sent_at: 1, + timestamp: 1, + flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, + sourceServiceId: CONTACT_A, + sourceDevice: 1, + expirationTimerUpdate: { + expireTimer: DurationInSeconds.fromMillis(5000), + sourceServiceId: CONTACT_A, + }, + }, + ]); + }); + + it('roundtrips profile change notification', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'profile-change', + received_at: 1, + sent_at: 1, + timestamp: 1, + sourceServiceId: CONTACT_A, + sourceDevice: 1, + changedId: contactA.id, + profileChange: { + type: 'name', + oldName: 'Old Name', + newName: 'New Name', + }, + }, + ]); + }); + + it('roundtrips thread merge', async () => { + await symmetricRoundtripHarness([ + { + conversationId: contactA.id, + id: generateGuid(), + type: 'conversation-merge', + received_at: 1, + sent_at: 1, + timestamp: 1, + sourceServiceId: CONTACT_A, + sourceDevice: 1, + conversationMerge: { + renderInfo: { + type: 'private', + e164: '+12125551234', + }, + }, + }, + ]); + }); +}); diff --git a/ts/types/Payment.ts b/ts/types/Payment.ts index 371f237abf..da3d7a773a 100644 --- a/ts/types/Payment.ts +++ b/ts/types/Payment.ts @@ -12,6 +12,11 @@ export enum PaymentEventKind { export type PaymentNotificationEvent = { kind: PaymentEventKind.Notification; note: string | null; + + // Backup related data + transactionDetailsBase64?: string; + amountMob?: string; + feeMob?: string; }; export type PaymentActivationRequestEvent = { diff --git a/ts/types/Stickers.ts b/ts/types/Stickers.ts index 4f73ce10fd..79544824b6 100644 --- a/ts/types/Stickers.ts +++ b/ts/types/Stickers.ts @@ -58,6 +58,9 @@ export type DownloadMap = Record< } >; +export const STICKERPACK_ID_BYTE_LEN = 16; +export const STICKERPACK_KEY_BYTE_LEN = 32; + const BLESSED_PACKS: Record = { '9acc9e8aba563d26a4994e69263e3b25': { key: 'Wm3/OUjCjvubeq+T7MN1xp/DFueAd+0mhnoU0QoPahI=',