mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-04-17 23:34:14 +01:00
Admin Delete
This commit is contained in:
266
ts/util/canDeleteForEveryone.preload.ts
Normal file
266
ts/util/canDeleteForEveryone.preload.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { AciString, ServiceIdString } from '../types/ServiceId.std.js';
|
||||
import type {
|
||||
ConversationAttributesType,
|
||||
ReadonlyMessageAttributesType,
|
||||
} from '../model-types.js';
|
||||
import { getMessageAge } from './getMessageAge.std.js';
|
||||
import {
|
||||
getAdminDeleteMaxAgeMs,
|
||||
getNormalDeleteMaxAgeMs,
|
||||
} from './getDeleteMaxAgeMs.dom.js';
|
||||
import { DAY } from './durations/index.std.js';
|
||||
import { isGroupV2, isMe } from './whatTypeOfConversation.dom.js';
|
||||
import { isSignalConversation } from './isSignalConversation.dom.js';
|
||||
import { getSourceServiceId } from '../messages/sources.preload.js';
|
||||
import { SignalService as Proto } from '../protobuf/index.std.js';
|
||||
|
||||
export type DeleteForEveryoneMessage = Pick<
|
||||
ReadonlyMessageAttributesType,
|
||||
| 'type'
|
||||
| 'sourceServiceId'
|
||||
| 'sent_at'
|
||||
| 'serverTimestamp'
|
||||
| 'deletedForEveryone'
|
||||
| 'sms'
|
||||
>;
|
||||
|
||||
export type DeleteForEveryoneConversation = Pick<
|
||||
ConversationAttributesType,
|
||||
'id' | 'e164' | 'serviceId' | 'groupId' | 'groupVersion'
|
||||
>;
|
||||
|
||||
type Result<T extends object = Record<never, never>> =
|
||||
| Readonly<{ ok: true } & T>
|
||||
| Readonly<{ ok: false; reason: string }>;
|
||||
|
||||
function checkCommon(
|
||||
targetConversation: DeleteForEveryoneConversation,
|
||||
targetMessage: DeleteForEveryoneMessage,
|
||||
options?: { allowAlreadyDeleted?: boolean }
|
||||
): Result {
|
||||
if (isSignalConversation(targetConversation)) {
|
||||
return { ok: false, reason: 'signal conversation' };
|
||||
}
|
||||
if (isMe(targetConversation)) {
|
||||
return { ok: false, reason: 'note to self conversation' };
|
||||
}
|
||||
if (!options?.allowAlreadyDeleted && targetMessage.deletedForEveryone) {
|
||||
return { ok: false, reason: 'already deleted' };
|
||||
}
|
||||
if (targetMessage.sms) {
|
||||
return { ok: false, reason: 'sms message' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function canUseNormalDelete(options: {
|
||||
deleterAci: AciString;
|
||||
messageAuthorAci: ServiceIdString | undefined;
|
||||
messageAge: number;
|
||||
gracePeriodMs?: number;
|
||||
}): Result {
|
||||
const {
|
||||
deleterAci,
|
||||
messageAuthorAci,
|
||||
messageAge,
|
||||
gracePeriodMs = 0,
|
||||
} = options;
|
||||
if (deleterAci !== messageAuthorAci) {
|
||||
return { ok: false, reason: 'not message author' };
|
||||
}
|
||||
if (messageAge > getNormalDeleteMaxAgeMs() + gracePeriodMs) {
|
||||
return { ok: false, reason: 'message is too old' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function isMemberGroupAdmin(
|
||||
conversation: Pick<ConversationAttributesType, 'membersV2'>,
|
||||
aci: AciString
|
||||
): boolean {
|
||||
const members = conversation.membersV2 ?? [];
|
||||
const member = members.find(m => m.aci === aci);
|
||||
return member?.role === Proto.Member.Role.ADMINISTRATOR;
|
||||
}
|
||||
|
||||
function canUseAdminDelete(options: {
|
||||
targetConversation: DeleteForEveryoneConversation;
|
||||
isDeleterGroupAdmin: boolean;
|
||||
messageAge: number;
|
||||
gracePeriodMs?: number;
|
||||
}): Result {
|
||||
const {
|
||||
targetConversation,
|
||||
isDeleterGroupAdmin,
|
||||
messageAge,
|
||||
gracePeriodMs = 0,
|
||||
} = options;
|
||||
if (!isGroupV2(targetConversation)) {
|
||||
return { ok: false, reason: 'not a group conversation' };
|
||||
}
|
||||
if (!isDeleterGroupAdmin) {
|
||||
return { ok: false, reason: 'does not have admin role' };
|
||||
}
|
||||
if (messageAge > getAdminDeleteMaxAgeMs() + gracePeriodMs) {
|
||||
return { ok: false, reason: 'message is too old' };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export type CanDeleteForEveryoneOptions = Readonly<{
|
||||
targetMessage: DeleteForEveryoneMessage;
|
||||
targetConversation: DeleteForEveryoneConversation;
|
||||
ourAci: AciString;
|
||||
isDeleterGroupAdmin: boolean;
|
||||
}>;
|
||||
|
||||
export type CanDeleteForEveryoneResult = Result<{
|
||||
needsAdminDelete: boolean;
|
||||
}>;
|
||||
|
||||
export function canSendDeleteForEveryone(
|
||||
options: CanDeleteForEveryoneOptions
|
||||
): CanDeleteForEveryoneResult {
|
||||
const { targetConversation, targetMessage, ourAci, isDeleterGroupAdmin } =
|
||||
options;
|
||||
|
||||
const commonCheck = checkCommon(targetConversation, targetMessage);
|
||||
if (!commonCheck.ok) {
|
||||
return commonCheck;
|
||||
}
|
||||
|
||||
const messageAuthorAci = getSourceServiceId(targetMessage);
|
||||
const messageAge = getMessageAge(Date.now(), targetMessage);
|
||||
|
||||
// Prefer normal delete for own messages
|
||||
const normalCheck = canUseNormalDelete({
|
||||
deleterAci: ourAci,
|
||||
messageAuthorAci,
|
||||
messageAge,
|
||||
});
|
||||
if (normalCheck.ok) {
|
||||
return { ok: true, needsAdminDelete: false };
|
||||
}
|
||||
|
||||
// Admin delete for group messages
|
||||
const adminCheck = canUseAdminDelete({
|
||||
targetConversation,
|
||||
isDeleterGroupAdmin,
|
||||
messageAge,
|
||||
});
|
||||
if (adminCheck.ok) {
|
||||
return { ok: true, needsAdminDelete: true };
|
||||
}
|
||||
|
||||
return { ok: false, reason: 'no permission' };
|
||||
}
|
||||
|
||||
export type CanRetrySendDeleteForEveryoneOptions = Readonly<{
|
||||
targetMessage: DeleteForEveryoneMessage &
|
||||
Pick<ReadonlyMessageAttributesType, 'deletedForEveryoneFailed'>;
|
||||
targetConversation: DeleteForEveryoneConversation;
|
||||
isAdminDelete: boolean;
|
||||
isDeleterGroupAdmin: boolean;
|
||||
ourAci: AciString;
|
||||
}>;
|
||||
|
||||
export type CanRetrySendDeleteForEveryoneResult = Result;
|
||||
|
||||
export function canRetrySendDeleteForEveryone(
|
||||
options: CanRetrySendDeleteForEveryoneOptions
|
||||
): CanRetrySendDeleteForEveryoneResult {
|
||||
const {
|
||||
targetMessage,
|
||||
targetConversation,
|
||||
isAdminDelete,
|
||||
isDeleterGroupAdmin,
|
||||
ourAci,
|
||||
} = options;
|
||||
|
||||
if (
|
||||
!targetMessage.deletedForEveryone ||
|
||||
!targetMessage.deletedForEveryoneFailed
|
||||
) {
|
||||
return { ok: false, reason: 'not a failed delete' };
|
||||
}
|
||||
|
||||
const commonCheck = checkCommon(targetConversation, targetMessage, {
|
||||
allowAlreadyDeleted: true,
|
||||
});
|
||||
if (!commonCheck.ok) {
|
||||
return commonCheck;
|
||||
}
|
||||
|
||||
const messageAuthorAci = getSourceServiceId(targetMessage);
|
||||
const messageAge = getMessageAge(Date.now(), targetMessage);
|
||||
|
||||
if (!isAdminDelete) {
|
||||
return canUseNormalDelete({
|
||||
deleterAci: ourAci,
|
||||
messageAuthorAci,
|
||||
messageAge,
|
||||
});
|
||||
}
|
||||
|
||||
return canUseAdminDelete({
|
||||
targetConversation,
|
||||
isDeleterGroupAdmin,
|
||||
messageAge,
|
||||
});
|
||||
}
|
||||
|
||||
export type CanReceiveDeleteForEveryoneOptions = Readonly<{
|
||||
targetMessage: DeleteForEveryoneMessage;
|
||||
targetConversation: ConversationAttributesType;
|
||||
isAdminDelete: boolean;
|
||||
deleteServerTimestamp: number;
|
||||
deleteSentByAci: AciString;
|
||||
}>;
|
||||
|
||||
export type CanReceiveForEveryoneResult = Result;
|
||||
|
||||
const MESSAGE_SEND_GRACE_PERIOD = DAY;
|
||||
|
||||
export function canReceiveDeleteForEveryone(
|
||||
options: CanReceiveDeleteForEveryoneOptions
|
||||
): CanReceiveForEveryoneResult {
|
||||
const {
|
||||
targetMessage,
|
||||
targetConversation,
|
||||
isAdminDelete,
|
||||
deleteServerTimestamp,
|
||||
deleteSentByAci,
|
||||
} = options;
|
||||
|
||||
const commonCheck = checkCommon(targetConversation, targetMessage);
|
||||
if (!commonCheck.ok) {
|
||||
return commonCheck;
|
||||
}
|
||||
|
||||
const messageAuthorAci = getSourceServiceId(targetMessage);
|
||||
const messageAge = getMessageAge(deleteServerTimestamp, targetMessage);
|
||||
|
||||
if (!isAdminDelete) {
|
||||
return canUseNormalDelete({
|
||||
deleterAci: deleteSentByAci,
|
||||
messageAuthorAci,
|
||||
messageAge,
|
||||
gracePeriodMs: MESSAGE_SEND_GRACE_PERIOD,
|
||||
});
|
||||
}
|
||||
|
||||
const isDeleterGroupAdmin = isMemberGroupAdmin(
|
||||
targetConversation,
|
||||
deleteSentByAci
|
||||
);
|
||||
|
||||
return canUseAdminDelete({
|
||||
targetConversation,
|
||||
isDeleterGroupAdmin,
|
||||
messageAge,
|
||||
gracePeriodMs: MESSAGE_SEND_GRACE_PERIOD,
|
||||
});
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export async function eraseMessageContents(
|
||||
| 'view-once-sent'
|
||||
| 'unsupported-message'
|
||||
| 'delete-for-everyone',
|
||||
additionalProperties = {}
|
||||
additionalProperties: Partial<MessageAttributesType> = {}
|
||||
): Promise<void> {
|
||||
log.info(
|
||||
`Erasing data for message ${getMessageIdForLogging(message.attributes)}: ${reason}`
|
||||
|
||||
@@ -2,77 +2,97 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { DeleteAttributesType } from '../messageModifiers/Deletes.preload.js';
|
||||
import type { MessageAttributesType } from '../model-types.d.ts';
|
||||
import type { MessageModel } from '../models/messages.preload.js';
|
||||
import { createLogger } from '../logging/log.std.js';
|
||||
import { isMe } from './whatTypeOfConversation.dom.js';
|
||||
import { getAuthorId } from '../messages/sources.preload.js';
|
||||
import { getSourceServiceId } from '../messages/sources.preload.js';
|
||||
import { isStory } from '../state/selectors/message.preload.js';
|
||||
import { isTooOldToModifyMessage } from './isTooOldToModifyMessage.std.js';
|
||||
import { canReceiveDeleteForEveryone } from './canDeleteForEveryone.preload.js';
|
||||
import { isAciString } from './isAciString.std.js';
|
||||
import { eraseMessageContents } from './cleanup.preload.js';
|
||||
import { notificationService } from '../services/notifications.preload.js';
|
||||
import { DataWriter } from '../sql/Client.preload.js';
|
||||
|
||||
const log = createLogger('deleteForEveryone');
|
||||
|
||||
export async function deleteForEveryone(
|
||||
/**
|
||||
* Receive path: validate an incoming delete-for-everyone, then apply it.
|
||||
*/
|
||||
export async function receiveDeleteForEveryone(
|
||||
message: MessageModel,
|
||||
doe: Pick<
|
||||
DeleteAttributesType,
|
||||
'fromId' | 'targetSentTimestamp' | 'serverTimestamp'
|
||||
| 'isAdminDelete'
|
||||
| 'targetSentTimestamp'
|
||||
| 'deleteServerTimestamp'
|
||||
| 'deleteSentByAci'
|
||||
| 'targetConversationId'
|
||||
>,
|
||||
{ shouldPersist = true }: { shouldPersist?: boolean } = {}
|
||||
): Promise<void> {
|
||||
if (isDeletionByMe(message, doe)) {
|
||||
const conversation = window.ConversationController.get(
|
||||
message.get('conversationId')
|
||||
);
|
||||
const conversation = window.ConversationController.get(
|
||||
message.get('conversationId')
|
||||
);
|
||||
|
||||
// Our 1:1 stories are deleted through ts/util/onStoryRecipientUpdate.ts
|
||||
if (
|
||||
isStory(message.attributes) &&
|
||||
conversation &&
|
||||
isMe(conversation.attributes)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleDeleteForEveryone(message, doe, { shouldPersist });
|
||||
// Our 1:1 stories are deleted through ts/util/onStoryRecipientUpdate.ts
|
||||
if (
|
||||
isStory(message.attributes) &&
|
||||
conversation &&
|
||||
isMe(conversation.attributes)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTooOldToModifyMessage(doe.serverTimestamp, message.attributes)) {
|
||||
log.warn('Received late DOE. Dropping.', {
|
||||
fromId: doe.fromId,
|
||||
const messageAuthorAci = getSourceServiceId(message.attributes);
|
||||
if (!messageAuthorAci || !isAciString(messageAuthorAci)) {
|
||||
log.warn('receiveDeleteForEveryone: Cannot determine message author ACI');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!conversation) {
|
||||
log.warn('receiveDeleteForEveryone: No conversation found');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = canReceiveDeleteForEveryone({
|
||||
isAdminDelete: doe.isAdminDelete,
|
||||
targetMessage: message.attributes,
|
||||
targetConversation: conversation.attributes,
|
||||
deleteSentByAci: doe.deleteSentByAci,
|
||||
deleteServerTimestamp: doe.deleteServerTimestamp,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
log.warn('receiveDeleteForEveryone: Rejected.', {
|
||||
reason: result.reason,
|
||||
targetConversationId: doe.targetConversationId,
|
||||
targetSentTimestamp: doe.targetSentTimestamp,
|
||||
messageServerTimestamp: message.get('serverTimestamp'),
|
||||
messageSentAt: message.get('sent_at'),
|
||||
deleteServerTimestamp: doe.serverTimestamp,
|
||||
deleteServerTimestamp: doe.deleteServerTimestamp,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await handleDeleteForEveryone(message, doe, { shouldPersist });
|
||||
await applyDeleteForEveryone(message, doe, { shouldPersist });
|
||||
}
|
||||
|
||||
function isDeletionByMe(
|
||||
message: Readonly<MessageModel>,
|
||||
doe: Pick<DeleteAttributesType, 'fromId'>
|
||||
): boolean {
|
||||
const ourConversationId =
|
||||
window.ConversationController.getOurConversationIdOrThrow();
|
||||
return (
|
||||
getAuthorId(message.attributes) === ourConversationId &&
|
||||
doe.fromId === ourConversationId
|
||||
);
|
||||
}
|
||||
|
||||
export async function handleDeleteForEveryone(
|
||||
/**
|
||||
* Apply a delete-for-everyone to a message. No validation — caller is
|
||||
* responsible for checking canDeleteForEveryone first.
|
||||
*/
|
||||
export async function applyDeleteForEveryone(
|
||||
message: MessageModel,
|
||||
del: Pick<
|
||||
DeleteAttributesType,
|
||||
'fromId' | 'targetSentTimestamp' | 'serverTimestamp'
|
||||
| 'isAdminDelete'
|
||||
| 'targetSentTimestamp'
|
||||
| 'deleteServerTimestamp'
|
||||
| 'deleteSentByAci'
|
||||
| 'targetConversationId'
|
||||
>,
|
||||
{ shouldPersist = true }: { shouldPersist?: boolean }
|
||||
{ shouldPersist = true }: { shouldPersist?: boolean } = {}
|
||||
): Promise<void> {
|
||||
if (message.deletingForEveryone || message.get('deletedForEveryone')) {
|
||||
return;
|
||||
@@ -80,10 +100,11 @@ export async function handleDeleteForEveryone(
|
||||
|
||||
log.info('Handling DOE.', {
|
||||
messageId: message.id,
|
||||
fromId: del.fromId,
|
||||
isAdminDelete: del.isAdminDelete,
|
||||
targetConversationId: del.targetConversationId,
|
||||
targetSentTimestamp: del.targetSentTimestamp,
|
||||
messageServerTimestamp: message.get('serverTimestamp'),
|
||||
deleteServerTimestamp: del.serverTimestamp,
|
||||
deleteServerTimestamp: del.deleteServerTimestamp,
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -94,10 +115,14 @@ export async function handleDeleteForEveryone(
|
||||
notificationService.removeBy({ messageId: message.get('id') });
|
||||
|
||||
// Erase the contents of this message
|
||||
await eraseMessageContents(message, 'delete-for-everyone', {
|
||||
const additionalProps: Partial<MessageAttributesType> = {
|
||||
deletedForEveryone: true,
|
||||
reactions: [],
|
||||
});
|
||||
};
|
||||
if (del.isAdminDelete) {
|
||||
additionalProps.deletedForEveryoneByAdminAci = del.deleteSentByAci;
|
||||
}
|
||||
await eraseMessageContents(message, 'delete-for-everyone', additionalProps);
|
||||
|
||||
if (shouldPersist) {
|
||||
// We delete the message first, before re-saving it -- this causes any foreign key
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright 2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { DAY } from './durations/index.std.js';
|
||||
import { sendDeleteForEveryoneMessage } from './sendDeleteForEveryoneMessage.preload.js';
|
||||
import { getMessageById } from '../messages/getMessageById.preload.js';
|
||||
import { createLogger } from '../logging/log.std.js';
|
||||
@@ -34,7 +33,6 @@ export async function deleteGroupStoryReplyForEveryone(
|
||||
}
|
||||
|
||||
void sendDeleteForEveryoneMessage(group.attributes, {
|
||||
deleteForEveryoneDuration: DAY,
|
||||
id: replyMessageId,
|
||||
timestamp,
|
||||
});
|
||||
|
||||
@@ -41,7 +41,6 @@ export async function deleteStoryForEveryone(
|
||||
);
|
||||
if (sourceConversation && isGroupV2(sourceConversation.attributes)) {
|
||||
void sendDeleteForEveryoneMessage(sourceConversation.attributes, {
|
||||
deleteForEveryoneDuration: DAY,
|
||||
id: story.messageId,
|
||||
timestamp: story.timestamp,
|
||||
});
|
||||
|
||||
26
ts/util/getDeleteMaxAgeMs.dom.ts
Normal file
26
ts/util/getDeleteMaxAgeMs.dom.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { getValue } from '../RemoteConfig.dom.js';
|
||||
import { safeParseInteger } from './numbers.std.js';
|
||||
import { DAY, SECOND } from './durations/index.std.js';
|
||||
|
||||
const DEFAULT_DELETE_MAX_AGE_MS = DAY;
|
||||
|
||||
function parseMaxAgeSecsToMs(configValue: string | undefined) {
|
||||
if (configValue != null) {
|
||||
const parsed = safeParseInteger(configValue);
|
||||
if (parsed != null && parsed > 0) {
|
||||
return parsed * SECOND;
|
||||
}
|
||||
}
|
||||
return DEFAULT_DELETE_MAX_AGE_MS;
|
||||
}
|
||||
|
||||
export function getNormalDeleteMaxAgeMs(): number {
|
||||
return parseMaxAgeSecsToMs(getValue('global.normalDeleteMaxAgeInSeconds'));
|
||||
}
|
||||
|
||||
export function getAdminDeleteMaxAgeMs(): number {
|
||||
return parseMaxAgeSecsToMs(getValue('global.adminDeleteMaxAgeInSeconds'));
|
||||
}
|
||||
@@ -1,19 +1,71 @@
|
||||
// Copyright 2023 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { AciString } from '../types/ServiceId.std.js';
|
||||
import type { ConversationAttributesType } from '../model-types.d.ts';
|
||||
import type { LastMessageType } from '../state/ducks/conversations.preload.js';
|
||||
import { dropNull } from './dropNull.std.js';
|
||||
import { findAndFormatContact } from './findAndFormatContact.preload.js';
|
||||
import { hydrateRanges } from './BodyRange.node.js';
|
||||
import { stripNewlinesForLeftPane } from './stripNewlinesForLeftPane.std.js';
|
||||
import { itemStorage } from '../textsecure/Storage.preload.js';
|
||||
import { getTitle } from './getTitle.preload.js';
|
||||
|
||||
function getNameForAci(
|
||||
aci: AciString | null | undefined,
|
||||
options?: { isShort?: boolean }
|
||||
): string | null {
|
||||
if (aci == null) {
|
||||
return null;
|
||||
}
|
||||
const conversation = window.ConversationController.get(aci);
|
||||
if (conversation != null) {
|
||||
return getTitle(conversation.attributes, options);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getDisplayNameForAci(
|
||||
aci: AciString | null | undefined,
|
||||
ourAci: AciString | null | undefined
|
||||
): string | null {
|
||||
if (aci === ourAci) {
|
||||
return window.SignalContext.i18n('icu:you');
|
||||
}
|
||||
return getNameForAci(aci, { isShort: true });
|
||||
}
|
||||
|
||||
export function getLastMessage(
|
||||
conversationAttrs: ConversationAttributesType
|
||||
): LastMessageType | undefined {
|
||||
const ourAci = itemStorage.user.getAci();
|
||||
|
||||
if (conversationAttrs.lastMessageDeletedForEveryone) {
|
||||
return { deletedForEveryone: true };
|
||||
const { lastMessageAuthorAci, lastMessageDeletedForEveryoneByAdminAci } =
|
||||
conversationAttrs;
|
||||
|
||||
// Only show admin name when the admin deleted someone else's message
|
||||
const isAdminDeletingOwnMessage =
|
||||
lastMessageDeletedForEveryoneByAdminAci != null &&
|
||||
lastMessageDeletedForEveryoneByAdminAci === lastMessageAuthorAci;
|
||||
const deletedByAdminName = isAdminDeletingOwnMessage
|
||||
? null
|
||||
: getNameForAci(lastMessageDeletedForEveryoneByAdminAci);
|
||||
|
||||
const authorName =
|
||||
getDisplayNameForAci(lastMessageAuthorAci, ourAci) ??
|
||||
// Deprecated: fall back to lastMessageAuthor from old database rows
|
||||
conversationAttrs.lastMessageAuthor ??
|
||||
null;
|
||||
|
||||
return {
|
||||
deletedForEveryone: true,
|
||||
deletedByAdminName,
|
||||
isOutgoing: lastMessageAuthorAci === ourAci,
|
||||
authorName,
|
||||
};
|
||||
}
|
||||
|
||||
const lastMessageText = conversationAttrs.lastMessage;
|
||||
if (!lastMessageText) {
|
||||
return undefined;
|
||||
@@ -25,8 +77,14 @@ export function getLastMessage(
|
||||
const text = stripNewlinesForLeftPane(lastMessageText);
|
||||
const prefix = conversationAttrs.lastMessagePrefix;
|
||||
|
||||
const author =
|
||||
getDisplayNameForAci(conversationAttrs.lastMessageAuthorAci, ourAci) ??
|
||||
// Deprecated: fall back to lastMessageAuthor from old database rows
|
||||
conversationAttrs.lastMessageAuthor ??
|
||||
null;
|
||||
|
||||
return {
|
||||
author: dropNull(conversationAttrs.lastMessageAuthor),
|
||||
author,
|
||||
bodyRanges,
|
||||
deletedForEveryone: false,
|
||||
prefix,
|
||||
|
||||
12
ts/util/getMessageAge.std.ts
Normal file
12
ts/util/getMessageAge.std.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ReadonlyMessageAttributesType } from '../model-types.d.ts';
|
||||
|
||||
export function getMessageAge(
|
||||
now: number,
|
||||
message: Pick<ReadonlyMessageAttributesType, 'serverTimestamp' | 'sent_at'>
|
||||
): number {
|
||||
const messageTimestamp = message.serverTimestamp ?? message.sent_at ?? 0;
|
||||
return Math.abs(now - messageTimestamp);
|
||||
}
|
||||
@@ -5,8 +5,11 @@ import type {
|
||||
ConversationAttributesType,
|
||||
ReadonlyMessageAttributesType,
|
||||
} from '../model-types.d.ts';
|
||||
import type { AciString } from '../types/ServiceId.std.js';
|
||||
import { isIncoming, isOutgoing } from '../state/selectors/message.preload.js';
|
||||
import { isAciString } from './isAciString.std.js';
|
||||
import { getTitle } from './getTitle.preload.js';
|
||||
import { itemStorage } from '../textsecure/Storage.preload.js';
|
||||
|
||||
function getIncomingContact(
|
||||
messageAttributes: ReadonlyMessageAttributesType
|
||||
@@ -23,6 +26,7 @@ function getIncomingContact(
|
||||
.attributes;
|
||||
}
|
||||
|
||||
/** @deprecated Use getMessageAuthorAci instead */
|
||||
export function getMessageAuthorText(
|
||||
messageAttributes?: ReadonlyMessageAttributesType
|
||||
): string | undefined {
|
||||
@@ -48,3 +52,18 @@ export function getMessageAuthorText(
|
||||
// it might be a group notification, so we return undefined
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getMessageAuthorAci(
|
||||
messageAttributes: ReadonlyMessageAttributesType
|
||||
): AciString | null {
|
||||
if (isOutgoing(messageAttributes)) {
|
||||
return itemStorage.user.getCheckedAci();
|
||||
}
|
||||
if (isIncoming(messageAttributes)) {
|
||||
const { sourceServiceId } = messageAttributes;
|
||||
if (isAciString(sourceServiceId)) {
|
||||
return sourceServiceId;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { getMessageIdForLogging } from './idForLogging.preload.js';
|
||||
import { hasErrors } from '../state/selectors/message.preload.js';
|
||||
import { isIncoming, isOutgoing } from '../messages/helpers.std.js';
|
||||
import { isDirectConversation } from './whatTypeOfConversation.dom.js';
|
||||
import { isTooOldToModifyMessage } from './isTooOldToModifyMessage.std.js';
|
||||
import { isTooOldToEditMessage } from './isTooOldToEditMessage.std.js';
|
||||
import { queueAttachmentDownloads } from './queueAttachmentDownloads.preload.js';
|
||||
import { modifyTargetMessage } from './modifyTargetMessage.preload.js';
|
||||
import { isMessageNoteToSelf } from './isMessageNoteToSelf.dom.js';
|
||||
@@ -93,7 +93,7 @@ export async function handleEditMessage(
|
||||
if (
|
||||
serverTimestamp &&
|
||||
!isMessageNoteToSelf(mainMessage) &&
|
||||
isTooOldToModifyMessage(serverTimestamp, mainMessage)
|
||||
isTooOldToEditMessage(serverTimestamp, mainMessage)
|
||||
) {
|
||||
log.warn(`${idLog}: cannot edit message older than 48h`, serverTimestamp);
|
||||
return;
|
||||
|
||||
18
ts/util/isAdminDeleteEnabled.dom.ts
Normal file
18
ts/util/isAdminDeleteEnabled.dom.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright 2026 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { isFeaturedEnabledNoRedux } from './isFeatureEnabled.dom.js';
|
||||
|
||||
export function isAdminDeleteReceiveEnabled(): boolean {
|
||||
return isFeaturedEnabledNoRedux({
|
||||
betaKey: 'desktop.adminDelete.receive.beta',
|
||||
prodKey: 'desktop.adminDelete.receive.prod',
|
||||
});
|
||||
}
|
||||
|
||||
export function isAdminDeleteSendEnabled(): boolean {
|
||||
return isFeaturedEnabledNoRedux({
|
||||
betaKey: 'desktop.adminDelete.send.beta',
|
||||
prodKey: 'desktop.adminDelete.send.prod',
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
// Copyright 2023 Signal Messenger, LLC
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ReadonlyMessageAttributesType } from '../model-types.d.ts';
|
||||
import { getMessageAge } from './getMessageAge.std.js';
|
||||
import { DAY } from './durations/index.std.js';
|
||||
|
||||
export function isTooOldToModifyMessage(
|
||||
export function isTooOldToEditMessage(
|
||||
serverTimestamp: number,
|
||||
message: Pick<ReadonlyMessageAttributesType, 'serverTimestamp' | 'sent_at'>
|
||||
): boolean {
|
||||
const messageTimestamp = message.serverTimestamp || message.sent_at || 0;
|
||||
const delta = Math.abs(serverTimestamp - messageTimestamp);
|
||||
return delta > DAY * 2;
|
||||
return getMessageAge(serverTimestamp, message) > DAY * 2;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
sendStateReducer,
|
||||
} from '../messages/MessageSendState.std.js';
|
||||
import { canConversationBeUnarchived } from './canConversationBeUnarchived.preload.js';
|
||||
import { deleteForEveryone } from './deleteForEveryone.preload.js';
|
||||
import { receiveDeleteForEveryone } from './deleteForEveryone.preload.js';
|
||||
import { drop } from './drop.std.js';
|
||||
import { handleEditMessage } from './handleEditMessage.preload.js';
|
||||
import { isGroup } from './whatTypeOfConversation.dom.js';
|
||||
@@ -349,7 +349,9 @@ export async function modifyTargetMessage(
|
||||
const deletes = Deletes.forMessage(message.attributes);
|
||||
await Promise.all(
|
||||
deletes.map(async del => {
|
||||
await deleteForEveryone(message, del, { shouldPersist: !isFirstRun });
|
||||
await receiveDeleteForEveryone(message, del, {
|
||||
shouldPersist: !isFirstRun,
|
||||
});
|
||||
changed = true;
|
||||
})
|
||||
);
|
||||
|
||||
@@ -13,7 +13,8 @@ import { queueUpdateMessage } from './messageBatcher.preload.js';
|
||||
import { isMe } from './whatTypeOfConversation.dom.js';
|
||||
import { drop } from './drop.std.js';
|
||||
import { fromServiceIdBinaryOrString } from './ServiceId.node.js';
|
||||
import { handleDeleteForEveryone } from './deleteForEveryone.preload.js';
|
||||
import { applyDeleteForEveryone } from './deleteForEveryone.preload.js';
|
||||
import { itemStorage } from '../textsecure/Storage.preload.js';
|
||||
import { MessageModel } from '../models/messages.preload.js';
|
||||
|
||||
const { isEqual } = lodash;
|
||||
@@ -199,12 +200,14 @@ export async function onStoryRecipientUpdate(
|
||||
// sent timestamp doesn't happen (it would return all copies of the
|
||||
// story, not just the one we want to delete).
|
||||
drop(
|
||||
handleDeleteForEveryone(
|
||||
applyDeleteForEveryone(
|
||||
message,
|
||||
{
|
||||
fromId: ourConversationId,
|
||||
serverTimestamp: Number(item.serverTimestamp),
|
||||
isAdminDelete: false,
|
||||
targetSentTimestamp: item.timestamp,
|
||||
deleteServerTimestamp: Number(item.serverTimestamp),
|
||||
deleteSentByAci: itemStorage.user.getCheckedAci(),
|
||||
targetConversationId: ourConversationId,
|
||||
},
|
||||
{ shouldPersist: true }
|
||||
)
|
||||
|
||||
@@ -4,13 +4,12 @@
|
||||
import type { ConversationAttributesType } from '../model-types.d.ts';
|
||||
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue.preload.js';
|
||||
import * as Errors from '../types/errors.std.js';
|
||||
import { DAY } from './durations/index.std.js';
|
||||
import { createLogger } from '../logging/log.std.js';
|
||||
import {
|
||||
conversationJobQueue,
|
||||
conversationQueueJobEnum,
|
||||
} from '../jobs/conversationJobQueue.preload.js';
|
||||
import { deleteForEveryone } from './deleteForEveryone.preload.js';
|
||||
import { applyDeleteForEveryone } from './deleteForEveryone.preload.js';
|
||||
import {
|
||||
getConversationIdForLogging,
|
||||
getMessageIdForLogging,
|
||||
@@ -19,40 +18,53 @@ import { getMessageById } from '../messages/getMessageById.preload.js';
|
||||
import { getRecipientConversationIds } from './getRecipientConversationIds.dom.js';
|
||||
import { getRecipients } from './getRecipients.dom.js';
|
||||
import { repeat, zipObject } from './iterables.std.js';
|
||||
import { isMe } from './whatTypeOfConversation.dom.js';
|
||||
import { isOutgoing } from '../state/selectors/message.preload.js';
|
||||
import { canSendDeleteForEveryone } from './canDeleteForEveryone.preload.js';
|
||||
import { areWeAdmin } from './areWeAdmin.preload.js';
|
||||
import { isAciString } from './isAciString.std.js';
|
||||
import { itemStorage } from '../textsecure/Storage.preload.js';
|
||||
import { strictAssert } from './assert.std.js';
|
||||
|
||||
const log = createLogger('sendDeleteForEveryoneMessage');
|
||||
|
||||
export async function sendDeleteForEveryoneMessage(
|
||||
conversationAttributes: ConversationAttributesType,
|
||||
options: {
|
||||
deleteForEveryoneDuration?: number;
|
||||
id: string;
|
||||
timestamp: number;
|
||||
}
|
||||
): Promise<void> {
|
||||
const {
|
||||
deleteForEveryoneDuration,
|
||||
timestamp: targetTimestamp,
|
||||
id: messageId,
|
||||
} = options;
|
||||
const { timestamp: targetTimestamp, id: messageId } = options;
|
||||
const message = await getMessageById(messageId);
|
||||
if (!message) {
|
||||
throw new Error('sendDeleteForEveryoneMessage: Cannot find message!');
|
||||
}
|
||||
const idForLogging = getMessageIdForLogging(message.attributes);
|
||||
|
||||
// If conversation is a Note To Self, no deletion time limits apply.
|
||||
if (!isMe(conversationAttributes)) {
|
||||
const timestamp = Date.now();
|
||||
const maxDuration = deleteForEveryoneDuration || DAY;
|
||||
if (timestamp - targetTimestamp > maxDuration) {
|
||||
throw new Error(
|
||||
`Cannot send DOE for a message older than ${maxDuration}`
|
||||
);
|
||||
}
|
||||
const ourAci = itemStorage.user.getCheckedAci();
|
||||
const { sourceServiceId } = message.attributes;
|
||||
const messageAuthorAci = isOutgoing(message.attributes)
|
||||
? ourAci
|
||||
: sourceServiceId;
|
||||
|
||||
strictAssert(
|
||||
isAciString(messageAuthorAci),
|
||||
'sendDeleteForEveryoneMessage: Needs message author ACI'
|
||||
);
|
||||
|
||||
const result = canSendDeleteForEveryone({
|
||||
targetMessage: message.attributes,
|
||||
targetConversation: conversationAttributes,
|
||||
ourAci,
|
||||
isDeleterGroupAdmin: areWeAdmin(conversationAttributes),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(`Cannot send DOE: ${result.reason}`);
|
||||
}
|
||||
|
||||
const { needsAdminDelete: isAdminDelete } = result;
|
||||
|
||||
message.set({
|
||||
deletedForEveryoneSendStatus: zipObject(
|
||||
getRecipientConversationIds(conversationAttributes),
|
||||
@@ -66,17 +78,17 @@ export async function sendDeleteForEveryoneMessage(
|
||||
|
||||
log.info(
|
||||
`enqueuing DeleteForEveryone: ${idForLogging} ` +
|
||||
`in conversation ${conversationIdForLogging}`
|
||||
`in conversation ${conversationIdForLogging} (isAdminDelete=${isAdminDelete})`
|
||||
);
|
||||
|
||||
try {
|
||||
const jobData: ConversationQueueJobData = {
|
||||
type: conversationQueueJobEnum.enum.DeleteForEveryone,
|
||||
conversationId: conversationAttributes.id,
|
||||
messageId,
|
||||
isAdminDelete,
|
||||
targetMessageId: messageId,
|
||||
recipients: getRecipients(conversationAttributes),
|
||||
revision: conversationAttributes.revision,
|
||||
targetTimestamp,
|
||||
};
|
||||
await conversationJobQueue.add(jobData, async jobToInsert => {
|
||||
log.info(
|
||||
@@ -95,9 +107,12 @@ export async function sendDeleteForEveryoneMessage(
|
||||
throw error;
|
||||
}
|
||||
|
||||
await deleteForEveryone(message, {
|
||||
await applyDeleteForEveryone(message, {
|
||||
isAdminDelete,
|
||||
targetSentTimestamp: targetTimestamp,
|
||||
serverTimestamp: Date.now(),
|
||||
fromId: window.ConversationController.getOurConversationIdOrThrow(),
|
||||
deleteServerTimestamp: Date.now(),
|
||||
deleteSentByAci: ourAci,
|
||||
targetConversationId:
|
||||
window.ConversationController.getOurConversationIdOrThrow(),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user