Export/import simple update messages

This commit is contained in:
Fedor Indutny 2024-05-22 09:34:19 -07:00 committed by GitHub
parent 19083cadf7
commit 9df3c63ca6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1604 additions and 386 deletions

View file

@ -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"

View file

@ -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;
}
}

View file

@ -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} />;
}

View 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} />
&nbsp;·&nbsp;
<MessageTimestamp i18n={i18n} timestamp={timestamp} />
</>
}
icon="info"
/>
);
}

View file

@ -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
View file

@ -145,6 +145,7 @@ type MessageType =
| 'group-v2-change'
| 'group'
| 'incoming'
| 'joined-signal-notification'
| 'keychange'
| 'outgoing'
| 'phone-number-discovery'

View file

@ -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(

View file

@ -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');
}
}
}

View file

@ -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(

View file

@ -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', () => {

View 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,
};
}

View 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',
},
},
},
]);
});
});

View file

@ -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 = {

View file

@ -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=',