Export/import simple update messages
This commit is contained in:
parent
19083cadf7
commit
9df3c63ca6
14 changed files with 1604 additions and 386 deletions
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Props>;
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
export function Default(): JSX.Element {
|
||||
return <JoinedSignalNotification timestamp={1618894800000} i18n={i18n} />;
|
||||
}
|
37
ts/components/conversation/JoinedSignalNotification.tsx
Normal file
37
ts/components/conversation/JoinedSignalNotification.tsx
Normal file
|
@ -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 (
|
||||
<SystemMessage
|
||||
contents={
|
||||
<>
|
||||
<I18n id="icu:JoinedSignal--notification" i18n={i18n} />
|
||||
·
|
||||
<MessageTimestamp i18n={i18n} timestamp={timestamp} />
|
||||
</>
|
||||
}
|
||||
icon="info"
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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 = (
|
||||
<JoinedSignalNotification
|
||||
{...reducedProps}
|
||||
{...item.data}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
} else if (item.type === 'titleTransitionNotification') {
|
||||
notification = (
|
||||
<TitleTransitionNotification
|
||||
|
|
1
ts/model-types.d.ts
vendored
1
ts/model-types.d.ts
vendored
|
@ -145,6 +145,7 @@ type MessageType =
|
|||
| 'group-v2-change'
|
||||
| 'group'
|
||||
| 'incoming'
|
||||
| 'joined-signal-notification'
|
||||
| 'keychange'
|
||||
| 'outgoing'
|
||||
| 'phone-number-discovery'
|
||||
|
|
|
@ -20,6 +20,7 @@ import {
|
|||
} from '../../types/ServiceId';
|
||||
import type { RawBodyRange } from '../../types/BodyRange';
|
||||
import { LONG_ATTACHMENT_LIMIT } from '../../types/Message';
|
||||
import { PaymentEventKind } from '../../types/Payment';
|
||||
import type {
|
||||
ConversationAttributesType,
|
||||
MessageAttributesType,
|
||||
|
@ -67,6 +68,8 @@ import {
|
|||
isUniversalTimerNotification,
|
||||
isUnsupportedMessage,
|
||||
isVerifiedChange,
|
||||
isChangeNumberNotification,
|
||||
isJoinedSignalNotification,
|
||||
} from '../../state/selectors/message';
|
||||
import * as Bytes from '../../Bytes';
|
||||
import { canBeSynced as canPreferredReactionEmojiBeSynced } from '../../reactions/preferredReactionEmoji';
|
||||
|
@ -117,6 +120,38 @@ type GetRecipientIdOptionsType =
|
|||
e164: string;
|
||||
}>;
|
||||
|
||||
type ToChatItemOptionsType = Readonly<{
|
||||
aboutMe: AboutMe;
|
||||
callHistoryByCallId: Record<string, CallHistoryDetails>;
|
||||
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<string, number>();
|
||||
|
@ -572,11 +607,7 @@ export class BackupExportStream extends Readable {
|
|||
|
||||
private async toChatItem(
|
||||
message: MessageAttributesType,
|
||||
options: {
|
||||
aboutMe: AboutMe;
|
||||
callHistoryByCallId: Record<string, CallHistoryDetails>;
|
||||
backupLevel: BackupLevel;
|
||||
}
|
||||
{ aboutMe, callHistoryByCallId, backupLevel }: ToChatItemOptionsType
|
||||
): Promise<Backups.IChatItem | undefined> {
|
||||
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<Backups.ISendStatus>();
|
||||
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<string, CallHistoryDetails>;
|
||||
}
|
||||
): Promise<Backups.IChatItem | undefined> {
|
||||
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<NonBubbleResultType> {
|
||||
return this.toChatItemUpdate(options);
|
||||
}
|
||||
|
||||
async toChatItemUpdate(
|
||||
chatItem: Backups.IChatItem,
|
||||
message: MessageAttributesType,
|
||||
options: {
|
||||
aboutMe: AboutMe;
|
||||
callHistoryByCallId: Record<string, CallHistoryDetails>;
|
||||
}
|
||||
): Promise<Backups.IChatItem | undefined> {
|
||||
options: NonBubbleOptionsType
|
||||
): Promise<NonBubbleResultType> {
|
||||
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<Backups.IReaction> | 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<Backups.ISendStatus>();
|
||||
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(
|
||||
|
|
|
@ -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<Backups.IReaction> | null | undefined
|
||||
): Array<MessageReactionType> | 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<Partial<MessageAttributesType> | 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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<MessageAttributesType>
|
||||
): Array<Partial<MessageAttributesType>> {
|
||||
return sortBy(messages, 'sent_at').map(message =>
|
||||
pick(
|
||||
message,
|
||||
'droppedGV2MemberIds',
|
||||
'expirationTimerUpdate',
|
||||
'groupMigration',
|
||||
'groupV2Change',
|
||||
'invitedGV2Members',
|
||||
'sent_at',
|
||||
'timestamp',
|
||||
'type'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function symmetricRoundtripHarness(
|
||||
messages: Array<MessageAttributesType>
|
||||
) {
|
||||
return asymmetricRoundtripHarness(messages, messages);
|
||||
}
|
||||
|
||||
async function asymmetricRoundtripHarness(
|
||||
before: Array<MessageAttributesType>,
|
||||
after: Array<MessageAttributesType>
|
||||
) {
|
||||
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', () => {
|
||||
|
|
148
ts/test-electron/backup/helpers.ts
Normal file
148
ts/test-electron/backup/helpers.ts
Normal file
|
@ -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<string, string>();
|
||||
|
||||
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<MessageAttributesType>
|
||||
): Array<unknown> {
|
||||
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<MessageAttributesType>
|
||||
): Promise<void> {
|
||||
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<MessageAttributesType>,
|
||||
after: Array<MessageAttributesType>
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
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,
|
||||
};
|
||||
}
|
410
ts/test-electron/backup/non_bubble_test.ts
Normal file
410
ts/test-electron/backup/non_bubble_test.ts
Normal file
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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 = {
|
||||
|
|
|
@ -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<string, BlessedType> = {
|
||||
'9acc9e8aba563d26a4994e69263e3b25': {
|
||||
key: 'Wm3/OUjCjvubeq+T7MN1xp/DFueAd+0mhnoU0QoPahI=',
|
||||
|
|
Loading…
Reference in a new issue