Refactor recipient tracking during export

This commit is contained in:
trevor-signal
2026-03-17 12:18:24 -07:00
committed by GitHub
parent 5233849d05
commit fcafec550e
5 changed files with 368 additions and 176 deletions

View File

@@ -131,9 +131,11 @@ const validateBackupResult: ExportResultType = {
distributionLists: 5,
messages: 6,
notificationProfiles: 2,
skippedConversations: 0,
skippedMessages: 7,
stickerPacks: 8,
fixedDirectMessages: 9,
unknownConversationReferences: {},
},
};

View File

@@ -190,6 +190,7 @@ import type { ThemeType } from '../../util/preload.preload.js';
import { MAX_VALUE as LONG_MAX_VALUE } from '../../util/long.std.js';
import { encodeDelimited } from '../../util/encodeDelimited.std.js';
import { safeParseStrict } from '../../util/schemas.std.js';
import type { WithRequiredProperties } from '../../types/Util.std.js';
const { isNumber } = lodash;
@@ -278,18 +279,22 @@ export class BackupExportStream extends Readable {
string,
Backups.FilePointer.Params
>();
readonly #stats: StatsType = {
readonly #stats: Omit<StatsType, 'unknownConversationReferences'> & {
unknownConversationReferences: Map<string, number>;
} = {
adHocCalls: 0,
callLinks: 0,
conversations: 0,
chatFolders: 0,
chats: 0,
distributionLists: 0,
fixedDirectMessages: 0,
messages: 0,
notificationProfiles: 0,
skippedConversations: 0,
skippedMessages: 0,
stickerPacks: 0,
fixedDirectMessages: 0,
unknownConversationReferences: new Map<string, number>(),
};
#ourConversation?: ConversationAttributesType;
#attachmentBackupJobs: Array<
@@ -387,7 +392,12 @@ export class BackupExportStream extends Readable {
}
public getStats(): Readonly<StatsType> {
return this.#stats;
return {
...this.#stats,
unknownConversationReferences: Object.fromEntries(
this.#stats.unknownConversationReferences
),
};
}
public getAttachmentBackupJobs(): ReadonlyArray<
CoreAttachmentBackupJobType | CoreAttachmentLocalBackupJobType
@@ -439,8 +449,6 @@ export class BackupExportStream extends Readable {
const skippedConversationIds = new Set<string>();
for (const { attributes } of window.ConversationController.getAll()) {
const recipientId = this.#getRecipientId(attributes);
let keyTransparencyData: Uint8Array | undefined;
if (
isDirectConversation(attributes) &&
@@ -453,12 +461,13 @@ export class BackupExportStream extends Readable {
);
}
const recipient = this.#toRecipient(recipientId, attributes, {
const recipient = this.#toRecipient(attributes, {
identityKeysById,
keyTransparencyData,
});
if (recipient == null) {
skippedConversationIds.add(attributes.id);
this.#stats.skippedConversations += 1;
// Can't be backed up.
continue;
}
@@ -516,9 +525,14 @@ export class BackupExportStream extends Readable {
name: list.name,
allowReplies: list.allowsReplies,
privacyMode,
memberRecipientIds: list.members.map(serviceId =>
this.#getOrPushPrivateRecipient({ serviceId })
),
memberRecipientIds: list.members
.map(serviceId =>
this.#getRecipientByServiceId(
serviceId,
'distributionList.memberRecipientIds'
)
)
.filter(isNotNil),
},
},
},
@@ -594,16 +608,17 @@ export class BackupExportStream extends Readable {
itemStorage.get('pinnedConversationIds') || [];
for (const { attributes } of window.ConversationController.getAll()) {
if (isGroupV1(attributes)) {
log.warn('skipping gv1 conversation');
continue;
}
if (skippedConversationIds.has(attributes.id)) {
continue;
}
const recipientId = this.#getRecipientId(attributes);
const recipientId = this.#getRecipientByConversationId(
attributes.id,
'adding chat frames'
);
strictAssert(
recipientId,
'recipient conversation must exist if not skipped'
);
let pinnedOrder: number | null = null;
if (attributes.isPinned) {
@@ -736,16 +751,12 @@ export class BackupExportStream extends Readable {
} = profile;
const allowedRecipients = Array.from(allowedMembers)
.map(conversationId => {
const conversation =
window.ConversationController.get(conversationId);
if (!conversation) {
return undefined;
}
const { attributes } = conversation;
return this.#getRecipientId(attributes);
})
.map(conversationId =>
this.#getRecipientByConversationId(
conversationId,
'notificationProfile.allowedMembers'
)
)
.filter(isNotNil);
this.#pushFrame({
@@ -792,12 +803,22 @@ export class BackupExportStream extends Readable {
showMutedChats: chatFolder.showMutedChats,
includeAllIndividualChats: chatFolder.includeAllIndividualChats,
includeAllGroupChats: chatFolder.includeAllGroupChats,
includedRecipientIds: chatFolder.includedConversationIds.map(id => {
return this.#getOrPushPrivateRecipient({ id });
}),
excludedRecipientIds: chatFolder.excludedConversationIds.map(id => {
return this.#getOrPushPrivateRecipient({ id });
}),
includedRecipientIds: chatFolder.includedConversationIds
.map(id => {
return this.#getRecipientByConversationId(
id,
'chatFolder.includedRecipientIds'
);
})
.filter(isNotNil),
excludedRecipientIds: chatFolder.excludedConversationIds
.map(id => {
return this.#getRecipientByConversationId(
id,
'chatFolder.excludedRecipientIds'
);
})
.filter(isNotNil),
},
});
@@ -1110,28 +1131,76 @@ export class BackupExportStream extends Readable {
};
}
#getExistingRecipientId(
options: GetRecipientIdOptionsType
#incrementUnknownConversationReference(reason: string) {
this.#stats.unknownConversationReferences.set(
reason,
(this.#stats.unknownConversationReferences.get(reason) ?? 0) + 1
);
}
#getRecipientByConversationId(
convoId: string,
reason: string
): bigint | undefined {
let existing: bigint | undefined;
if (options.serviceId != null) {
existing = this.#serviceIdToRecipientId.get(options.serviceId);
const fnLog = log.child(`getRecipientByConversationId(${reason})`);
const recipientId = this.#convoIdToRecipientId.get(convoId);
if (recipientId == null) {
fnLog.warn('recipient convoId missing', reason);
this.#incrementUnknownConversationReference(reason);
}
if (existing === undefined && options.e164 != null) {
existing = this.#e164ToRecipientId.get(options.e164);
}
if (existing === undefined && options.id != null) {
existing = this.#convoIdToRecipientId.get(options.id);
}
return existing;
return this.#convoIdToRecipientId.get(convoId);
}
#getRecipientId(options: GetRecipientIdOptionsType): bigint {
const existing = this.#getExistingRecipientId(options);
if (existing !== undefined) {
return existing;
#getRecipientByServiceId(
serviceId: ServiceIdString,
reason: string
): bigint | undefined {
const fnLog = log.child(`getRecipientByServiceId(${reason})`);
if (!isServiceIdString(serviceId)) {
fnLog.warn('invalid serviceId');
return undefined;
}
const recipientId = this.#serviceIdToRecipientId.get(serviceId);
if (recipientId != null) {
return recipientId;
}
fnLog.warn('recipient serviceId missing', reason);
this.#incrementUnknownConversationReference(reason);
const recipient = this.#toRecipient({ serviceId, type: 'private' });
if (recipient == null) {
return undefined;
}
this.#pushFrame({ recipient });
strictAssert(recipient.id != null, 'recipient.id must exist');
return recipient.id;
}
#getRecipientByE164(e164: string, reason: string): bigint | undefined {
const fnLog = log.child(`getRecipientByE164(${reason})`);
const recipientId = this.#e164ToRecipientId.get(e164);
if (recipientId != null) {
return recipientId;
}
fnLog.warn('recipient e164 missing', reason);
this.#incrementUnknownConversationReference(reason);
const recipient = this.#toRecipient({ e164, type: 'private' });
if (recipient == null) {
return undefined;
}
this.#pushFrame({ recipient });
strictAssert(recipient.id != null, 'recipient.id must exist');
return recipient.id;
}
#getNewRecipientId(options: GetRecipientIdOptionsType): bigint {
const { id, serviceId, e164 } = options;
const recipientId = this.#nextRecipientId;
@@ -1150,28 +1219,6 @@ export class BackupExportStream extends Readable {
return recipientId;
}
#getOrPushPrivateRecipient(options: GetRecipientIdOptionsType): bigint {
const existing = this.#getExistingRecipientId(options);
const needsPush = existing == null;
const result = this.#getRecipientId(options);
if (needsPush) {
const { serviceId, e164 } = options;
const recipient = this.#toRecipient(result, {
type: 'private',
serviceId,
pni: isPniString(serviceId) ? serviceId : undefined,
e164,
});
if (recipient != null) {
this.#pushFrame({ recipient });
}
}
return result;
}
#getNextRecipientId(): bigint {
const recipientId = this.#nextRecipientId;
this.#nextRecipientId += 1n;
@@ -1180,14 +1227,16 @@ export class BackupExportStream extends Readable {
}
#toRecipient(
recipientId: bigint,
convo: Omit<
ConversationAttributesType,
'id' | 'version' | 'expireTimerVersion'
>,
convo: WithRequiredProperties<Partial<ConversationAttributesType>, 'type'>,
options?: ToRecipientOptionsType
): Backups.Recipient.Params | null {
if (isMe(convo)) {
strictAssert(convo.id, 'id must exist');
const recipientId = this.#getNewRecipientId({
id: convo.id,
serviceId: convo.serviceId,
e164: convo.e164,
});
return {
id: recipientId,
destination: {
@@ -1252,6 +1301,22 @@ export class BackupExportStream extends Readable {
'Contact has no identifier'
);
let recipientId: bigint | null = null;
if (convo.serviceId) {
recipientId = this.#getNewRecipientId({
id: convo.id,
serviceId: convo.serviceId,
e164: convo.e164,
});
} else if (convo.e164) {
recipientId = this.#getNewRecipientId({
id: convo.id,
e164: convo.e164,
});
}
strictAssert(recipientId != null, 'recipientId must exist');
return {
id: recipientId,
destination: {
@@ -1303,7 +1368,7 @@ export class BackupExportStream extends Readable {
},
};
}
if (isGroupV2(convo) && convo.masterKey) {
if (isGroupV2(convo)) {
let storySendMode: Backups.Group.StorySendMode;
switch (convo.storySendMode) {
case StorySendMode.Always:
@@ -1317,8 +1382,16 @@ export class BackupExportStream extends Readable {
break;
}
const masterKey = Bytes.fromBase64(convo.masterKey);
if (!convo.masterKey) {
log.warn('groupV2 convo missing master key, skipping');
return null;
}
const masterKey = Bytes.fromBase64(convo.masterKey);
strictAssert(convo.id, 'group conversationId must exist');
const recipientId = this.#getNewRecipientId({
id: convo.id,
});
return {
id: recipientId,
destination: {
@@ -1417,6 +1490,11 @@ export class BackupExportStream extends Readable {
};
}
if (isGroupV1(convo)) {
log.warn('skipping gv1 conversation');
return null;
}
return null;
}
@@ -1432,12 +1510,11 @@ export class BackupExportStream extends Readable {
message.conversationId
);
if (conversation && isGroupV1(conversation.attributes)) {
log.warn('skipping gv1 message');
return undefined;
}
const chatId = this.#getRecipientByConversationId(
message.conversationId,
'message.conversationId'
);
const chatId = this.#getRecipientId({ id: message.conversationId });
if (chatId === undefined) {
log.warn('message chat not found');
return undefined;
@@ -1477,30 +1554,24 @@ export class BackupExportStream extends Readable {
let authorId: bigint | undefined;
const me = this.#getOrPushPrivateRecipient({
serviceId: aboutMe.aci,
});
const me = this.#getRecipientByServiceId(aboutMe.aci, 'getting self');
strictAssert(me, 'self recipient must exist');
let isOutgoing = message.type === 'outgoing';
let isIncoming = message.type === 'incoming';
if (message.sourceServiceId && isAciString(message.sourceServiceId)) {
authorId = this.#getOrPushPrivateRecipient({
serviceId: message.sourceServiceId,
e164: message.source,
});
authorId = this.#getRecipientByServiceId(
message.sourceServiceId,
'message.sourceServiceId'
);
} else if (message.source) {
authorId = this.#getOrPushPrivateRecipient({
serviceId: message.sourceServiceId,
e164: message.source,
});
authorId = this.#getRecipientByE164(message.source, 'message.source');
} else {
strictAssert(!isIncoming, 'Incoming message must have source');
// Author must be always present, even if we are directionless
authorId = this.#getOrPushPrivateRecipient({
serviceId: aboutMe.aci,
});
authorId = me;
}
// Mark incoming messages from self as outgoing
@@ -1526,9 +1597,10 @@ export class BackupExportStream extends Readable {
conversation &&
isDirectConversation(conversation.attributes)
) {
const convoAuthor = this.#getOrPushPrivateRecipient({
id: conversation.attributes.id,
});
const convoAuthor = this.#getRecipientByConversationId(
conversation.attributes.id,
'message.conversationId'
);
if (authorId !== convoAuthor) {
authorId = convoAuthor;
@@ -1538,9 +1610,10 @@ export class BackupExportStream extends Readable {
// Drop messages in the wrong 1:1 chat
if (conversation && isDirectConversation(conversation.attributes)) {
const convoAuthor = this.#getOrPushPrivateRecipient({
id: conversation.attributes.id,
});
const convoAuthor = this.#getRecipientByConversationId(
conversation.attributes.id,
'message.conversationId'
);
if (authorId !== me && authorId !== convoAuthor) {
log.warn(
@@ -1572,7 +1645,7 @@ export class BackupExportStream extends Readable {
'directionalDetails' | 'item' | 'pinDetails' | 'revisions'
> = {
chatId,
authorId,
authorId: authorId ?? null,
dateSent: getSafeLongFromTimestamp(
message.editMessageTimestamp || message.sent_at
),
@@ -1645,9 +1718,11 @@ export class BackupExportStream extends Readable {
if (message.deletedForEveryoneByAdminAci) {
item = {
adminDeletedMessage: {
adminId: this.#getOrPushPrivateRecipient({
serviceId: message.deletedForEveryoneByAdminAci,
}),
adminId:
this.#getRecipientByServiceId(
message.deletedForEveryoneByAdminAci,
'adminDeletedMessage.adminId'
) ?? null,
},
};
} else {
@@ -1852,23 +1927,23 @@ export class BackupExportStream extends Readable {
}
}
const votes = Array.from(votesForThisOption.entries()).map(
([conversationId, voteCount]) => {
const voterConvo =
window.ConversationController.get(conversationId);
let voterId: bigint | null = null;
if (voterConvo) {
voterId = this.#getOrPushPrivateRecipient(
voterConvo.attributes
const votes = Array.from(votesForThisOption.entries())
.map(([conversationId, voteCount]) => {
const voterId = this.#getRecipientByConversationId(
conversationId,
'poll.votes.voterId'
);
if (!voterId) {
return null;
}
return {
voterId,
voteCount,
};
}
);
})
.filter(isNotNil);
return {
option: optionText,
@@ -1878,9 +1953,6 @@ export class BackupExportStream extends Readable {
reactions: this.#getMessageReactions(message),
},
};
} else if (message.pollTerminateNotification) {
// TODO (DESKTOP-9282)
return undefined;
} else if (message.isErased) {
return undefined;
} else {
@@ -2029,9 +2101,11 @@ export class BackupExportStream extends Readable {
);
}
ringerRecipientId = this.#getRecipientId(
ringerConversation.attributes
);
ringerRecipientId =
this.#getRecipientByConversationId(
ringerConversation.id,
'callHistory.ringerId'
) ?? null;
}
let startedCallRecipientId: bigint | null = null;
@@ -2043,9 +2117,11 @@ export class BackupExportStream extends Readable {
);
}
startedCallRecipientId = this.#getRecipientId(
startedByConvo.attributes
);
startedCallRecipientId =
this.#getRecipientByConversationId(
startedByConvo.id,
'callHistory.startedById'
) ?? null;
}
let groupCallId: bigint;
@@ -2125,9 +2201,13 @@ export class BackupExportStream extends Readable {
const sourceServiceId = message.expirationTimerUpdate?.sourceServiceId;
if (conversation && isGroup(conversation.attributes)) {
patch.authorId = this.#getRecipientId({
serviceId: options.aboutMe.aci,
});
const selfId = this.#getRecipientByServiceId(
options.aboutMe.aci,
'getting self'
);
strictAssert(selfId, 'self must exist');
patch.authorId = selfId;
let updaterAci: Uint8Array | null = null;
if (sourceServiceId && Aci.parseFromServiceIdString(sourceServiceId)) {
@@ -2154,15 +2234,17 @@ export class BackupExportStream extends Readable {
if (!authorId) {
if (sourceServiceId) {
patch.authorId = this.#getOrPushPrivateRecipient({
id: source,
serviceId: sourceServiceId,
});
patch.authorId =
this.#getRecipientByServiceId(
sourceServiceId,
'expirationTimerUpdate.sourceServiceId'
) ?? undefined;
} else if (source) {
patch.authorId = this.#getOrPushPrivateRecipient({
id: source,
});
patch.authorId =
this.#getRecipientByE164(source, 'expirationTimerUpdate.source') ??
undefined;
}
patch.authorId = authorId;
}
updateMessage.update = {
@@ -2179,7 +2261,12 @@ export class BackupExportStream extends Readable {
groupChange: await this.toGroupV2Update(message, options),
};
strictAssert(this.#ourConversation?.id, 'our conversation must exist');
patch.authorId = this.#getOrPushPrivateRecipient(this.#ourConversation);
const selfId = this.#getRecipientByConversationId(
this.#ourConversation.id,
'getting self'
);
strictAssert(selfId != null, 'self must exist');
patch.authorId = selfId;
return { kind: NonBubbleResultKind.Directionless, patch };
}
@@ -2200,8 +2287,15 @@ export class BackupExportStream extends Readable {
);
return { kind: NonBubbleResultKind.Drop };
}
const keyChangeAuthorId = this.#getRecipientByConversationId(
target.id,
'keyChange.key_changed'
);
if (!keyChangeAuthorId) {
return { kind: NonBubbleResultKind.Drop };
}
// This will override authorId on the original chatItem
patch.authorId = this.#getOrPushPrivateRecipient(target.attributes);
patch.authorId = keyChangeAuthorId;
}
updateMessage.update = {
@@ -2226,9 +2320,11 @@ export class BackupExportStream extends Readable {
}
targetSentTimestamp = BigInt(message.pinMessage.targetSentTimestamp);
targetAuthorId = this.#getOrPushPrivateRecipient({
serviceId: message.pinMessage.targetAuthorAci,
});
targetAuthorId =
this.#getRecipientByServiceId(
message.pinMessage.targetAuthorAci,
'pinnedMessageNotification.targetAuthorAci'
) ?? null;
updateMessage.update = {
pinMessage: {
@@ -2272,7 +2368,11 @@ export class BackupExportStream extends Readable {
);
if (changedConvo) {
// This will override authorId on the original chatItem
patch.authorId = this.#getOrPushPrivateRecipient(changedConvo);
patch.authorId =
this.#getRecipientByConversationId(
changedConvo.id,
'profileChange.changedId'
) ?? undefined;
} else {
log.warn(
`${logId}: failed to resolve changedId ${message.changedId}`
@@ -2309,9 +2409,11 @@ export class BackupExportStream extends Readable {
if (message.verifiedChanged) {
// This will override authorId on the original chatItem
patch.authorId = this.#getOrPushPrivateRecipient({
id: message.verifiedChanged,
});
patch.authorId =
this.#getRecipientByConversationId(
message.verifiedChanged,
'message.verifiedChange'
) ?? undefined;
}
return { kind: NonBubbleResultKind.Directionless, patch };
@@ -2414,9 +2516,11 @@ export class BackupExportStream extends Readable {
// `sourceServiceId` and thus are attributed to our conversation.
// However, we need to include proper `authorId` for compatibility with
// other clients.
patch.authorId = this.#getOrPushPrivateRecipient({
id: message.conversationId,
});
patch.authorId =
this.#getRecipientByConversationId(
message.conversationId,
'conversationMerge.conversationId'
) ?? undefined;
updateMessage.update = {
threadMerge: {
@@ -3004,18 +3108,17 @@ export class BackupExportStream extends Readable {
return null;
}
let authorId: bigint;
let authorId: bigint | undefined;
if (quote.authorAci) {
authorId = this.#getOrPushPrivateRecipient({
serviceId: quote.authorAci,
e164: quote.author,
});
authorId = this.#getRecipientByServiceId(
quote.authorAci,
'quote.authorAci'
);
} else if (quote.author) {
authorId = this.#getOrPushPrivateRecipient({
serviceId: quote.authorAci,
e164: quote.author,
});
} else {
authorId = this.#getRecipientByE164(quote.author, 'quote.author');
}
if (authorId == null) {
log.warn('quote has no author id');
return null;
}
@@ -3216,16 +3319,25 @@ export class BackupExportStream extends Readable {
return null;
}
return reactions.map((reaction, sortOrder): Backups.Reaction.Params => {
return reactions
.map((reaction, sortOrder): Backups.Reaction.Params | null => {
const authorId = this.#getRecipientByConversationId(
reaction.fromId,
'reaction.authorId'
);
if (!authorId) {
return null;
}
return {
emoji: reaction.emoji ?? null,
authorId: this.#getOrPushPrivateRecipient({
id: reaction.fromId,
}),
authorId,
sentTimestamp: getSafeLongFromTimestamp(reaction.timestamp),
sortOrder: BigInt(sortOrder),
};
});
})
.filter(isNotNil);
}
#getIncomingMessageDetails({
@@ -3299,7 +3411,9 @@ export class BackupExportStream extends Readable {
}
const { serviceId } = target.attributes;
const recipientId = this.#getOrPushPrivateRecipient(target.attributes);
const recipientId =
this.#getRecipientByConversationId(id, 'sendStateByConversationId') ??
null;
const timestamp =
entry.updatedAt != null
? getSafeLongFromTimestamp(entry.updatedAt)

View File

@@ -70,11 +70,13 @@ export type StatsType = {
chatFolders: number;
chats: number;
distributionLists: number;
fixedDirectMessages: number;
messages: number;
notificationProfiles: number;
skippedConversations: number;
skippedMessages: number;
stickerPacks: number;
fixedDirectMessages: number;
unknownConversationReferences: Record<string, number>;
};
export type ExportResultType = Readonly<{

View File

@@ -610,7 +610,7 @@ describe('backup/attachments', () => {
describe('quotes', () => {
it('BackupLevel.Free, roundtrips quote attachments', async () => {
const attachment = composeAttachment(1, { clientUuid: undefined });
const authorAci = generateAci();
const authorAci = CONTACT_A;
const quotedMessage: QuotedMessageType = {
authorAci,
isViewOnce: false,
@@ -648,7 +648,7 @@ describe('backup/attachments', () => {
it('BackupLevel.Paid, roundtrips quote attachments', async () => {
const attachment = composeAttachment(1, { clientUuid: undefined });
strictAssert(attachment.digest, 'digest exists');
const authorAci = generateAci();
const authorAci = CONTACT_A;
const quotedMessage: QuotedMessageType = {
authorAci,
isViewOnce: false,

View File

@@ -34,6 +34,7 @@ const CONTACT_A = generateAci();
const CONTACT_B = generateAci();
const CONTACT_B_E164 = '+12135550123';
const GV1_ID = Bytes.toBinary(getRandomBytes(ID_V1_LENGTH));
const GV2_ID = Bytes.toBase64(getRandomBytes(32));
const BADGE_RECEIPT =
'AEpyZxbRBT+T5PQw9Wcx1QE2aFvL7LoLir9V4UF09Kk9qiP4SpIlHdlWHrAICy6F' +
@@ -48,6 +49,7 @@ describe('backup/bubble messages', () => {
let contactA: ConversationModel;
let contactB: ConversationModel;
let gv1: ConversationModel;
let gv2: ConversationModel;
beforeEach(async () => {
await DataWriter._removeAllMessages();
@@ -76,6 +78,17 @@ describe('backup/bubble messages', () => {
}
);
gv2 = await window.ConversationController.getOrCreateAndWait(
GV2_ID,
'group',
{
groupVersion: 2,
masterKey: Bytes.toBase64(getRandomBytes(32)),
name: 'Rock Enthusiasts',
active_at: 1,
}
);
await loadAllAndReinitializeRedux();
});
@@ -208,6 +221,26 @@ describe('backup/bubble messages', () => {
);
});
it('is resilient to ACIs not from known conversations', async () => {
const unknownAci = generateAci();
await symmetricRoundtripHarness([
{
conversationId: gv2.id,
id: generateGuid(),
body: 'body',
type: 'incoming',
received_at: 3,
received_at_ms: 3,
sent_at: 3,
timestamp: 3,
sourceServiceId: unknownAci,
readStatus: ReadStatus.Unread,
seenStatus: SeenStatus.Unseen,
unidentifiedDeliveryReceived: true,
},
]);
});
it('drops edited revisions with neither text nor attachments', async () => {
const message: MessageAttributesType = {
conversationId: contactA.id,
@@ -418,6 +451,47 @@ describe('backup/bubble messages', () => {
);
});
it('filters out reactions from unknown authors', async () => {
const message = {
conversationId: contactA.id,
id: generateGuid(),
type: 'incoming',
received_at: 3,
received_at_ms: 3,
sent_at: 3,
sourceServiceId: CONTACT_A,
readStatus: ReadStatus.Unread,
seenStatus: SeenStatus.Unseen,
unidentifiedDeliveryReceived: true,
timestamp: 3,
reactions: [
{
emoji: 'first',
fromId: contactA.id,
targetTimestamp: 3,
timestamp: 3,
},
{
emoji: 'second',
fromId: generateGuid(),
targetTimestamp: 3,
timestamp: 3,
},
{
emoji: 'third',
fromId: contactB.id,
targetTimestamp: 3,
timestamp: 3,
},
],
body: 'hello',
} as const;
await asymmetricRoundtripHarness(
[message],
[{ ...message, reactions: [message.reactions[0], message.reactions[2]] }]
);
});
describe('quotes', () => {
it('roundtrips gift badge quote', async () => {
await symmetricRoundtripHarness([
@@ -1231,7 +1305,7 @@ describe('backup/bubble messages', () => {
messageId: '',
},
sendStateByConversationId: {
[CONTACT_A]: {
[contactA.id]: {
status: SendStatus.Read,
updatedAt: 3,
},
@@ -1275,7 +1349,7 @@ describe('backup/bubble messages', () => {
messageId: '',
},
sendStateByConversationId: {
[CONTACT_A]: {
[contactA.id]: {
status: SendStatus.Read,
updatedAt: 3,
},