Support for local deletes synced to all your devices
This commit is contained in:
parent
06f71a7ef8
commit
11eb1782a7
39 changed files with 2094 additions and 72 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue