Support for local deletes synced to all your devices

This commit is contained in:
Scott Nonnenberg 2024-05-29 01:56:00 +10:00 committed by GitHub
parent 06f71a7ef8
commit 11eb1782a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2094 additions and 72 deletions

View file

@ -130,6 +130,13 @@ import {
StoryRecipientUpdateEvent,
CallLogEventSyncEvent,
CallLinkUpdateSyncEvent,
DeleteForMeSyncEvent,
} from './messageReceiverEvents';
import type {
MessageToDelete,
DeleteForMeSyncEventData,
DeleteForMeSyncTarget,
ConversationToDelete,
} from './messageReceiverEvents';
import * as log from '../logging/log';
import * as durations from '../util/durations';
@ -686,6 +693,11 @@ export default class MessageReceiver
handler: (ev: CallLogEventSyncEvent) => void
): void;
public override addEventListener(
name: 'deleteForMeSync',
handler: (ev: DeleteForMeSyncEvent) => void
): void;
public override addEventListener(name: string, handler: EventHandler): void {
return super.addEventListener(name, handler);
}
@ -3165,6 +3177,9 @@ export default class MessageReceiver
if (syncMessage.callLogEvent) {
return this.handleCallLogEvent(envelope, syncMessage.callLogEvent);
}
if (syncMessage.deleteForMe) {
return this.handleDeleteForMeSync(envelope, syncMessage.deleteForMe);
}
this.removeFromCache(envelope);
const envelopeId = getEnvelopeId(envelope);
@ -3615,6 +3630,118 @@ export default class MessageReceiver
log.info('handleCallLogEvent: finished');
}
private async handleDeleteForMeSync(
envelope: ProcessedEnvelope,
deleteSync: Proto.SyncMessage.IDeleteForMe
): Promise<void> {
const logId = getEnvelopeId(envelope);
log.info('MessageReceiver.handleDeleteForMeSync', logId);
logUnexpectedUrgentValue(envelope, 'deleteForMeSync');
const { timestamp } = envelope;
let eventData: DeleteForMeSyncEventData = [];
try {
if (deleteSync.messageDeletes?.length) {
const messageDeletes: Array<DeleteForMeSyncTarget> =
deleteSync.messageDeletes
.flatMap((item): Array<DeleteForMeSyncTarget> | undefined => {
const messages = item.messages
?.map(message => processMessageToDelete(message, logId))
.filter(isNotNil);
const conversation = item.conversation
? processConversationToDelete(item.conversation, logId)
: undefined;
if (messages?.length && conversation) {
// We want each message in its own task
return messages.map(innerItem => {
return {
type: 'delete-message' as const,
message: innerItem,
conversation,
timestamp,
};
});
}
return undefined;
})
.filter(isNotNil);
eventData = eventData.concat(messageDeletes);
}
if (deleteSync.conversationDeletes?.length) {
const conversationDeletes: Array<DeleteForMeSyncTarget> =
deleteSync.conversationDeletes
.map(item => {
const mostRecentMessages = item.mostRecentMessages
?.map(message => processMessageToDelete(message, logId))
.filter(isNotNil);
const conversation = item.conversation
? processConversationToDelete(item.conversation, logId)
: undefined;
if (mostRecentMessages?.length && conversation) {
return {
type: 'delete-conversation' as const,
conversation,
isFullDelete: Boolean(item.isFullDelete),
mostRecentMessages,
timestamp,
};
}
return undefined;
})
.filter(isNotNil);
eventData = eventData.concat(conversationDeletes);
}
if (deleteSync.localOnlyConversationDeletes?.length) {
const localOnlyConversationDeletes: Array<DeleteForMeSyncTarget> =
deleteSync.localOnlyConversationDeletes
.map(item => {
const conversation = item.conversation
? processConversationToDelete(item.conversation, logId)
: undefined;
if (conversation) {
return {
type: 'delete-local-conversation' as const,
conversation,
timestamp,
};
}
return undefined;
})
.filter(isNotNil);
eventData = eventData.concat(localOnlyConversationDeletes);
}
if (!eventData.length) {
throw new Error(`${logId}: Nothing found in sync message!`);
}
} catch (error: unknown) {
this.removeFromCache(envelope);
throw error;
}
const deleteSyncEventSync = new DeleteForMeSyncEvent(
eventData,
timestamp,
envelope.id,
this.removeFromCache.bind(this, envelope)
);
await this.dispatchAndWait(logId, deleteSyncEventSync);
log.info('handleDeleteForMeSync: finished');
}
private async handleContacts(
envelope: ProcessedEnvelope,
contactSyncProto: Proto.SyncMessage.IContacts
@ -3820,3 +3947,70 @@ function envelopeTypeToCiphertextType(type: number | undefined): number {
throw new Error(`envelopeTypeToCiphertextType: Unknown type ${type}`);
}
function processMessageToDelete(
target: Proto.SyncMessage.DeleteForMe.IAddressableMessage,
logId: string
): MessageToDelete | undefined {
const sentAt = target.sentTimestamp?.toNumber();
if (!isNumber(sentAt)) {
log.warn(
`${logId}/processMessageToDelete: No sentTimestamp found! Dropping AddressableMessage.`
);
return undefined;
}
if (target.authorAci) {
return {
type: 'aci' as const,
authorAci: normalizeAci(
target.authorAci,
`${logId}/processMessageToDelete`
),
sentAt,
};
}
if (target.authorE164) {
return {
type: 'e164' as const,
authorE164: target.authorE164,
sentAt,
};
}
log.warn(
`${logId}/processMessageToDelete: No author field found! Dropping AddressableMessage.`
);
return undefined;
}
function processConversationToDelete(
target: Proto.SyncMessage.DeleteForMe.IConversationIdentifier,
logId: string
): ConversationToDelete | undefined {
const { threadAci, threadGroupId, threadE164 } = target;
if (threadAci) {
return {
type: 'aci' as const,
aci: normalizeAci(threadAci, `${logId}/threadAci`),
};
}
if (threadGroupId) {
return {
type: 'group' as const,
groupId: Buffer.from(threadGroupId).toString('base64'),
};
}
if (threadE164) {
return {
type: 'e164' as const,
e164: threadE164,
};
}
log.warn(
`${logId}/processConversationToDelete: No identifier field found! Dropping ConversationIdentifier.`
);
return undefined;
}

View file

@ -82,6 +82,13 @@ import {
} from '../types/EmbeddedContact';
import { missingCaseError } from '../util/missingCaseError';
import { drop } from '../util/drop';
import type {
ConversationToDelete,
DeleteForMeSyncEventData,
DeleteMessageSyncTarget,
MessageToDelete,
} from './messageReceiverEvents';
import { getConversationFromTarget } from '../util/deleteForMe';
export type SendMetadataType = {
[serviceId: ServiceIdString]: {
@ -1475,6 +1482,91 @@ export default class MessageSender {
};
}
static getDeleteForMeSyncMessage(
data: DeleteForMeSyncEventData
): SingleProtoJobData {
const myAci = window.textsecure.storage.user.getCheckedAci();
const deleteForMe = new Proto.SyncMessage.DeleteForMe();
const messageDeletes: Map<
string,
Array<DeleteMessageSyncTarget>
> = new Map();
data.forEach(item => {
if (item.type === 'delete-message') {
const conversation = getConversationFromTarget(item.conversation);
if (!conversation) {
throw new Error(
'getDeleteForMeSyncMessage: Failed to find conversation for delete-message'
);
}
const existing = messageDeletes.get(conversation.id);
if (existing) {
existing.push(item);
} else {
messageDeletes.set(conversation.id, [item]);
}
} else if (item.type === 'delete-conversation') {
const mostRecentMessages =
item.mostRecentMessages.map(toAddressableMessage);
const conversation = toConversationIdentifier(item.conversation);
deleteForMe.conversationDeletes = deleteForMe.conversationDeletes || [];
deleteForMe.conversationDeletes.push({
mostRecentMessages,
conversation,
isFullDelete: true,
});
} else if (item.type === 'delete-local-conversation') {
const conversation = toConversationIdentifier(item.conversation);
deleteForMe.localOnlyConversationDeletes =
deleteForMe.localOnlyConversationDeletes || [];
deleteForMe.localOnlyConversationDeletes.push({
conversation,
});
} else {
throw missingCaseError(item);
}
});
if (messageDeletes.size > 0) {
for (const items of messageDeletes.values()) {
const first = items[0];
if (!first) {
throw new Error('Failed to fetch first from items');
}
const messages = items.map(item => toAddressableMessage(item.message));
const conversation = toConversationIdentifier(first.conversation);
deleteForMe.messageDeletes = deleteForMe.messageDeletes || [];
deleteForMe.messageDeletes.push({
messages,
conversation,
});
}
}
const syncMessage = this.createSyncMessage();
syncMessage.deleteForMe = deleteForMe;
const contentMessage = new Proto.Content();
contentMessage.syncMessage = syncMessage;
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
return {
contentHint: ContentHint.RESENDABLE,
serviceId: myAci,
isSyncMessage: true,
protoBase64: Bytes.toBase64(
Proto.Content.encode(contentMessage).finish()
),
type: 'deleteForMeSync',
urgent: false,
};
}
async syncReadMessages(
reads: ReadonlyArray<{
senderAci?: AciString;
@ -2253,3 +2345,37 @@ export default class MessageSender {
return this.server.sendChallengeResponse(challengeResponse);
}
}
// Helpers
function toAddressableMessage(message: MessageToDelete) {
const targetMessage = new Proto.SyncMessage.DeleteForMe.AddressableMessage();
targetMessage.sentTimestamp = Long.fromNumber(message.sentAt);
if (message.type === 'aci') {
targetMessage.authorAci = message.authorAci;
} else if (message.type === 'e164') {
targetMessage.authorE164 = message.authorE164;
} else {
throw missingCaseError(message);
}
return targetMessage;
}
function toConversationIdentifier(conversation: ConversationToDelete) {
const targetConversation =
new Proto.SyncMessage.DeleteForMe.ConversationIdentifier();
if (conversation.type === 'aci') {
targetConversation.threadAci = conversation.aci;
} else if (conversation.type === 'group') {
targetConversation.threadGroupId = Bytes.fromBase64(conversation.groupId);
} else if (conversation.type === 'e164') {
targetConversation.threadE164 = conversation.e164;
} else {
throw missingCaseError(conversation);
}
return targetConversation;
}

View file

@ -3,6 +3,7 @@
/* eslint-disable max-classes-per-file */
import type { PublicKey } from '@signalapp/libsignal-client';
import { z } from 'zod';
import type { SignalService as Proto } from '../protobuf';
import type { ServiceIdString, AciString } from '../types/ServiceId';
@ -15,6 +16,7 @@ import type {
import type { ContactDetailsWithAvatar } from './ContactsParser';
import type { CallEventDetails, CallLogEvent } from '../types/CallDisposition';
import type { CallLinkUpdateSyncType } from '../types/CallLink';
import { isAciString } from '../util/isAciString';
export class EmptyEvent extends Event {
constructor() {
@ -456,6 +458,78 @@ export class CallLinkUpdateSyncEvent extends ConfirmableEvent {
}
}
const messageToDeleteSchema = z.union([
z.object({
type: z.literal('aci').readonly(),
authorAci: z.string().refine(isAciString),
sentAt: z.number(),
}),
z.object({
type: z.literal('e164').readonly(),
authorE164: z.string(),
sentAt: z.number(),
}),
]);
export type MessageToDelete = z.infer<typeof messageToDeleteSchema>;
const conversationToDeleteSchema = z.union([
z.object({
type: z.literal('group').readonly(),
groupId: z.string(),
}),
z.object({
type: z.literal('aci').readonly(),
aci: z.string().refine(isAciString),
}),
z.object({
type: z.literal('e164').readonly(),
e164: z.string(),
}),
]);
export type ConversationToDelete = z.infer<typeof conversationToDeleteSchema>;
export const deleteMessageSchema = z.object({
type: z.literal('delete-message').readonly(),
conversation: conversationToDeleteSchema,
message: messageToDeleteSchema,
timestamp: z.number(),
});
export type DeleteMessageSyncTarget = z.infer<typeof deleteMessageSchema>;
export const deleteConversationSchema = z.object({
type: z.literal('delete-conversation').readonly(),
conversation: conversationToDeleteSchema,
mostRecentMessages: z.array(messageToDeleteSchema),
isFullDelete: z.boolean(),
timestamp: z.number(),
});
export const deleteLocalConversationSchema = z.object({
type: z.literal('delete-local-conversation').readonly(),
conversation: conversationToDeleteSchema,
timestamp: z.number(),
});
export const deleteForMeSyncTargetSchema = z.union([
deleteMessageSchema,
deleteConversationSchema,
deleteLocalConversationSchema,
]);
export type DeleteForMeSyncTarget = z.infer<typeof deleteForMeSyncTargetSchema>;
export type DeleteForMeSyncEventData = ReadonlyArray<DeleteForMeSyncTarget>;
export class DeleteForMeSyncEvent extends ConfirmableEvent {
constructor(
public readonly deleteForMeSync: DeleteForMeSyncEventData,
public readonly timestamp: number,
public readonly envelopeId: string,
confirm: ConfirmCallback
) {
super('deleteForMeSync', confirm);
}
}
export type CallLogEventSyncEventData = Readonly<{
event: CallLogEvent;
timestamp: number;