Admin Delete

Co-authored-by: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com>
This commit is contained in:
automated-signal
2026-03-04 13:22:47 -06:00
committed by GitHub
parent c16ff9e778
commit efb7c6725c
67 changed files with 2328 additions and 569 deletions
+44
View File
@@ -2948,6 +2948,22 @@
"messageformat": "Delete failed",
"description": "Shown on a message which was deleted for everyone if the delete wasn't successfully sent to anyone"
},
"icu:deleteFailedClickForDetails": {
"messageformat": "Delete failed, click for details",
"description": "Shown on a message which was deleted for everyone if the delete wasn't successfully sent to anyone and can be retried"
},
"icu:retryDeleteForEveryone--title": {
"messageformat": "Retry failed delete?",
"description": "Title of dialog shown when user clicks on a failed delete for everyone message to retry (screen reader only)"
},
"icu:retryDeleteForEveryone--body": {
"messageformat": "Message failed to delete. Check your connection and try again.",
"description": "Body text of dialog shown when user clicks on a failed delete for everyone message to retry"
},
"icu:retryDeleteForEveryone--tryAgain": {
"messageformat": "Try again",
"description": "Button text to retry a failed delete for everyone"
},
"icu:editFailed": {
"messageformat": "Edit failed, click for details",
"description": "Shown on a message which was edited if the edit wasn't successfully sent to anyone"
@@ -2964,6 +2980,10 @@
"messageformat": "Partially deleted, click to retry",
"description": "Shown on a message which was deleted for everyone if the delete wasn't successfully sent to everyone"
},
"icu:partiallyDeleted--clickForDetails": {
"messageformat": "Partially deleted, click for details",
"description": "Shown on a message which was deleted for everyone if the delete wasn't successfully sent to everyone"
},
"icu:expiredWarning": {
"messageformat": "This version of Signal Desktop has expired. Please upgrade to the latest version to continue messaging.",
"description": "Warning notification that this version of the app has expired"
@@ -3484,6 +3504,18 @@
"messageformat": "This message was deleted.",
"description": "Shown in a message's bubble when the message has been deleted for everyone."
},
"icu:message--deletedByAdmin": {
"messageformat": "Admin {admin} deleted this message",
"description": "Shown in a message's bubble when the message has been deleted by a group admin."
},
"icu:message--deletedForEveryone--incoming": {
"messageformat": "{name} deleted this message",
"description": "Shown in a message bubble when an incoming message was deleted for everyone by its author."
},
"icu:message--deletedForEveryone--outgoing": {
"messageformat": "You deleted this message",
"description": "Shown in a message bubble when you deleted your own message for everyone."
},
"icu:message--attachmentTooBig--one": {
"messageformat": "Attachment too large to display.",
"description": "Shown in a message bubble if no attachments are left on message when too-large attachments are dropped"
@@ -6640,6 +6672,10 @@
"messageformat": "Delete {count, plural, one {message} other {# messages}}?",
"description": "delete selected messages > confirmation modal > title"
},
"icu:DeleteMessagesModal--title-2": {
"messageformat": "Delete selected {count, plural, one {message} other {# messages}}?",
"description": "delete selected messages > confirmation modal > title"
},
"icu:DeleteMessagesModal--description": {
"messageformat": "Who would you like to delete {count, plural, one {this message} other {these messages}} for?",
"description": "delete selected messages > confirmation modal > description"
@@ -6664,6 +6700,14 @@
"messageformat": "Delete for everyone",
"description": "delete selected messages > confirmation modal > delete for everyone"
},
"icu:DeleteMessagesModal--adminDeleteConfirmation--title": {
"messageformat": "Delete for everyone?",
"description": "delete selected messages > admin delete confirmation modal > title"
},
"icu:DeleteMessagesModal--adminDeleteConfirmation--description": {
"messageformat": "As an admin, group members will see that you deleted {count, plural, one {this message} other {these messages}}.",
"description": "delete selected messages > admin delete confirmation modal > description"
},
"icu:DeleteMessagesModal--deleteFromAllDevices": {
"messageformat": "Delete from all devices",
"description": "within note to self conversation > delete selected messages > confirmation modal > delete from all devices (same as delete for everyone)"
+6
View File
@@ -137,6 +137,7 @@ message AccountData {
CallsUseLessDataSetting callsUseLessDataSetting = 29; // If unset, treat the same as "Unknown" case
bool allowSealedSenderFromAnyone = 30;
bool allowAutomaticKeyVerification = 31;
bool hasSeenAdminDeleteEducationDialog = 32;
}
message SubscriberData {
@@ -504,6 +505,7 @@ message ChatItem {
ViewOnceMessage viewOnceMessage = 18;
DirectStoryReplyMessage directStoryReplyMessage = 19; // group story reply messages are not backed up
Poll poll = 20;
AdminDeletedMessage adminDeletedMessage = 22;
}
PinDetails pinDetails = 21; // only set if message is pinned
@@ -904,6 +906,10 @@ message Poll {
repeated Reaction reactions = 5;
}
message AdminDeletedMessage {
uint64 adminId = 1; // id of the admin that deleted the message
}
message ChatUpdateMessage {
// If unset, importers should ignore the update message without throwing an error.
oneof update {
+7 -1
View File
@@ -404,6 +404,11 @@ message DataMessage {
optional uint64 targetSentTimestamp = 2;
}
message AdminDelete {
optional bytes targetAuthorAciBinary = 1; // 16-byte UUID
optional uint64 targetSentTimestamp = 2;
}
optional string body = 1;
repeated AttachmentPointer attachments = 2;
reserved /*groupV1*/ 3;
@@ -431,7 +436,8 @@ message DataMessage {
optional PollVote pollVote = 26;
optional PinMessage pinMessage = 27;
optional UnpinMessage unpinMessage = 28;
// NEXT ID: 29
optional AdminDelete adminDelete = 29;
// NEXT ID: 30
}
message NullMessage {
+1
View File
@@ -302,6 +302,7 @@ message AccountRecord {
NotificationProfileManualOverride notificationProfileManualOverride = 44;
bool notificationProfileSyncDisabled = 45;
bool automaticKeyVerificationDisabled = 46;
bool hasSeenAdminDeleteEducationDialog = 47;
}
message StoryDistributionListRecord {
+1 -11
View File
@@ -1181,13 +1181,7 @@ $message-padding-horizontal: 12px;
}
.module-message__text--delete-for-everyone {
user-select: none;
@include mixins.light-theme {
color: variables.$color-gray-90;
}
@include mixins.dark-theme {
color: variables.$color-gray-05;
}
color: light-dark(variables.$color-gray-60, variables.$color-gray-25);
}
.module-message__metadata {
@@ -6322,10 +6316,6 @@ button.module-calling-participants-list__contact {
&--with-reactions {
margin-bottom: -6px;
}
&--deleted-for-everyone {
font-style: italic;
}
}
.module-message__container--sticker-like {
+1 -1
View File
@@ -7,7 +7,7 @@
@use '../variables';
@use '../mixins';
button.module-contact-name {
:where(button).module-contact-name {
@include mixins.button-reset;
&:hover,
+1
View File
@@ -6,6 +6,7 @@
@import 'tailwindcss' source(none);
@import './tailwind-plugins/animate-general.css';
@import './tailwind-plugins/animate-enter-exit.css';
@import './tailwind-plugins/container-scroll-state.css';
@import './tailwind-plugins/scrollbar.css';
@import './tailwind-plugins/superellipse.css';
@@ -0,0 +1,16 @@
/**
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
@custom-variant container-scrollable {
@container not scroll-state(scrollable: none) {
@slot;
}
}
@custom-variant container-not-scrollable {
@container scroll-state(scrollable: none) {
@slot;
}
}
+6
View File
@@ -36,6 +36,10 @@ const SemverKeys = [
'desktop.donationPaypal.prod',
'desktop.groupMemberLabels.edit.beta',
'desktop.groupMemberLabels.edit.prod',
'desktop.adminDelete.receive.beta',
'desktop.adminDelete.receive.prod',
'desktop.adminDelete.send.beta',
'desktop.adminDelete.send.prod',
'desktop.pinnedMessages.receive.beta',
'desktop.pinnedMessages.receive.prod',
'desktop.pinnedMessages.send.beta',
@@ -81,6 +85,8 @@ const ScalarKeys = [
'global.nicknames.min',
'global.pinned_message_limit',
'global.textAttachmentLimitBytes',
'global.normalDeleteMaxAgeInSeconds',
'global.adminDeleteMaxAgeInSeconds',
] as const;
// These keys should always match those in Net.REMOTE_CONFIG_KEYS, prefixed by
+34 -5
View File
@@ -9,6 +9,7 @@ import { tw } from './tw.dom.js';
import { AxoBaseDialog } from './_internal/AxoBaseDialog.dom.js';
import { AxoScrollArea } from './AxoScrollArea.dom.js';
import type { AxoSymbol } from './AxoSymbol.dom.js';
import { FlexWrapDetector } from './_internal/FlexWrapDetector.dom.js';
const Namespace = 'AxoAlertDialog';
@@ -138,7 +139,24 @@ export namespace AxoAlertDialog {
}>;
export const Footer: FC<FooterProps> = memo(props => {
return <div className={tw('flex gap-2 px-6 py-4')}>{props.children}</div>;
return (
<FlexWrapDetector>
<div
className={tw(
'flex flex-wrap gap-2 px-6 py-4',
// When actions are not being wrapped:
// Try to keep all actions equal size, but don't truncate them.
'container-not-scrollable:*:basis-0',
'container-not-scrollable:*:min-w-fit',
// When actions are being wrapped:
// Make all of them full width
'container-scrollable:*:w-full'
)}
>
{props.children}
</div>
</FlexWrapDetector>
);
});
Footer.displayName = `${Namespace}.Footer`;
@@ -201,7 +219,7 @@ export namespace AxoAlertDialog {
export const Cancel: FC<CancelProps> = memo(props => {
return (
<AlertDialog.Cancel asChild>
<AxoButton.Root variant="secondary" size="md" width="full">
<AxoButton.Root variant="secondary" size="md" width="grow">
{props.children}
</AxoButton.Root>
</AlertDialog.Cancel>
@@ -215,25 +233,36 @@ export namespace AxoAlertDialog {
* ----------------------------------
*/
export type ActionVariant = 'primary' | 'secondary' | 'destructive';
export type ActionVariant =
| 'primary'
| 'secondary'
| 'destructive'
| 'subtle-destructive';
export type ActionProps = Readonly<{
variant: ActionVariant;
symbol?: AxoSymbol.InlineGlyphName;
arrow?: boolean;
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
disabled?: boolean;
focusableWhenDisabled?: boolean;
children: ReactNode;
}>;
export const Action: FC<ActionProps> = memo(props => {
return (
<AlertDialog.Action asChild onClick={props.onClick}>
<AlertDialog.Action
asChild
onClick={props.onClick}
disabled={props.disabled}
>
<AxoButton.Root
variant={props.variant}
symbol={props.symbol}
arrow={props.arrow}
size="md"
width="full"
width="grow"
focusableWhenDisabled
>
{props.children}
</AxoButton.Root>
+5
View File
@@ -220,6 +220,7 @@ export namespace AxoButton {
arrow?: boolean;
experimentalSpinner?: { 'aria-label': string } | null;
disabled?: GenericButtonProps['disabled'];
focusableWhenDisabled?: boolean;
onClick?: GenericButtonProps['onClick'];
children: ReactNode;
// Note: Technically we forward all props for Radix, but we restrict the
@@ -235,6 +236,8 @@ export namespace AxoButton {
symbol,
arrow,
experimentalSpinner,
disabled,
focusableWhenDisabled,
children,
...rest
} = props;
@@ -251,6 +254,8 @@ export namespace AxoButton {
return (
<button
ref={ref}
disabled={(disabled && !focusableWhenDisabled) || undefined}
aria-disabled={(focusableWhenDisabled && disabled) || undefined}
{...rest}
type="button"
className={tw(variantStyles, sizeStyles, widthStyles)}
+31
View File
@@ -0,0 +1,31 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, type ReactNode } from 'react';
import { tw } from '../tw.dom.js';
export type FlexWrapDetectorProps = Readonly<{
children: ReactNode;
}>;
export const FlexWrapDetector = memo(function FlexWrapDetector(
props: FlexWrapDetectorProps
) {
return (
<div
className={tw(
// 1. Create a new container for querying scroll-state()
'@container-[scroll-state] overflow-x-hidden',
// 2. Make it a wrapping flex container
'flex flex-wrap'
)}
>
{/* 3. When wrapped, this will grow to fill the container */}
<div className={tw('relative grow')}>
{/* 4. And then this will make the scroll container overflow */}
<div className={tw('absolute -end-px size-px')} />
</div>
{/* 5. When not wrapped, this item should take priority when growing the items */}
<div className={tw('grow-[9999]')}>{props.children}</div>
</div>
);
});
+108 -4
View File
@@ -37,6 +37,7 @@ import { isWindowDragElement } from './util/isWindowDragElement.std.js';
import { assertDev, strictAssert } from './util/assert.std.js';
import { filter } from './util/iterables.std.js';
import { isNotNil } from './util/isNotNil.std.js';
import { isAdminDeleteReceiveEnabled } from './util/isAdminDeleteEnabled.dom.js';
import { areRemoteBackupsTurnedOn } from './util/isBackupEnabled.preload.js';
import { lightSessionResetQueue } from './util/lightSessionResetQueue.std.js';
import { setAppLoadingScreenMessage } from './setAppLoadingScreenMessage.dom.js';
@@ -2584,6 +2585,55 @@ export async function startApp(): Promise<void> {
return;
}
if (data.message.adminDelete) {
if (!isAdminDeleteReceiveEnabled()) {
log.info('Ignoring admin DOE: feature not enabled');
confirm();
return;
}
const { adminDelete } = data.message;
log.info(
'Queuing incoming admin DOE for',
adminDelete.targetSentTimestamp
);
strictAssert(
adminDelete.targetSentTimestamp,
'AdminDelete missing targetSentTimestamp'
);
strictAssert(
adminDelete.targetAuthorAci,
'AdminDelete missing targetAuthorAci'
);
strictAssert(data.serverTimestamp, 'AdminDelete missing serverTimestamp');
strictAssert(data.sourceAci, 'AdminDelete missing sourceAci');
const targetAuthorConversation =
window.ConversationController.lookupOrCreate({
serviceId: adminDelete.targetAuthorAci,
reason: 'admin-delete-incoming',
});
strictAssert(
targetAuthorConversation,
'AdminDelete: failed to find target author conversation'
);
const attributes: DeleteAttributesType = {
envelopeId: data.envelopeId,
isAdminDelete: true,
targetSentTimestamp: adminDelete.targetSentTimestamp,
targetAuthorAci: adminDelete.targetAuthorAci,
targetConversationId: targetAuthorConversation.id,
deleteServerTimestamp: data.serverTimestamp,
deleteSentByAci: data.sourceAci,
removeFromMessageReceiverCache: confirm,
};
drop(Deletes.onDelete(attributes));
return;
}
if (data.message.delete) {
const { delete: del } = data.message;
log.info('Queuing incoming DOE for', del.targetSentTimestamp);
@@ -2593,12 +2643,16 @@ export async function startApp(): Promise<void> {
'Delete missing targetSentTimestamp'
);
strictAssert(data.serverTimestamp, 'Delete missing serverTimestamp');
strictAssert(data.sourceAci, 'Delete missing sourceAci');
const attributes: DeleteAttributesType = {
envelopeId: data.envelopeId,
isAdminDelete: false,
targetSentTimestamp: del.targetSentTimestamp,
serverTimestamp: data.serverTimestamp,
fromId: fromConversation.id,
targetAuthorAci: data.sourceAci,
targetConversationId: fromConversation.id,
deleteServerTimestamp: data.serverTimestamp,
deleteSentByAci: data.sourceAci,
removeFromMessageReceiverCache: confirm,
};
drop(Deletes.onDelete(attributes));
@@ -3090,6 +3144,51 @@ export async function startApp(): Promise<void> {
return;
}
if (data.message.adminDelete) {
if (!isAdminDeleteReceiveEnabled()) {
log.info('Ignoring sent admin DOE sync: feature not enabled');
confirm();
return;
}
const { adminDelete } = data.message;
strictAssert(
adminDelete.targetSentTimestamp,
'AdminDelete without targetSentTimestamp'
);
strictAssert(
adminDelete.targetAuthorAci,
'AdminDelete without targetAuthorAci'
);
strictAssert(data.serverTimestamp, 'AdminDelete has no serverTimestamp');
log.info('Queuing sent admin DOE for', adminDelete.targetSentTimestamp);
const ourAci = itemStorage.user.getCheckedAci();
const targetAuthorConversation =
window.ConversationController.lookupOrCreate({
serviceId: adminDelete.targetAuthorAci,
reason: 'admin-delete-sync',
});
strictAssert(
targetAuthorConversation,
'AdminDelete sync: failed to find target author conversation'
);
const attributes: DeleteAttributesType = {
envelopeId: data.envelopeId,
isAdminDelete: true,
targetSentTimestamp: adminDelete.targetSentTimestamp,
targetAuthorAci: adminDelete.targetAuthorAci,
targetConversationId: targetAuthorConversation.id,
deleteServerTimestamp: data.serverTimestamp,
deleteSentByAci: ourAci,
removeFromMessageReceiverCache: confirm,
};
drop(Deletes.onDelete(attributes));
return;
}
if (data.message.delete) {
const { delete: del } = data.message;
strictAssert(
@@ -3100,11 +3199,16 @@ export async function startApp(): Promise<void> {
log.info('Queuing sent DOE for', del.targetSentTimestamp);
const ourAci = itemStorage.user.getCheckedAci();
const attributes: DeleteAttributesType = {
envelopeId: data.envelopeId,
isAdminDelete: false,
targetSentTimestamp: del.targetSentTimestamp,
serverTimestamp: data.serverTimestamp,
fromId: window.ConversationController.getOurConversationIdOrThrow(),
targetAuthorAci: ourAci,
targetConversationId:
window.ConversationController.getOurConversationIdOrThrow(),
deleteServerTimestamp: data.serverTimestamp,
deleteSentByAci: ourAci,
removeFromMessageReceiverCache: confirm,
};
drop(Deletes.onDelete(attributes));
@@ -1,63 +1,97 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { Meta, StoryFn } from '@storybook/react';
import React, { useCallback, useState } from 'react';
import type { Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import DeleteMessagesModal from './DeleteMessagesModal.dom.js';
import type { DeleteMessagesModalProps } from './DeleteMessagesModal.dom.js';
import { AxoButton } from '../axo/AxoButton.dom.js';
const { i18n } = window.SignalContext;
export default {
title: 'Components/DeleteMessagesModal',
component: DeleteMessagesModal,
args: {
i18n,
isMe: false,
canDeleteForEveryone: true,
messageCount: 1,
onClose: action('onClose'),
onDeleteForMe: action('onDeleteForMe'),
onDeleteForEveryone: action('onDeleteForEveryone'),
showToast: action('showToast'),
},
} satisfies Meta<DeleteMessagesModalProps>;
} satisfies Meta;
function createProps(args: Partial<DeleteMessagesModalProps>) {
return {
i18n,
isMe: false,
canDeleteForEveryone: true,
messageCount: 1,
onClose: action('onClose'),
onDeleteForMe: action('onDeleteForMe'),
onDeleteForEveryone: action('onDeleteForEveryone'),
showToast: action('showToast'),
...args,
};
function Template(props: Partial<DeleteMessagesModalProps>): React.JSX.Element {
const [open, setOpen] = useState(true);
const handleOpen = useCallback(() => {
setOpen(true);
}, []);
const handleClose = useCallback(() => {
setOpen(false);
}, []);
return (
<>
<AxoButton.Root size="md" variant="secondary" onClick={handleOpen}>
Open Dialog
</AxoButton.Root>
{open && (
<DeleteMessagesModal
isMe={false}
canDeleteForEveryone={false}
needsAdminDelete={false}
isDeletingOwnMessages={false}
hasSeenAdminDeleteEducationDialog={false}
i18n={i18n}
messageCount={1}
onClose={handleClose}
onDeleteForMe={action('onDeleteForMe')}
onDeleteForEveryone={action('onDeleteForEveryone')}
onSeenAdminDeleteEducationDialog={action(
'onSeenAdminDeleteEducationDialog'
)}
showToast={action('showToast')}
{...props}
/>
)}
</>
);
}
// eslint-disable-next-line react/function-component-definition
const Template: StoryFn<DeleteMessagesModalProps> = args => {
return <DeleteMessagesModal {...args} />;
};
export function DeleteForMeOnly(): React.JSX.Element {
return <Template />;
}
export const OneMessage = Template.bind({});
export function DeleteForEveryone(): React.JSX.Element {
return <Template canDeleteForEveryone />;
}
export const ThreeMessages = Template.bind({});
ThreeMessages.args = createProps({
messageCount: 3,
});
export function DeleteForEveryoneMultiple(): React.JSX.Element {
return <Template canDeleteForEveryone messageCount={3} />;
}
export const IsMe = Template.bind({});
IsMe.args = createProps({
isMe: true,
});
export function AdminDelete(): React.JSX.Element {
return <Template canDeleteForEveryone needsAdminDelete />;
}
export const IsMeThreeMessages = Template.bind({});
IsMeThreeMessages.args = createProps({
isMe: true,
messageCount: 3,
});
export function AdminDeleteMultiple(): React.JSX.Element {
return <Template canDeleteForEveryone needsAdminDelete messageCount={3} />;
}
export function AdminDeleteAfterOnboarding(): React.JSX.Element {
return (
<Template
canDeleteForEveryone
needsAdminDelete
hasSeenAdminDeleteEducationDialog
/>
);
}
export function NoteToSelf(): React.JSX.Element {
return <Template isMe />;
}
export function NoteToSelfMultiple(): React.JSX.Element {
return <Template isMe messageCount={3} />;
}
export function TooManyMessagesForDeleteForEveryone(): React.JSX.Element {
return <Template canDeleteForEveryone messageCount={31} />;
}
+210 -62
View File
@@ -1,101 +1,249 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { ActionSpec } from './ConfirmationDialog.dom.js';
import { ConfirmationDialog } from './ConfirmationDialog.dom.js';
import type { MouseEvent } from 'react';
import React, { useCallback, useState } from 'react';
import type { LocalizerType } from '../types/Util.std.js';
import type { ShowToastAction } from '../state/ducks/toast.preload.js';
import { ToastType } from '../types/Toast.dom.js';
import { AxoAlertDialog } from '../axo/AxoAlertDialog.dom.js';
export type DeleteMessagesModalProps = Readonly<{
isMe: boolean;
canDeleteForEveryone: boolean;
needsAdminDelete: boolean;
isDeletingOwnMessages: boolean;
hasSeenAdminDeleteEducationDialog: boolean;
i18n: LocalizerType;
messageCount: number;
onClose: () => void;
onDeleteForMe: () => void;
onDeleteForEveryone: () => void;
onSeenAdminDeleteEducationDialog: () => void;
showToast: ShowToastAction;
}>;
const MAX_DELETE_FOR_EVERYONE = 30;
enum Step {
SELECT_DELETE_TYPE,
CONFIRM_ADMIN_DELETE,
}
export default function DeleteMessagesModal({
isMe,
canDeleteForEveryone,
needsAdminDelete,
isDeletingOwnMessages,
hasSeenAdminDeleteEducationDialog,
i18n,
messageCount,
onClose,
onDeleteForMe,
onDeleteForEveryone,
onSeenAdminDeleteEducationDialog,
showToast,
}: DeleteMessagesModalProps): React.JSX.Element {
const actions: Array<ActionSpec> = [];
const [step, setStep] = useState(Step.SELECT_DELETE_TYPE);
const syncNoteToSelfDelete = isMe;
const tooManyMessages = messageCount > MAX_DELETE_FOR_EVERYONE;
let deleteForMeText = i18n('icu:DeleteMessagesModal--deleteForMe');
if (syncNoteToSelfDelete) {
deleteForMeText = i18n('icu:DeleteMessagesModal--noteToSelf--deleteSync');
} else if (isMe) {
deleteForMeText = i18n('icu:DeleteMessagesModal--deleteFromThisDevice');
}
const handleBackToSelectDeleteType = useCallback(() => {
setStep(Step.SELECT_DELETE_TYPE);
}, []);
actions.push({
action: onDeleteForMe,
style: 'negative',
text: deleteForMeText,
});
const handleSelectDeleteForMe = useCallback(() => {
onDeleteForMe();
onClose();
}, [onDeleteForMe, onClose]);
if (canDeleteForEveryone && !syncNoteToSelfDelete) {
const tooManyMessages = messageCount > MAX_DELETE_FOR_EVERYONE;
actions.push({
'aria-disabled': tooManyMessages,
autoClose: !tooManyMessages,
action: () => {
if (tooManyMessages) {
showToast({
toastType: ToastType.TooManyMessagesToDeleteForEveryone,
parameters: { count: MAX_DELETE_FOR_EVERYONE },
});
} else {
onDeleteForEveryone();
}
},
style: 'negative',
text: isMe
? i18n('icu:DeleteMessagesModal--deleteFromAllDevices')
: i18n('icu:DeleteMessagesModal--deleteForEveryone'),
});
}
const handleConfirmDeleteForEveryone = useCallback(() => {
onDeleteForEveryone();
onClose();
}, [onDeleteForEveryone, onClose]);
let descriptionText = i18n('icu:DeleteMessagesModal--description', {
count: messageCount,
});
if (syncNoteToSelfDelete) {
descriptionText = i18n(
'icu:DeleteMessagesModal--description--noteToSelf--deleteSync',
{ count: messageCount }
);
} else if (isMe) {
descriptionText = i18n('icu:DeleteMessagesModal--description--noteToSelf', {
count: messageCount,
});
}
const handleSelectDeleteForEveryone = useCallback(() => {
if (tooManyMessages) {
showToast({
toastType: ToastType.TooManyMessagesToDeleteForEveryone,
parameters: { count: MAX_DELETE_FOR_EVERYONE },
});
return;
}
if (
needsAdminDelete &&
!isDeletingOwnMessages &&
!hasSeenAdminDeleteEducationDialog
) {
setStep(Step.CONFIRM_ADMIN_DELETE);
return;
}
handleConfirmDeleteForEveryone();
}, [
tooManyMessages,
needsAdminDelete,
isDeletingOwnMessages,
hasSeenAdminDeleteEducationDialog,
showToast,
handleConfirmDeleteForEveryone,
]);
return (
<ConfirmationDialog
actions={actions}
dialogName="ConfirmDeleteForMeModal"
i18n={i18n}
onClose={onClose}
title={i18n('icu:DeleteMessagesModal--title', {
count: messageCount,
})}
moduleClassName="DeleteMessagesModal"
>
{descriptionText}
</ConfirmationDialog>
<>
<DeleteMessagesSelectDeleteTypeDialog
isMe={isMe}
canDeleteForEveryone={canDeleteForEveryone}
i18n={i18n}
messageCount={messageCount}
tooManyMessages={tooManyMessages}
open={step === Step.SELECT_DELETE_TYPE}
onClose={onClose}
onSelectDeleteForMe={handleSelectDeleteForMe}
onSelectDeleteForEveryone={handleSelectDeleteForEveryone}
/>
<DeleteMessagesConfirmAdminDeleteDialog
i18n={i18n}
messageCount={messageCount}
open={step === Step.CONFIRM_ADMIN_DELETE}
onOpenChange={handleBackToSelectDeleteType}
onCancel={handleBackToSelectDeleteType}
onConfirm={onDeleteForEveryone}
onClose={onClose}
onSeenAdminDeleteEducationDialog={onSeenAdminDeleteEducationDialog}
/>
</>
);
}
function DeleteMessagesSelectDeleteTypeDialog(props: {
isMe: boolean;
canDeleteForEveryone: boolean;
i18n: LocalizerType;
messageCount: number;
tooManyMessages: boolean;
open: boolean;
onClose: () => void;
onSelectDeleteForMe: () => void;
onSelectDeleteForEveryone: () => void;
}) {
const { i18n, onClose, onSelectDeleteForEveryone } = props;
const handleOpenChange = useCallback(
(value: boolean) => {
if (!value) {
onClose();
}
},
[onClose]
);
const handleSelectDeleteForEveryone = useCallback(
(event: MouseEvent) => {
event.preventDefault();
onSelectDeleteForEveryone();
},
[onSelectDeleteForEveryone]
);
return (
<AxoAlertDialog.Root open={props.open} onOpenChange={handleOpenChange}>
<AxoAlertDialog.Content escape="cancel-is-noop">
<AxoAlertDialog.Body>
<AxoAlertDialog.Title>
{i18n('icu:DeleteMessagesModal--title-2', {
count: props.messageCount,
})}
</AxoAlertDialog.Title>
<AxoAlertDialog.Description>
{props.isMe
? i18n(
'icu:DeleteMessagesModal--description--noteToSelf--deleteSync',
{ count: props.messageCount }
)
: i18n('icu:DeleteMessagesModal--description', {
count: props.messageCount,
})}
</AxoAlertDialog.Description>
</AxoAlertDialog.Body>
<AxoAlertDialog.Footer>
<AxoAlertDialog.Cancel>{i18n('icu:cancel')}</AxoAlertDialog.Cancel>
<AxoAlertDialog.Action
variant="subtle-destructive"
onClick={props.onSelectDeleteForMe}
>
{props.isMe
? i18n('icu:DeleteMessagesModal--noteToSelf--deleteSync')
: i18n('icu:DeleteMessagesModal--deleteForMe')}
</AxoAlertDialog.Action>
{props.canDeleteForEveryone && !props.isMe && (
<AxoAlertDialog.Action
variant="subtle-destructive"
onClick={handleSelectDeleteForEveryone}
>
{i18n('icu:DeleteMessagesModal--deleteForEveryone')}
</AxoAlertDialog.Action>
)}
</AxoAlertDialog.Footer>
</AxoAlertDialog.Content>
</AxoAlertDialog.Root>
);
}
function DeleteMessagesConfirmAdminDeleteDialog(props: {
i18n: LocalizerType;
messageCount: number;
open: boolean;
onOpenChange: () => void;
onCancel: () => void;
onConfirm: () => void;
onClose: () => void;
onSeenAdminDeleteEducationDialog: () => void;
}) {
const {
i18n,
messageCount,
onCancel,
onConfirm,
onClose,
onSeenAdminDeleteEducationDialog,
} = props;
const handleCancel = useCallback(() => {
onCancel();
}, [onCancel]);
const handleConfirm = useCallback(() => {
onSeenAdminDeleteEducationDialog();
onConfirm();
onClose();
}, [onConfirm, onClose, onSeenAdminDeleteEducationDialog]);
return (
<AxoAlertDialog.Root open={props.open} onOpenChange={props.onOpenChange}>
<AxoAlertDialog.Content escape="cancel-is-noop">
<AxoAlertDialog.Body>
<AxoAlertDialog.Title>
{i18n('icu:DeleteMessagesModal--adminDeleteConfirmation--title')}
</AxoAlertDialog.Title>
<AxoAlertDialog.Description>
{i18n(
'icu:DeleteMessagesModal--adminDeleteConfirmation--description',
{ count: messageCount }
)}
</AxoAlertDialog.Description>
</AxoAlertDialog.Body>
<AxoAlertDialog.Footer>
<AxoAlertDialog.Action variant="secondary" onClick={handleCancel}>
{i18n('icu:cancel')}
</AxoAlertDialog.Action>
<AxoAlertDialog.Action
variant="subtle-destructive"
onClick={handleConfirm}
>
{i18n('icu:DeleteMessagesModal--deleteForEveryone')}
</AxoAlertDialog.Action>
</AxoAlertDialog.Footer>
</AxoAlertDialog.Content>
</AxoAlertDialog.Root>
);
}
@@ -42,6 +42,8 @@ export type PropsType = {
const MESSAGE_DEFAULT_PROPS = {
canDeleteForEveryone: false,
canRetryDeleteForEveryone: false,
retryDeleteForEveryone: shouldNeverBeCalled,
checkForAccount: shouldNeverBeCalled,
clearSelectedMessage: shouldNeverBeCalled,
clearTargetedMessage: shouldNeverBeCalled,
@@ -54,6 +54,8 @@ const { noop, orderBy } = lodash;
// text messages and reactions.
const MESSAGE_DEFAULT_PROPS = {
canDeleteForEveryone: false,
canRetryDeleteForEveryone: false,
retryDeleteForEveryone: shouldNeverBeCalled,
checkForAccount: shouldNeverBeCalled,
clearTargetedMessage: shouldNeverBeCalled,
containerWidthBreakpoint: WidthBreakpoint.Medium,
+118 -34
View File
@@ -37,6 +37,7 @@ import { ImageGrid } from './ImageGrid.dom.js';
import { GIF } from './GIF.dom.js';
import { CurveType, Image } from './Image.dom.js';
import { ContactName } from './ContactName.dom.js';
import { I18n } from '../I18n.dom.js';
import type { QuotedAttachmentForUIType } from './Quote.dom.js';
import { Quote } from './Quote.dom.js';
import { EmbeddedContact } from './EmbeddedContact.dom.js';
@@ -125,6 +126,7 @@ import {
} from '../fun/data/emojis.std.js';
import { useGroupedAndOrderedReactions } from '../../util/groupAndOrderReactions.dom.js';
import type { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.js';
import { AxoSymbol } from '../../axo/AxoSymbol.dom.js';
import type { RenderAudioAttachmentProps } from '../../state/smart/renderAudioAttachment.preload.js';
import type { MemberLabelType } from '../../types/GroupMemberLabels.std.js';
import type { ContactModalStateType } from '../../types/globalModals.std.js';
@@ -339,9 +341,15 @@ export type PropsData = {
reactions?: ReactionViewerProps['reactions'];
deletedForEveryone?: boolean;
deletedForEveryoneByAdmin?: {
conversationId: string;
title: string;
contactNameColor: ContactNameColorType;
};
attachmentDroppedDueToSize?: boolean;
canDeleteForEveryone: boolean;
canRetryDeleteForEveryone: boolean;
isBlocked: boolean;
isMessageRequestAccepted: boolean;
bodyRanges?: HydratedBodyRangesType;
@@ -386,6 +394,7 @@ export type PropsActions = {
showConversation: ShowConversationType;
openGiftBadge: (messageId: string) => void;
pushPanelForConversation: PushPanelForConversationActionType;
retryDeleteForEveryone: (messageId: string) => unknown;
retryMessageSend: (messageId: string) => unknown;
sendPollVote: (params: {
messageId: string;
@@ -1086,6 +1095,7 @@ export class Message extends React.PureComponent<Props, State> {
const {
attachmentDroppedDueToSize,
canRetryDeleteForEveryone,
deletedForEveryone,
direction,
expirationLength,
@@ -1096,6 +1106,7 @@ export class Message extends React.PureComponent<Props, State> {
isPinned,
isSMS,
isSticker,
retryDeleteForEveryone,
retryMessageSend,
pushPanelForConversation,
showEditHistoryModal,
@@ -1109,6 +1120,7 @@ export class Message extends React.PureComponent<Props, State> {
return (
<MessageMetadata
canRetryDeleteForEveryone={canRetryDeleteForEveryone}
deletedForEveryone={deletedForEveryone}
direction={direction}
expirationLength={expirationLength}
@@ -1128,6 +1140,7 @@ export class Message extends React.PureComponent<Props, State> {
onWidthMeasured={isInline ? this.#updateMetadataWidth : undefined}
pushPanelForConversation={pushPanelForConversation}
ref={this.#metadataRef}
retryDeleteForEveryone={retryDeleteForEveryone}
retryMessageSend={retryMessageSend}
showEditHistoryModal={showEditHistoryModal}
status={status}
@@ -1170,6 +1183,7 @@ export class Message extends React.PureComponent<Props, State> {
_forceTapToPlay,
attachmentDroppedDueToSize,
attachments,
canRetryDeleteForEveryone,
cancelAttachmentDownload,
direction,
expirationLength,
@@ -1185,6 +1199,7 @@ export class Message extends React.PureComponent<Props, State> {
quote,
renderAudioAttachment,
renderingContext,
retryDeleteForEveryone,
retryMessageSend,
shouldHideMetadata,
shouldCollapseAbove,
@@ -1436,6 +1451,7 @@ export class Message extends React.PureComponent<Props, State> {
{text || !willShowMetadata ? undefined : (
<div className="module-message__simple-attachment__metadata-container">
<MessageMetadata
canRetryDeleteForEveryone={canRetryDeleteForEveryone}
deletedForEveryone={false}
direction={direction}
expirationLength={expirationLength}
@@ -1453,6 +1469,7 @@ export class Message extends React.PureComponent<Props, State> {
onWidthMeasured={undefined}
pushPanelForConversation={pushPanelForConversation}
ref={this.#metadataRef}
retryDeleteForEveryone={retryDeleteForEveryone}
retryMessageSend={retryMessageSend}
showEditHistoryModal={showEditHistoryModal}
status={status}
@@ -2344,21 +2361,80 @@ export class Message extends React.PureComponent<Props, State> {
);
}
#getContents(): string | undefined {
const { deletedForEveryone, direction, i18n, status, text } = this.props;
#getMessageStatusContents(): React.JSX.Element | string | null {
const {
author,
conversationId,
deletedForEveryone,
deletedForEveryoneByAdmin,
direction,
i18n,
showContactModal,
status,
} = this.props;
if (deletedForEveryone) {
return i18n('icu:message--deletedForEveryone');
let text: React.JSX.Element | string;
if (deletedForEveryoneByAdmin != null) {
text = (
<I18n
id="icu:message--deletedByAdmin"
i18n={i18n}
components={{
admin: (
<strong>
<ContactName
title={deletedForEveryoneByAdmin.title}
contactNameColor={
deletedForEveryoneByAdmin.contactNameColor
}
onClick={() => {
showContactModal({
conversationId,
contactId: deletedForEveryoneByAdmin.conversationId,
});
}}
/>
</strong>
),
}}
/>
);
} else if (direction === 'outgoing') {
text = i18n('icu:message--deletedForEveryone--outgoing');
} else {
text = (
<I18n
id="icu:message--deletedForEveryone--incoming"
i18n={i18n}
components={{
name: (
<ContactName
preferFirstName
title={author.title}
firstName={author.firstName}
/>
),
}}
/>
);
}
return (
<>
<AxoSymbol.InlineGlyph symbol="x-circle" label={null} /> {text}
</>
);
}
if (direction === 'incoming' && status === 'error') {
return i18n('icu:incomingError');
}
return text;
return null;
}
public renderText(): React.JSX.Element | null {
const {
text,
bodyRanges,
deletedForEveryone,
direction,
@@ -2377,9 +2453,8 @@ export class Message extends React.PureComponent<Props, State> {
} = this.props;
const { metadataWidth } = this.state;
const contents = this.#getContents();
if (!contents) {
const messageStatusContents = this.#getMessageStatusContents();
if (messageStatusContents == null && text == null) {
return null;
}
@@ -2394,7 +2469,7 @@ export class Message extends React.PureComponent<Props, State> {
className={classNames(
'module-message__text',
`module-message__text--${direction}`,
status === 'error' && direction === 'incoming'
status === 'error' && direction === 'incoming' && !deletedForEveryone
? 'module-message__text--error'
: null,
deletedForEveryone
@@ -2419,32 +2494,35 @@ export class Message extends React.PureComponent<Props, State> {
event.stopPropagation();
}}
>
<MessageBodyReadMore
bodyRanges={bodyRanges}
direction={direction}
disableLinks={!this.#areLinksEnabled()}
displayLimit={displayLimit}
i18n={i18n}
id={id}
isSpoilerExpanded={isSpoilerExpanded || {}}
kickOffBodyDownload={() => {
if (!textAttachment) {
return;
}
if (isDownloaded(textAttachment)) {
return;
}
kickOffAttachmentDownload({
messageId: id,
});
}}
messageExpanded={messageExpanded}
showConversation={showConversation}
renderLocation={RenderLocation.Timeline}
onExpandSpoiler={data => showSpoiler(id, data)}
text={contents || ''}
textAttachment={textAttachment}
/>
{messageStatusContents != null && messageStatusContents}
{messageStatusContents == null && text != null && (
<MessageBodyReadMore
bodyRanges={bodyRanges}
direction={direction}
disableLinks={!this.#areLinksEnabled()}
displayLimit={displayLimit}
i18n={i18n}
id={id}
isSpoilerExpanded={isSpoilerExpanded || {}}
kickOffBodyDownload={() => {
if (!textAttachment) {
return;
}
if (isDownloaded(textAttachment)) {
return;
}
kickOffAttachmentDownload({
messageId: id,
});
}}
messageExpanded={messageExpanded}
showConversation={showConversation}
renderLocation={RenderLocation.Timeline}
onExpandSpoiler={data => showSpoiler(id, data)}
text={text}
textAttachment={textAttachment}
/>
)}
{this.#getMetadataPlacement() === MetadataPlacement.InlineWithText && (
<MessageTextMetadataSpacer metadataWidth={metadataWidth} />
)}
@@ -2711,6 +2789,7 @@ export class Message extends React.PureComponent<Props, State> {
const {
attachments,
attachmentDroppedDueToSize,
canRetryDeleteForEveryone,
conversationType,
direction,
expirationLength,
@@ -2722,6 +2801,7 @@ export class Message extends React.PureComponent<Props, State> {
isTapToViewExpired,
pushPanelForConversation,
readStatus,
retryDeleteForEveryone,
retryMessageSend,
showEditHistoryModal,
status,
@@ -2774,6 +2854,7 @@ export class Message extends React.PureComponent<Props, State> {
{collapseMetadata ? undefined : (
<div className="module-message__simple-attachment__metadata-container">
<MessageMetadata
canRetryDeleteForEveryone={canRetryDeleteForEveryone}
deletedForEveryone={false}
direction={direction}
expirationLength={expirationLength}
@@ -2791,6 +2872,7 @@ export class Message extends React.PureComponent<Props, State> {
onWidthMeasured={undefined}
pushPanelForConversation={pushPanelForConversation}
ref={this.#metadataRef}
retryDeleteForEveryone={retryDeleteForEveryone}
retryMessageSend={retryMessageSend}
showEditHistoryModal={showEditHistoryModal}
status={status}
@@ -2816,6 +2898,7 @@ export class Message extends React.PureComponent<Props, State> {
{collapseMetadata ? undefined : (
<div className="module-message__simple-attachment__metadata-container">
<MessageMetadata
canRetryDeleteForEveryone={canRetryDeleteForEveryone}
deletedForEveryone={false}
direction={direction}
expirationLength={expirationLength}
@@ -2833,6 +2916,7 @@ export class Message extends React.PureComponent<Props, State> {
onWidthMeasured={undefined}
pushPanelForConversation={pushPanelForConversation}
ref={this.#metadataRef}
retryDeleteForEveryone={retryDeleteForEveryone}
retryMessageSend={retryMessageSend}
showEditHistoryModal={showEditHistoryModal}
status={status}
@@ -377,6 +377,7 @@ export function MessageAudio(props: Props): React.JSX.Element {
{!withContentBelow && !collapseMetadata && (
<MessageMetadata
canRetryDeleteForEveryone={false}
direction={direction}
expirationLength={expirationLength}
expirationTimestamp={expirationTimestamp}
@@ -387,6 +388,7 @@ export function MessageAudio(props: Props): React.JSX.Element {
isShowingImage={false}
isSticker={false}
pushPanelForConversation={pushPanelForConversation}
retryDeleteForEveryone={shouldNeverBeCalled}
retryMessageSend={shouldNeverBeCalled}
status={status}
textPending={textPending}
@@ -40,7 +40,7 @@ function getSizeClass(str: string): FunJumboEmojiSize | null {
}
export type Props = {
author?: string;
author?: string | null;
bodyRanges?: HydratedBodyRangesType;
direction?: 'incoming' | 'outgoing';
// If set, all emoji will be the same size. Otherwise, just one emoji will be large.
@@ -22,6 +22,7 @@ const defaultMessage: MessageDataPropsType = {
title: 'Max',
}),
canDeleteForEveryone: true,
canRetryDeleteForEveryone: false,
conversationColor: 'crimson',
conversationId: 'my-convo',
conversationTitle: 'Conversation Title',
@@ -59,7 +59,7 @@ export type Contact = Pick<
isOutgoingKeyError: boolean;
isUnidentifiedDelivery: boolean;
errors?: Array<Error>;
errors?: ReadonlyArray<Error>;
};
export type PropsData = {
@@ -69,7 +69,7 @@ export type PropsData = {
contacts: ReadonlyArray<Contact>;
contactNameColor?: ContactNameColorType;
errors: Array<Error>;
errors: ReadonlyArray<Error>;
message: Omit<
MessagePropsDataType,
'renderingContext' | 'menu' | 'contextMenu' | 'showMenu'
@@ -111,6 +111,7 @@ export type PropsReduxActions = Pick<
| 'showLightboxForViewOnceMedia'
| 'showMediaNoLongerAvailableToast'
| 'showSpoiler'
| 'retryDeleteForEveryone'
| 'showTapToViewNotAvailableModal'
| 'startConversation'
| 'viewStory'
@@ -147,6 +148,7 @@ export function MessageDetail({
openGiftBadge,
platform,
pushPanelForConversation,
retryDeleteForEveryone,
retryMessageSend,
sendPollVote,
renderAudioAttachment,
@@ -350,6 +352,7 @@ export function MessageDetail({
openGiftBadge={openGiftBadge}
platform={platform}
pushPanelForConversation={pushPanelForConversation}
retryDeleteForEveryone={retryDeleteForEveryone}
retryMessageSend={retryMessageSend}
sendPollVote={sendPollVote}
renderAudioAttachment={renderAudioAttachment}
@@ -14,6 +14,7 @@ import { ExpireTimer } from './ExpireTimer.dom.js';
import { MessageTimestamp } from './MessageTimestamp.dom.js';
import { PanelType } from '../../types/Panels.std.js';
import { Spinner } from '../Spinner.dom.js';
import { AxoAlertDialog } from '../../axo/AxoAlertDialog.dom.js';
import { ConfirmationDialog } from '../ConfirmationDialog.dom.js';
import { refMerger } from '../../util/refMerger.std.js';
import type { Size } from '../../hooks/useSizeObserver.dom.js';
@@ -22,6 +23,7 @@ import { AxoSymbol } from '../../axo/AxoSymbol.dom.js';
import { tw } from '../../axo/tw.dom.js';
type PropsType = {
canRetryDeleteForEveryone: boolean;
deletedForEveryone?: boolean;
direction: DirectionType;
expirationLength?: number;
@@ -38,6 +40,7 @@ type PropsType = {
isSticker?: boolean;
onWidthMeasured?: (width: number) => unknown;
pushPanelForConversation: PushPanelForConversationActionType;
retryDeleteForEveryone: (messageId: string) => unknown;
retryMessageSend: (messageId: string) => unknown;
showEditHistoryModal?: (id: string) => unknown;
status?: MessageStatusType;
@@ -47,11 +50,13 @@ type PropsType = {
enum ConfirmationType {
EditError = 'EditError',
DeleteError = 'DeleteError',
}
export const MessageMetadata = forwardRef<HTMLDivElement, Readonly<PropsType>>(
function MessageMetadataInner(
{
canRetryDeleteForEveryone,
deletedForEveryone,
direction,
expirationLength,
@@ -68,6 +73,7 @@ export const MessageMetadata = forwardRef<HTMLDivElement, Readonly<PropsType>>(
isSticker,
onWidthMeasured,
pushPanelForConversation,
retryDeleteForEveryone,
retryMessageSend,
showEditHistoryModal,
status,
@@ -86,15 +92,32 @@ export const MessageMetadata = forwardRef<HTMLDivElement, Readonly<PropsType>>(
let timestampNode: ReactNode;
{
const isError = status === 'error' && direction === 'outgoing';
const isPartiallySent =
status === 'partial-sent' && direction === 'outgoing';
// When deletedForEveryone, the status reflects our delete send,
// not the original message direction
const isOutgoingOrDelete = direction === 'outgoing' || deletedForEveryone;
const isError = status === 'error' && isOutgoingOrDelete;
const isPartiallySent = status === 'partial-sent' && isOutgoingOrDelete;
const isPaused = status === 'paused';
if (isError || isPartiallySent || isPaused) {
let statusInfo: React.ReactNode;
if (isError) {
if (deletedForEveryone) {
if (deletedForEveryone && canRetryDeleteForEveryone) {
statusInfo = (
<button
type="button"
className="module-message__metadata__tapable"
onClick={(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
setConfirmationType(ConfirmationType.DeleteError);
}}
>
{i18n('icu:deleteFailedClickForDetails')}
</button>
);
} else if (deletedForEveryone) {
statusInfo = i18n('icu:deleteFailed');
} else if (isEditedMessage) {
statusInfo = (
@@ -132,7 +155,7 @@ export const MessageMetadata = forwardRef<HTMLDivElement, Readonly<PropsType>>(
}}
>
{deletedForEveryone
? i18n('icu:partiallyDeleted')
? i18n('icu:partiallyDeleted--clickForDetails')
: i18n('icu:partiallySent')}
</button>
);
@@ -181,6 +204,38 @@ export const MessageMetadata = forwardRef<HTMLDivElement, Readonly<PropsType>>(
{i18n('icu:ResendMessageEdit__body')}
</ConfirmationDialog>
);
} else if (confirmationType === ConfirmationType.DeleteError) {
confirmation = (
<AxoAlertDialog.Root
open
onOpenChange={() => setConfirmationType(undefined)}
>
<AxoAlertDialog.Content escape="cancel-is-noop">
<AxoAlertDialog.Body>
<AxoAlertDialog.Title screenReaderOnly>
{i18n('icu:retryDeleteForEveryone--title')}
</AxoAlertDialog.Title>
<AxoAlertDialog.Description>
{i18n('icu:retryDeleteForEveryone--body')}
</AxoAlertDialog.Description>
</AxoAlertDialog.Body>
<AxoAlertDialog.Footer>
<AxoAlertDialog.Cancel>
{i18n('icu:cancel')}
</AxoAlertDialog.Cancel>
<AxoAlertDialog.Action
variant="primary"
onClick={() => {
retryDeleteForEveryone(id);
setConfirmationType(undefined);
}}
>
{i18n('icu:retryDeleteForEveryone--tryAgain')}
</AxoAlertDialog.Action>
</AxoAlertDialog.Footer>
</AxoAlertDialog.Content>
</AxoAlertDialog.Root>
);
} else {
throw missingCaseError(confirmationType);
}
@@ -231,7 +286,7 @@ export const MessageMetadata = forwardRef<HTMLDivElement, Readonly<PropsType>>(
) : null}
{(!deletedForEveryone || status === 'sending') &&
!textPending &&
direction === 'outgoing' &&
(direction === 'outgoing' || deletedForEveryone) &&
status !== 'error' &&
status !== 'partial-sent' ? (
<div
@@ -260,6 +260,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
contactLabel: overrideProps.contactLabel,
// disableMenu: overrideProps.disableMenu,
deletedForEveryone: overrideProps.deletedForEveryone,
deletedForEveryoneByAdmin: overrideProps.deletedForEveryoneByAdmin,
disableScroll: overrideProps.disableScroll,
direction: overrideProps.direction || 'incoming',
showLightboxForViewOnceMedia: action('showLightboxForViewOnceMedia'),
@@ -943,6 +944,22 @@ export function Deleted(): React.JSX.Element {
);
}
export function DeletedByAdmin(): React.JSX.Element {
const props = createProps({
conversationType: 'group',
deletedForEveryone: true,
deletedForEveryoneByAdmin: {
conversationId: 'admin-conversation-id',
title: 'Alice Smith',
contactNameColor: '200',
},
canForward: false,
status: 'sent',
});
return renderBothDirections(props);
}
export const DeletedWithExpireTimer = Template.bind({});
DeletedWithExpireTimer.args = {
timestamp: Date.now() - 60 * 1000,
@@ -955,10 +972,58 @@ DeletedWithExpireTimer.args = {
status: 'sent',
};
export function DeletedPending(): React.JSX.Element {
const props = createProps({
timestamp: Date.now() - 60 * 1000,
conversationType: 'group',
contactNameColor: '100',
deletedForEveryone: true,
status: 'sending',
direction: 'outgoing',
});
return <>{renderThree(props)}</>;
}
export function AdminDeletedPending(): React.JSX.Element {
const props = createProps({
timestamp: Date.now() - 60 * 1000,
conversationType: 'group',
contactNameColor: '100',
deletedForEveryone: true,
deletedForEveryoneByAdmin: {
conversationId: 'admin-conversation-id',
title: 'Alice Smith',
contactNameColor: '200',
},
status: 'sending',
direction: 'outgoing',
});
const propsIncoming = createProps({
timestamp: Date.now() - 60 * 1000,
conversationType: 'group',
contactNameColor: '100',
deletedForEveryone: true,
deletedForEveryoneByAdmin: {
conversationId: 'admin-conversation-id',
title: 'Alice Smith',
contactNameColor: '200',
},
status: 'sending',
direction: 'incoming',
});
return (
<>
{renderThree(props)}
{renderThree(propsIncoming)}
</>
);
}
export function DeletedWithError(): React.JSX.Element {
const propsPartialError = createProps({
timestamp: Date.now() - 60 * 1000,
// canDeleteForEveryone: true,
conversationType: 'group',
contactNameColor: '100',
deletedForEveryone: true,
@@ -967,7 +1032,6 @@ export function DeletedWithError(): React.JSX.Element {
});
const propsError = createProps({
timestamp: Date.now() - 60 * 1000,
// canDeleteForEveryone: true,
conversationType: 'group',
contactNameColor: '100',
deletedForEveryone: true,
@@ -983,6 +1047,148 @@ export function DeletedWithError(): React.JSX.Element {
);
}
export function DeletedWithErrorCanRetry(): React.JSX.Element {
const propsPartialError = createProps({
timestamp: Date.now() - 60 * 1000,
conversationType: 'group',
contactNameColor: '100',
deletedForEveryone: true,
canRetryDeleteForEveryone: true,
status: 'partial-sent',
direction: 'outgoing',
});
const propsError = createProps({
timestamp: Date.now() - 60 * 1000,
conversationType: 'group',
contactNameColor: '100',
deletedForEveryone: true,
canRetryDeleteForEveryone: true,
status: 'error',
direction: 'outgoing',
});
return (
<>
{renderThree(propsPartialError)}
{renderThree(propsError)}
</>
);
}
export function AdminDeletedWithError(): React.JSX.Element {
const adminProps = {
deletedForEveryoneByAdmin: {
conversationId: 'admin-conversation-id',
title: 'Alice Smith',
contactNameColor: '200' as const,
},
};
const propsOutgoingPartialError = createProps({
timestamp: Date.now() - 60 * 1000,
conversationType: 'group',
contactNameColor: '100',
deletedForEveryone: true,
...adminProps,
status: 'partial-sent',
direction: 'outgoing',
});
const propsOutgoingError = createProps({
timestamp: Date.now() - 60 * 1000,
conversationType: 'group',
contactNameColor: '100',
deletedForEveryone: true,
...adminProps,
status: 'error',
direction: 'outgoing',
});
const propsIncomingPartialError = createProps({
timestamp: Date.now() - 60 * 1000,
conversationType: 'group',
contactNameColor: '100',
deletedForEveryone: true,
...adminProps,
status: 'partial-sent',
direction: 'incoming',
});
const propsIncomingError = createProps({
timestamp: Date.now() - 60 * 1000,
conversationType: 'group',
contactNameColor: '100',
deletedForEveryone: true,
...adminProps,
status: 'error',
direction: 'incoming',
});
return (
<>
{renderThree(propsOutgoingPartialError)}
{renderThree(propsOutgoingError)}
{renderThree(propsIncomingPartialError)}
{renderThree(propsIncomingError)}
</>
);
}
export function AdminDeletedWithErrorCanRetry(): React.JSX.Element {
const adminProps = {
deletedForEveryoneByAdmin: {
conversationId: 'admin-conversation-id',
title: 'Alice Smith',
contactNameColor: '200' as const,
},
};
const propsOutgoingPartialError = createProps({
timestamp: Date.now() - 60 * 1000,
conversationType: 'group',
contactNameColor: '100',
deletedForEveryone: true,
canRetryDeleteForEveryone: true,
...adminProps,
status: 'partial-sent',
direction: 'outgoing',
});
const propsOutgoingError = createProps({
timestamp: Date.now() - 60 * 1000,
conversationType: 'group',
contactNameColor: '100',
deletedForEveryone: true,
canRetryDeleteForEveryone: true,
...adminProps,
status: 'error',
direction: 'outgoing',
});
const propsIncomingPartialError = createProps({
timestamp: Date.now() - 60 * 1000,
conversationType: 'group',
contactNameColor: '100',
deletedForEveryone: true,
canRetryDeleteForEveryone: true,
...adminProps,
status: 'partial-sent',
direction: 'incoming',
});
const propsIncomingError = createProps({
timestamp: Date.now() - 60 * 1000,
conversationType: 'group',
contactNameColor: '100',
deletedForEveryone: true,
canRetryDeleteForEveryone: true,
...adminProps,
status: 'error',
direction: 'incoming',
});
return (
<>
{renderThree(propsOutgoingPartialError)}
{renderThree(propsOutgoingError)}
{renderThree(propsIncomingPartialError)}
{renderThree(propsIncomingError)}
</>
);
}
export const CanDeleteForEveryone = Template.bind({});
CanDeleteForEveryone.args = {
status: 'read',
@@ -231,6 +231,8 @@ export function GroupMemberLabelEditor({
previews={[]}
isPinned={false}
canDeleteForEveryone={false}
canRetryDeleteForEveryone={false}
retryDeleteForEveryone={noop}
isBlocked={false}
isMessageRequestAccepted={false}
containerElementRef={messageContainer}
@@ -170,15 +170,27 @@ export const ConversationListItem: FunctionComponent<Props> = React.memo(
);
headerDate = draftTimestamp;
} else if (lastMessage?.deletedForEveryone) {
let deletedText: string;
if (lastMessage.deletedByAdminName != null) {
deletedText = i18n('icu:message--deletedByAdmin', {
admin: lastMessage.deletedByAdminName,
});
} else if (lastMessage.isOutgoing) {
deletedText = i18n('icu:message--deletedForEveryone--outgoing');
} else {
deletedText = i18n('icu:message--deletedForEveryone--incoming', {
name: lastMessage.authorName ?? '',
});
}
messageText = (
<span className={`${MESSAGE_TEXT_CLASS_NAME}__deleted-for-everyone`}>
{i18n('icu:message--deletedForEveryone')}
{deletedText}
</span>
);
} else if (lastMessage) {
messageText = (
<MessageBody
author={type === 'group' ? lastMessage.author : undefined}
author={type === 'group' ? lastMessage.author : null}
bodyRanges={lastMessage.bodyRanges}
disableJumbomoji
disableLinks
+2 -2
View File
@@ -103,10 +103,10 @@ export type CallingMessageJobData = z.infer<typeof callingMessageJobDataSchema>;
const deleteForEveryoneJobDataSchema = z.object({
type: z.literal(conversationQueueJobEnum.enum.DeleteForEveryone),
conversationId: z.string(),
messageId: z.string(),
isAdminDelete: z.boolean(),
targetMessageId: z.string(),
recipients: z.array(z.string()),
revision: z.number().optional(),
targetTimestamp: z.number(),
});
export type DeleteForEveryoneJobData = z.infer<
typeof deleteForEveryoneJobDataSchema
@@ -36,6 +36,10 @@ import type { LoggerType } from '../../types/Logging.std.js';
import type { ServiceIdString } from '../../types/ServiceId.std.js';
import { isStory } from '../../messages/helpers.std.js';
import { sendToGroup } from '../../util/sendToGroup.preload.js';
import { getMessageSentTimestamp } from '../../util/getMessageSentTimestamp.std.js';
import { getSourceServiceId } from '../../messages/sources.preload.js';
import { isAciString } from '../../util/isAciString.std.js';
import type { SendDeleteForEveryoneType } from '../../textsecure/SendMessage.preload.js';
import { shouldSendToDirectConversation } from './shouldSendToConversation.preload.js';
const { isNumber } = lodash;
@@ -53,20 +57,22 @@ export async function sendDeleteForEveryone(
data: DeleteForEveryoneJobData
): Promise<void> {
const {
messageId,
isAdminDelete,
targetMessageId,
recipients: recipientsFromJob,
revision,
targetTimestamp,
} = data;
const logId = `sendDeleteForEveryone(${conversation.idForLogging()}, ${messageId})`;
const logId = `sendDeleteForEveryone(${conversation.idForLogging()}, ${targetMessageId}, isAdminDelete=${isAdminDelete})`;
const message = await getMessageById(messageId);
const message = await getMessageById(targetMessageId);
if (!message) {
log.error(`${logId}: Failed to fetch message. Failing job.`);
return;
}
const targetTimestamp = getMessageSentTimestamp(message.attributes, { log });
const story = isStory(message.attributes);
if (story && !isGroupV2(conversation.attributes)) {
log.error(`${logId}: 1-on-1 Story DOE must use its own job. Failing job`);
@@ -85,7 +91,7 @@ export async function sendDeleteForEveryone(
const sendType = 'deleteForEveryone';
const contentHint = ContentHint.Resendable;
const messageIds = [messageId];
const messageIds = [targetMessageId];
const deletedForEveryoneSendStatus = message.get(
'deletedForEveryoneSendStatus'
@@ -109,12 +115,25 @@ export async function sendDeleteForEveryone(
);
}
// Build the delete for everyone options
const targetAuthorServiceId = getSourceServiceId(message.attributes);
strictAssert(
targetAuthorServiceId != null && isAciString(targetAuthorServiceId),
`${logId}: Could not get target author ACI`
);
const deleteForEveryone: SendDeleteForEveryoneType = {
isAdminDelete,
targetSentTimestamp: targetTimestamp,
targetAuthorAci: targetAuthorServiceId,
};
await conversation.queueJob(
'conversationQueue/sendDeleteForEveryone',
async abortSignal => {
log.info(
`${logId}: Sending deleteForEveryone with timestamp ${timestamp}` +
`for message ${targetTimestamp}, isStory=${story}`
` for message ${targetTimestamp}, isStory=${story}`
);
let profileKey: Uint8Array | undefined;
@@ -129,7 +148,7 @@ export async function sendDeleteForEveryone(
try {
if (isMe(conversation.attributes)) {
const proto = await messaging.getContentMessage({
deletedForEveryoneTimestamp: targetTimestamp,
deleteForEveryone,
profileKey,
recipients: conversation.getRecipients(),
timestamp,
@@ -172,7 +191,7 @@ export async function sendDeleteForEveryone(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
serviceId: conversation.getSendTarget()!,
messageOptions: {
deletedForEveryoneTimestamp: targetTimestamp,
deleteForEveryone,
timestamp,
profileKey,
},
@@ -212,11 +231,11 @@ export async function sendDeleteForEveryone(
contentHint,
groupSendOptions: {
groupV2: groupV2Info,
deletedForEveryoneTimestamp: targetTimestamp,
deleteForEveryone,
timestamp,
profileKey,
},
messageId,
messageId: targetMessageId,
sendOptions,
sendTarget: conversation.toSenderKeyTarget(),
sendType: 'deleteForEveryone',
@@ -30,6 +30,7 @@ import { SendMessageProtoError } from '../../textsecure/Errors.std.js';
import { strictAssert } from '../../util/assert.std.js';
import type { LoggerType } from '../../types/Logging.std.js';
import { isStory } from '../../messages/helpers.std.js';
import { itemStorage } from '../../textsecure/Storage.preload.js';
import { shouldSendToDirectConversation } from './shouldSendToConversation.preload.js';
export async function sendDeleteStoryForEveryone(
@@ -146,11 +147,17 @@ export async function sendDeleteStoryForEveryone(
const serviceId = conversation.getSendTarget();
strictAssert(serviceId, 'conversation has no service id');
const ourAci = itemStorage.user.getCheckedAci();
await handleMessageSend(
messaging.sendMessageToServiceId({
serviceId,
messageOptions: {
deletedForEveryoneTimestamp: targetTimestamp,
deleteForEveryone: {
isAdminDelete: false,
targetSentTimestamp: targetTimestamp,
targetAuthorAci: ourAci,
},
timestamp,
profileKey: conversation.get('profileSharing')
? profileKey
@@ -223,7 +223,6 @@ export async function sendNormalMessage(
attachments,
body,
contact,
deletedForEveryoneTimestamp,
expireTimer,
isViewOnce,
bodyRanges,
@@ -302,7 +301,6 @@ export async function sendNormalMessage(
body,
bodyRanges,
contact,
deletedForEveryoneTimestamp,
expireTimer,
expireTimerVersion: conversation.getExpireTimerVersion(),
groupV2: conversation.getGroupV2Info({
@@ -356,7 +354,6 @@ export async function sendNormalMessage(
attachments,
bodyRanges,
contact,
deletedForEveryoneTimestamp,
expireTimer,
groupV2: groupV2Info,
isViewOnce,
@@ -401,7 +398,6 @@ export async function sendNormalMessage(
body,
bodyRanges,
contact,
deletedForEveryoneTimestamp,
expireTimer,
expireTimerVersion: conversation.getExpireTimerVersion(),
isViewOnce,
@@ -599,7 +595,6 @@ async function getMessageSendData({
attachments: Array<SignalService.IAttachmentPointer>;
body: undefined | string;
contact?: Array<EmbeddedContactWithUploadedAvatar>;
deletedForEveryoneTimestamp: undefined | number;
expireTimer: undefined | DurationInSeconds;
bodyRanges: undefined | ReadonlyArray<RawBodyRange>;
isViewOnce?: boolean;
@@ -721,7 +716,6 @@ async function getMessageSendData({
],
body,
contact,
deletedForEveryoneTimestamp: message.get('deletedForEveryoneTimestamp'),
expireTimer: message.get('expireTimer'),
isViewOnce: message.get('isViewOnce'),
bodyRanges: getPropForTimestamp({
+1 -1
View File
@@ -115,7 +115,7 @@ export async function sendNullMessage(
attachments: [],
bodyRanges: [],
contact: [],
deletedForEveryoneTimestamp: undefined,
deleteForEveryone: undefined,
expireTimer: undefined,
groupV2: groupV2Info,
body: undefined,
+18 -12
View File
@@ -2,24 +2,28 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { MessageAttributesType } from '../model-types.d.ts';
import type { AciString } from '../types/ServiceId.std.js';
import { getAuthorId } from '../messages/sources.preload.js';
import { DataReader } from '../sql/Client.preload.js';
import { createLogger } from '../logging/log.std.js';
import * as Errors from '../types/errors.std.js';
import { deleteForEveryone } from '../util/deleteForEveryone.preload.js';
import { receiveDeleteForEveryone } from '../util/deleteForEveryone.preload.js';
import { drop } from '../util/drop.std.js';
import { getMessageSentTimestampSet } from '../util/getMessageSentTimestampSet.std.js';
import { MessageModel } from '../models/messages.preload.js';
const log = createLogger('Deletes');
export type DeleteAttributesType = {
export type DeleteAttributesType = Readonly<{
envelopeId: string;
isAdminDelete: boolean;
targetSentTimestamp: number;
serverTimestamp: number;
fromId: string;
targetAuthorAci: AciString;
targetConversationId: string;
deleteServerTimestamp: number;
deleteSentByAci: AciString;
removeFromMessageReceiverCache: () => unknown;
};
}>;
const deletes = new Map<string, DeleteAttributesType>();
@@ -36,7 +40,7 @@ export function forMessage(
const matchingDeletes = deleteValues.filter(item => {
return (
item.fromId === getAuthorId(messageAttributes) &&
item.targetConversationId === getAuthorId(messageAttributes) &&
sentTimestamps.has(item.targetSentTimestamp)
);
});
@@ -55,14 +59,14 @@ export function forMessage(
export async function onDelete(del: DeleteAttributesType): Promise<void> {
deletes.set(del.envelopeId, del);
const logId = `Deletes.onDelete(timestamp=${del.targetSentTimestamp})`;
const logId = `Deletes.onDelete(timestamp=${del.targetSentTimestamp}, isAdminDelete=${del.isAdminDelete})`;
try {
// The conversation the deleted message was in; we have to find it in the database
// to to figure that out.
const targetConversation =
await window.ConversationController.getConversationForTargetMessage(
del.fromId,
del.targetConversationId,
del.targetSentTimestamp
);
@@ -80,9 +84,11 @@ export async function onDelete(del: DeleteAttributesType): Promise<void> {
del.targetSentTimestamp
);
const targetMessage = messages.find(
m => del.fromId === getAuthorId(m) && !m.deletedForEveryone
);
const targetMessage = messages.find(m => {
return (
del.targetConversationId === getAuthorId(m) && !m.deletedForEveryone
);
});
if (!targetMessage) {
log.info(`${logId}: No message for DOE 2`);
@@ -93,7 +99,7 @@ export async function onDelete(del: DeleteAttributesType): Promise<void> {
new MessageModel(targetMessage)
);
await deleteForEveryone(message, del);
await receiveDeleteForEveryone(message, del);
remove(del);
})
+5 -1
View File
@@ -57,7 +57,10 @@ import { hydrateStoryContext } from '../util/hydrateStoryContext.preload.js';
import { isMessageEmpty } from '../util/isMessageEmpty.preload.js';
import { isValidTapToView } from '../util/isValidTapToView.std.js';
import { getNotificationTextForMessage } from '../util/getNotificationTextForMessage.preload.js';
import { getMessageAuthorText } from '../util/getMessageAuthorText.preload.js';
import {
getMessageAuthorAci,
getMessageAuthorText,
} from '../util/getMessageAuthorText.preload.js';
import { GiftBadgeStates } from '../types/GiftBadgeStates.std.js';
import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer.std.js';
import { SignalService as Proto } from '../protobuf/index.std.js';
@@ -752,6 +755,7 @@ export async function handleDataMessage(
conversation.set({
lastMessage: getNotificationTextForMessage(message.attributes),
lastMessageAuthor: getMessageAuthorText(message.attributes),
lastMessageAuthorAci: getMessageAuthorAci(message.attributes),
timestamp: message.get('sent_at'),
});
}
+4
View File
@@ -194,6 +194,7 @@ export type MessageAttributesType = {
dataMessage?: Uint8Array | null;
decrypted_at?: number;
deletedForEveryone?: boolean;
deletedForEveryoneByAdminAci?: AciString;
deletedForEveryoneTimestamp?: number;
errors?: ReadonlyArray<CustomError> | null;
expirationStartTimestamp?: number | null;
@@ -394,9 +395,12 @@ export type ConversationAttributesType = {
removalStage?: 'justNotification' | 'messageRequest';
isPinned?: boolean;
lastMessageDeletedForEveryone?: boolean;
lastMessageDeletedForEveryoneByAdminAci?: AciString;
lastMessageAuthorAci?: AciString | null;
lastMessage?: string | null;
lastMessageBodyRanges?: ReadonlyArray<RawBodyRange>;
lastMessagePrefix?: string;
/** @deprecated Use lastMessageAuthorAci instead */
lastMessageAuthor?: string | null;
lastMessageStatus?: LastMessageStatus | null;
lastMessageReceivedAt?: number;
+11 -1
View File
@@ -211,7 +211,10 @@ import {
} from '../util/zkgroup.node.js';
import { incrementMessageCounter } from '../util/incrementMessageCounter.preload.js';
import { generateMessageId } from '../util/generateMessageId.node.js';
import { getMessageAuthorText } from '../util/getMessageAuthorText.preload.js';
import {
getMessageAuthorAci,
getMessageAuthorText,
} from '../util/getMessageAuthorText.preload.js';
import { downscaleOutgoingAttachment } from '../util/attachments.preload.js';
import {
MessageRequestResponseSource,
@@ -4502,6 +4505,8 @@ export class ConversationModel {
| 'lastMessageReceivedAtMs'
| 'timestamp'
| 'lastMessageDeletedForEveryone'
| 'lastMessageDeletedForEveryoneByAdminAci'
| 'lastMessageAuthorAci'
> {
const ourConversationId =
window.ConversationController.getOurConversationId();
@@ -4543,6 +4548,10 @@ export class ConversationModel {
lastMessageReceivedAtMs,
timestamp,
lastMessageDeletedForEveryone: preview?.deletedForEveryone || false,
lastMessageDeletedForEveryoneByAdminAci:
preview?.deletedForEveryoneByAdminAci,
lastMessageAuthorAci:
preview != null ? getMessageAuthorAci(preview) : undefined,
};
}
@@ -5401,6 +5410,7 @@ export class ConversationModel {
this.set({
lastMessage: null,
lastMessageAuthor: null,
lastMessageAuthorAci: undefined,
timestamp: null,
active_at: null,
pendingUniversalTimer: undefined,
+12 -1
View File
@@ -1057,6 +1057,9 @@ export class BackupExportStream extends Readable {
hasSeenGroupStoryEducationSheet: itemStorage.get(
'hasSeenGroupStoryEducationSheet'
),
hasSeenAdminDeleteEducationDialog: itemStorage.get(
'hasSeenAdminDeleteEducationDialog'
),
phoneNumberSharingMode,
// Note that this should be called before `toDefaultChatStyle` because
// it builds `customColorIdByUuid`
@@ -1553,7 +1556,15 @@ export class BackupExportStream extends Readable {
message,
});
} else if (message.deletedForEveryone) {
result.remoteDeletedMessage = {};
if (message.deletedForEveryoneByAdminAci) {
result.adminDeletedMessage = {
adminId: this.#getOrPushPrivateRecipient({
serviceId: message.deletedForEveryoneByAdminAci,
}),
};
} else {
result.remoteDeletedMessage = {};
}
} else if (messageHasPaymentEvent(message)) {
const { payment } = message;
switch (payment.kind) {
+30
View File
@@ -912,6 +912,10 @@ export class BackupImportStream extends Writable {
'hasSeenGroupStoryEducationSheet',
accountSettings?.hasSeenGroupStoryEducationSheet === true
);
await itemStorage.put(
'hasSeenAdminDeleteEducationDialog',
accountSettings?.hasSeenAdminDeleteEducationDialog === true
);
await itemStorage.put(
'preferredReactionEmoji',
accountSettings?.preferredReactionEmoji || []
@@ -2560,6 +2564,32 @@ export class BackupImportStream extends Writable {
additionalMessages: [],
};
}
if (chatItem.adminDeletedMessage) {
strictAssert(
chatItem.adminDeletedMessage.adminId != null,
'adminDeletedMessage: Missing adminId'
);
const adminConversation = this.#recipientIdToConvo.get(
chatItem.adminDeletedMessage.adminId.toNumber()
);
strictAssert(
adminConversation != null,
'adminDeletedMessage: Missing admin conversation'
);
strictAssert(
isAciString(adminConversation.serviceId),
'adminConversation: Missing serviceId'
);
return {
message: {
isErased: true,
deletedForEveryone: true,
deletedForEveryoneByAdminAci: adminConversation.serviceId,
},
additionalMessages: [],
};
}
if (chatItem.remoteDeletedMessage) {
return {
message: {
+13
View File
@@ -589,6 +589,14 @@ export function toAccountRecord(
hasSeenGroupStoryEducationSheet;
}
const hasSeenAdminDeleteEducationDialog = itemStorage.get(
'hasSeenAdminDeleteEducationDialog'
);
if (hasSeenAdminDeleteEducationDialog != null) {
accountRecord.hasSeenAdminDeleteEducationDialog =
hasSeenAdminDeleteEducationDialog;
}
const hasKeyTransparencyDisabled = itemStorage.get(
'hasKeyTransparencyDisabled'
);
@@ -1645,6 +1653,7 @@ export async function mergeAccountRecord(
keepMutedChatsArchived,
hasCompletedUsernameOnboarding,
hasSeenGroupStoryEducationSheet,
hasSeenAdminDeleteEducationDialog,
hasSetMyStoriesPrivacy,
hasViewedOnboardingStory,
storiesDisabled,
@@ -1924,6 +1933,10 @@ export async function mergeAccountRecord(
hasCompletedUsernameOnboardingBool
);
}
await itemStorage.put(
'hasSeenAdminDeleteEducationDialog',
hasSeenAdminDeleteEducationDialog ?? false
);
{
const hasKeyTransparencyDisabled = Boolean(
automaticKeyVerificationDisabled
+30 -17
View File
@@ -157,8 +157,8 @@ import {
import { getMessageById } from '../../messages/getMessageById.preload.js';
import type { PanelArgsType } from '../../types/Panels.std.js';
import type { ConversationQueueJobData } from '../../jobs/conversationJobQueue.preload.js';
import { isOlderThan } from '../../util/timestamp.std.js';
import { DAY } from '../../util/durations/index.std.js';
import { areWeAdmin } from '../../util/areWeAdmin.preload.js';
import { canRetrySendDeleteForEveryone } from '../../util/canDeleteForEveryone.preload.js';
import { isNotNil } from '../../util/isNotNil.std.js';
import { getMessageSentTimestamp } from '../../util/getMessageSentTimestamp.std.js';
import { removeLinkPreview } from '../../services/LinkPreview.preload.js';
@@ -295,13 +295,18 @@ export type ConversationTypeType = ReadonlyDeep<
export type LastMessageType = ReadonlyDeep<
| {
deletedForEveryone: false;
author?: string;
author?: string | null;
bodyRanges?: HydratedBodyRangesType;
prefix?: string;
status?: LastMessageStatus;
text: string;
}
| { deletedForEveryone: true }
| {
deletedForEveryone: true;
deletedByAdminName?: string | null;
isOutgoing?: boolean;
authorName?: string | null;
}
>;
export type DraftPreviewType = ReadonlyDeep<{
text: string;
@@ -2669,29 +2674,37 @@ export function retryDeleteForEveryone(
throw new Error(`retryDeleteForEveryone: Message ${messageId} missing!`);
}
if (isOlderThan(message.get('sent_at'), DAY)) {
const conversation = window.ConversationController.get(
message.get('conversationId')
);
if (!conversation) {
throw new Error(
'retryDeleteForEveryone: Message too old to retry delete for everyone!'
`retryDeleteForEveryone: Conversation for ${messageId} missing!`
);
}
try {
const conversation = window.ConversationController.get(
message.get('conversationId')
);
if (!conversation) {
throw new Error(
`retryDeleteForEveryone: Conversation for ${messageId} missing!`
);
}
const isAdminDelete = message.get('deletedForEveryoneByAdminAci') != null;
const ourAci = itemStorage.user.getCheckedAci();
const result = canRetrySendDeleteForEveryone({
targetMessage: message.attributes,
targetConversation: conversation.attributes,
isAdminDelete,
isDeleterGroupAdmin: areWeAdmin(conversation.attributes),
ourAci,
});
if (!result.ok) {
throw new Error(`retryDeleteForEveryone: Cannot retry: ${result.reason}`);
}
try {
const jobData: ConversationQueueJobData = {
type: conversationQueueJobEnum.enum.DeleteForEveryone,
conversationId: conversation.id,
messageId,
isAdminDelete,
targetMessageId: messageId,
recipients: conversation.getRecipients(),
revision: conversation.get('revision'),
targetTimestamp: message.get('sent_at'),
};
log.info(
+10
View File
@@ -20,6 +20,8 @@ import type { StorageAccessType } from '../../types/Storage.d.ts';
import { actions as conversationActions } from './conversations.preload.js';
import type { ConfigMapType as RemoteConfigType } from '../../RemoteConfig.dom.js';
import type { EmojiSkinTone } from '../../components/fun/data/emojis.std.js';
import type { StateThunk } from '../types.std.js';
import { storageServiceUploadJob } from '../../services/storage.preload.js';
const { omit } = lodash;
@@ -81,6 +83,7 @@ export const actions = {
setGlobalDefaultConversationColor,
toggleNavTabsCollapse,
setEmojiSkinToneDefault,
onSeenAdminDeleteEducationDialog,
putItem,
putItemExternal,
removeItem,
@@ -287,6 +290,13 @@ function toggleNavTabsCollapse(
};
}
function onSeenAdminDeleteEducationDialog(): StateThunk<ItemPutAction> {
return dispatch => {
dispatch(putItem('hasSeenAdminDeleteEducationDialog', true));
storageServiceUploadJob({ reason: 'onSeenAdminDeleteEducationDialog' });
};
}
// Reducer
export function getEmptyState(): ItemsStateType {
+6
View File
@@ -313,3 +313,9 @@ export const getSeenPinMessageDisappearingMessagesWarningCount: StateSelector<nu
getItems,
state => state.seenPinMessageDisappearingMessagesWarningCount ?? 0
);
export const getHasSeenAdminDeleteEducationDialog: StateSelector<boolean> =
createSelector(
getItems,
state => state.hasSeenAdminDeleteEducationDialog ?? false
);
+411 -158
View File
@@ -11,6 +11,7 @@ import type { ReadonlyDeep } from 'type-fest';
import type {
LastMessageStatus,
ReadonlyMessageAttributesType,
CustomError,
MessageReactionType,
QuotedAttachmentType,
ShallowChallengeError,
@@ -57,7 +58,7 @@ import type { EmbeddedContactForUIType } from '../../types/EmbeddedContact.std.j
import { embeddedContactSelector } from '../../types/EmbeddedContact.std.js';
import type { HydratedBodyRangesType } from '../../types/BodyRange.std.js';
import { hydrateRanges } from '../../util/BodyRange.node.js';
import type { AssertProps } from '../../types/Util.std.js';
import type { AssertProps, LocalizerType } from '../../types/Util.std.js';
import type { LinkPreviewForUIType } from '../../types/message/LinkPreviews.std.js';
import { getMentionsRegex } from '../../types/Message.std.js';
import { SignalService as Proto } from '../../protobuf/index.std.js';
@@ -78,7 +79,13 @@ import type { CallingNotificationType } from '../../util/callingNotification.std
import { getRecipients } from '../../util/getRecipients.dom.js';
import { getOwn } from '../../util/getOwn.std.js';
import { isNotNil } from '../../util/isNotNil.std.js';
import { isMoreRecentThan } from '../../util/timestamp.std.js';
import {
canSendDeleteForEveryone,
canRetrySendDeleteForEveryone,
} from '../../util/canDeleteForEveryone.preload.js';
import { isAdminDeleteSendEnabled } from '../../util/isAdminDeleteEnabled.dom.js';
import { isAciString } from '../../util/isAciString.std.js';
import { isSignalConversation } from '../../util/isSignalConversation.dom.js';
import * as iterables from '../../util/iterables.std.js';
import { strictAssert } from '../../util/assert.std.js';
import { canEditMessage } from '../../util/canEditMessage.dom.js';
@@ -135,7 +142,7 @@ import {
} from '../../messages/MessageSendState.std.js';
import { createLogger } from '../../logging/log.std.js';
import { getConversationColorAttributes } from '../../util/getConversationColorAttributes.std.js';
import { DAY, DurationInSeconds } from '../../util/durations/index.std.js';
import { DurationInSeconds } from '../../util/durations/index.std.js';
import { getStoryReplyText } from '../../util/getStoryReplyText.std.js';
import type { MessageAttributesWithPaymentEvent } from '../../messages/payments.std.js';
import {
@@ -147,7 +154,6 @@ import {
import { messageHasPaymentEvent } from '../../messages/payments.std.js';
import { calculateExpirationTimestamp } from '../../util/expirationTimer.std.js';
import { isSignalConversation } from '../../util/isSignalConversation.dom.js';
import type { AnyPaymentEvent } from '../../types/Payment.std.js';
import { isPaymentNotificationEvent } from '../../types/Payment.std.js';
import type {
@@ -174,7 +180,6 @@ const { groupBy, isEmpty, isNumber, isObject, map } = lodash;
const log = createLogger('message');
export { isIncoming, isOutgoing, isStory };
const linkify = new LinkifyIt();
type FormattedContact = Partial<ConversationType> &
@@ -949,7 +954,10 @@ export const getPropsForMessage = (
payment,
canCopy: canCopy(message),
canEditMessage: canEditMessage(message),
canDeleteForEveryone: canDeleteForEveryone(message, conversation.isMe),
canDeleteForEveryone: canDeleteForEveryoneInSelector(message, {
conversation,
ourAci,
}),
canDownload: canDownload(message, conversationSelector),
canEndPoll: canEndPoll(message),
canForward: canForward(message),
@@ -957,7 +965,10 @@ export const getPropsForMessage = (
canReact: canReact(message, ourConversationId, conversationSelector),
canReply: canReply(message, ourConversationId, conversationSelector),
canRetry: hasErrors(message),
canRetryDeleteForEveryone: canRetryDeleteForEveryone(message),
canRetryDeleteForEveryone: canRetryDeleteForEveryone(message, {
conversation,
ourAci,
}),
contact: getPropsForEmbeddedContact(message, regionCode, accountSelector),
contactLabel,
contactNameColor,
@@ -967,6 +978,11 @@ export const getPropsForMessage = (
conversationType: isGroup ? 'group' : 'direct',
customColor,
deletedForEveryone: message.deletedForEveryone || false,
deletedForEveryoneByAdmin: getDeletedForEveryoneByAdmin(message, {
conversationSelector,
contactNameColors,
ourAci,
}),
direction: isIncoming(message) ? 'incoming' : 'outgoing',
displayLimit: message.displayLimit,
expirationLength,
@@ -2016,14 +2032,6 @@ export function getMessagePropStatus(
>,
ourConversationId: string | undefined
): LastMessageStatus | undefined {
if (!isOutgoing(message)) {
return hasErrors(message) ? 'error' : undefined;
}
if (getLastChallengeError(message)) {
return 'paused';
}
const {
deletedForEveryone,
deletedForEveryoneFailed,
@@ -2049,6 +2057,14 @@ export function getMessagePropStatus(
}
}
if (!isOutgoing(message)) {
return hasErrors(message) ? 'error' : undefined;
}
if (getLastChallengeError(message)) {
return 'paused';
}
if (
ourConversationId &&
isMessageJustForMe(sendStateByConversationId, ourConversationId)
@@ -2092,6 +2108,38 @@ export function getMessagePropStatus(
return 'sending';
}
function getDeletedForEveryoneByAdmin(
message: MessageWithUIFieldsType,
options: {
conversationSelector: GetConversationByIdType;
contactNameColors: Map<string, string>;
ourAci: AciString | undefined;
}
): PropsData['deletedForEveryoneByAdmin'] {
const { deletedForEveryoneByAdminAci } = message;
if (deletedForEveryoneByAdminAci == null) {
return undefined;
}
// If the admin deleted their own message, display it like a normal delete
const messageAuthorAci = getSourceServiceId(message, options.ourAci);
if (deletedForEveryoneByAdminAci === messageAuthorAci) {
return undefined;
}
const adminConversationId =
window.ConversationController.getConversationId(
deletedForEveryoneByAdminAci
) ?? '';
const adminConversation = options.conversationSelector(adminConversationId);
return {
conversationId: adminConversationId,
title: adminConversation?.title ?? '',
contactNameColor: getContactNameColor(
options.contactNameColors,
adminConversationId
),
};
}
export function getPropsForEmbeddedContact(
message: MessageWithUIFieldsType,
regionCode: string | undefined,
@@ -2300,57 +2348,201 @@ export function canCopy(
return !message.deletedForEveryone && Boolean(message.body);
}
export function canDeleteForEveryone(
type CanDeleteForEveryoneConversation = Pick<
ConversationType,
'id' | 'e164' | 'serviceId' | 'groupId' | 'groupVersion' | 'areWeAdmin'
>;
type MessageCanDeleteForEveryoneResult = Readonly<{
canDeleteForEveryone: boolean;
needsAdminDelete: boolean;
isDeletingOwnMessage: boolean;
}>;
function getMessageCanDeleteForEveryone(
message: Pick<
MessageWithUIFieldsType,
| 'type'
| 'deletedForEveryone'
| 'sent_at'
| 'serverTimestamp'
| 'sendStateByConversationId'
| 'sourceServiceId'
| 'sms'
>,
isMe: boolean
): boolean {
return (
// Is this an SMS restored from backup?
!message.sms &&
// Is this a message I sent?
isOutgoing(message) &&
// Has the message already been deleted?
!message.deletedForEveryone &&
// Is it too old to delete? (we relax that requirement in Note to Self)
(isMoreRecentThan(message.sent_at, DAY) || isMe) &&
// Is it sent to anyone?
someSendStatus(message.sendStateByConversationId ?? {}, isSent)
);
options: {
conversation: CanDeleteForEveryoneConversation;
ourAci: AciString | undefined;
}
): MessageCanDeleteForEveryoneResult {
const { conversation, ourAci } = options;
const isDeletingOwnMessage = isOutgoing(message);
if (!ourAci) {
return {
canDeleteForEveryone: false,
needsAdminDelete: false,
isDeletingOwnMessage,
};
}
// For outgoing messages, we must have actually sent to someone
if (
isDeletingOwnMessage &&
!someSendStatus(message.sendStateByConversationId ?? {}, isSent)
) {
return {
canDeleteForEveryone: false,
needsAdminDelete: false,
isDeletingOwnMessage,
};
}
const messageAuthorAci = isDeletingOwnMessage
? ourAci
: message.sourceServiceId;
if (!messageAuthorAci || !isAciString(messageAuthorAci)) {
return {
canDeleteForEveryone: false,
needsAdminDelete: false,
isDeletingOwnMessage,
};
}
const result = canSendDeleteForEveryone({
targetMessage: message,
targetConversation: conversation,
ourAci,
isDeleterGroupAdmin: Boolean(conversation.areWeAdmin),
});
if (!result.ok) {
return {
canDeleteForEveryone: false,
needsAdminDelete: false,
isDeletingOwnMessage,
};
}
if (result.needsAdminDelete) {
if (!isAdminDeleteSendEnabled()) {
return {
canDeleteForEveryone: false,
needsAdminDelete: false,
isDeletingOwnMessage,
};
}
return {
canDeleteForEveryone: true,
needsAdminDelete: true,
isDeletingOwnMessage,
};
}
return {
canDeleteForEveryone: true,
needsAdminDelete: false,
isDeletingOwnMessage,
};
}
export const canDeleteMessagesForEveryone = createSelector(
function canDeleteForEveryoneInSelector(
message: Pick<
MessageWithUIFieldsType,
| 'type'
| 'deletedForEveryone'
| 'sent_at'
| 'serverTimestamp'
| 'sendStateByConversationId'
| 'sourceServiceId'
| 'sms'
>,
options: {
conversation: CanDeleteForEveryoneConversation;
ourAci: AciString | undefined;
}
): boolean {
return getMessageCanDeleteForEveryone(message, options).canDeleteForEveryone;
}
export type MessagesCanDeleteForEveryoneResult = Readonly<{
canDeleteForEveryone: boolean;
needsAdminDelete: boolean;
isDeletingOwnMessages: boolean;
}>;
export const getMessagesCanDeleteForEveryone = createSelector(
[
getMessages,
(_state, options: { messageIds: ReadonlyArray<string>; isMe: boolean }) =>
options,
(
_state,
options: {
messageIds: ReadonlyArray<string>;
conversation: CanDeleteForEveryoneConversation;
ourAci: AciString | undefined;
}
) => options,
],
(messagesLookup, options) => {
return options.messageIds.every(messageId => {
(messagesLookup, options): MessagesCanDeleteForEveryoneResult => {
let canDeleteForEveryone = true;
let needsAdminDelete = false;
let isDeletingOwnMessages = true;
for (const messageId of options.messageIds) {
const message = getOwn(messagesLookup, messageId);
return message != null && canDeleteForEveryone(message, options.isMe);
});
if (message == null) {
log.warn(`Message ${messageId} not found, maybe it was deleted`);
continue;
}
const messageResult = getMessageCanDeleteForEveryone(message, options);
if (!messageResult.canDeleteForEveryone) {
canDeleteForEveryone = false;
} else if (messageResult.needsAdminDelete) {
needsAdminDelete = true;
}
if (!messageResult.isDeletingOwnMessage) {
isDeletingOwnMessages = false;
}
}
if (!canDeleteForEveryone) {
needsAdminDelete = false;
}
return { canDeleteForEveryone, needsAdminDelete, isDeletingOwnMessages };
}
);
export function canRetryDeleteForEveryone(
message: Pick<
MessageWithUIFieldsType,
'deletedForEveryone' | 'deletedForEveryoneFailed' | 'sent_at'
>
| 'type'
| 'sourceServiceId'
| 'deletedForEveryone'
| 'deletedForEveryoneByAdminAci'
| 'deletedForEveryoneFailed'
| 'sent_at'
| 'serverTimestamp'
| 'sms'
>,
options: {
conversation: CanDeleteForEveryoneConversation;
ourAci: AciString | undefined;
}
): boolean {
return Boolean(
message.deletedForEveryone &&
message.deletedForEveryoneFailed &&
// Is it too old to delete?
isMoreRecentThan(message.sent_at, DAY)
);
const { conversation, ourAci } = options;
if (!ourAci) {
return false;
}
const isAdminDelete = message.deletedForEveryoneByAdminAci != null;
const result = canRetrySendDeleteForEveryone({
targetMessage: message,
targetConversation: conversation,
isAdminDelete,
isDeleterGroupAdmin: Boolean(conversation.areWeAdmin),
ourAci,
});
return result.ok;
}
export function canEndPoll(
@@ -2459,6 +2651,159 @@ export function getLastChallengeError(
const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError';
function getMessageDetailErrors(
message: MessageWithUIFieldsType,
i18n: LocalizerType
): ReadonlyArray<CustomError> {
const messageErrors = message.errors ?? [];
// This will make the error message for outgoing key errors a bit nicer
return messageErrors.map(error => {
if (error.name === OUTGOING_KEY_ERROR) {
return { ...error, message: i18n('icu:newIdentity') };
}
return error;
});
}
function getDeleteDetailRecipients(
message: MessageWithUIFieldsType,
options: { conversationSelector: GetConversationByIdType }
): ReadonlyArray<SmartMessageDetailContact> {
const { deletedForEveryoneSendStatus = {} } = message;
const { conversationSelector } = options;
return Object.keys(deletedForEveryoneSendStatus).map(conversationId => {
const isSentToRecipient = deletedForEveryoneSendStatus[conversationId];
let status: SendStatus;
if (isSentToRecipient) {
status = SendStatus.Sent;
} else if (message.deletedForEveryoneFailed) {
status = SendStatus.Failed;
} else {
status = SendStatus.Pending;
}
return {
...conversationSelector(conversationId),
status,
errors: [],
isOutgoingKeyError: false,
isUnidentifiedDelivery: false,
};
});
}
function getMessageDetailRecipients(
message: MessageWithUIFieldsType,
allErrors: ReadonlyArray<CustomError>,
options: {
conversationSelector: GetConversationByIdType;
hasUnidentifiedDeliveryIndicators: boolean;
ourAci: AciString | undefined;
ourConversationId: string;
ourNumber: string | undefined;
}
): ReadonlyArray<SmartMessageDetailContact> {
const {
conversationSelector,
hasUnidentifiedDeliveryIndicators,
ourAci,
ourConversationId,
ourNumber,
} = options;
const {
sendStateByConversationId = {},
unidentifiedDeliveries = [],
unidentifiedDeliveryReceived,
} = message;
const unidentifiedDeliveriesSet = new Set(
unidentifiedDeliveries.map(identifier =>
window.ConversationController.getConversationId(identifier)
)
);
let conversationIds: Array<string>;
if (isIncoming(message)) {
conversationIds = [
getAuthorId(message, {
conversationSelector,
ourConversationId,
ourNumber,
ourAci,
}),
].filter(isNotNil);
} else if (!isEmpty(sendStateByConversationId)) {
if (isMessageJustForMe(sendStateByConversationId, ourConversationId)) {
conversationIds = [ourConversationId];
} else {
conversationIds = Object.keys(sendStateByConversationId).filter(
id => id !== ourConversationId
);
}
} else {
const messageConversation = window.ConversationController.get(
message.conversationId
);
const conversationRecipients = messageConversation
? getRecipients(messageConversation.attributes) || []
: [];
// Older messages don't have the recipients included on the message, so we fall
// back to the conversation's current recipients
conversationIds = conversationRecipients
.map((id: string) => window.ConversationController.getConversationId(id))
.filter(isNotNil);
}
const errorsGroupedById = groupBy(allErrors, error => {
const serviceId = error.serviceId || error.number;
if (!serviceId) {
return null;
}
return window.ConversationController.getConversationId(serviceId);
});
return conversationIds.map(id => {
const errorsForContact = getOwn(errorsGroupedById, id);
const isOutgoingKeyError = Boolean(
errorsForContact?.some(error => error.name === OUTGOING_KEY_ERROR)
);
let isUnidentifiedDelivery = false;
if (hasUnidentifiedDeliveryIndicators) {
isUnidentifiedDelivery = isIncoming(message)
? Boolean(unidentifiedDeliveryReceived)
: unidentifiedDeliveriesSet.has(id);
}
const sendState = getOwn(sendStateByConversationId, id);
let status = sendState?.status;
// If a message was only sent to yourself (Note to Self or a lonely group), it
// is shown read.
if (id === ourConversationId && status && isSent(status)) {
status = SendStatus.Read;
}
const statusUpdatedAt = sendState?.updatedAt;
const statusTimestamp =
statusUpdatedAt === message.timestamp ? undefined : statusUpdatedAt;
return {
...conversationSelector(id),
errors: errorsForContact,
isOutgoingKeyError,
isUnidentifiedDelivery,
status,
statusTimestamp,
};
});
}
export const getMessageDetailsSelector = createSelector(
getAccountSelector,
getCachedConversationMemberColorsSelector,
@@ -2500,121 +2845,29 @@ export const getMessageDetailsSelector = createSelector(
return;
}
const {
errors: messageErrors,
sendStateByConversationId = {},
unidentifiedDeliveries = [],
unidentifiedDeliveryReceived,
} = message;
let errors: ReadonlyArray<CustomError>;
let contacts: ReadonlyArray<SmartMessageDetailContact>;
const unidentifiedDeliveriesSet = new Set(
map(
unidentifiedDeliveries,
identifier =>
window.ConversationController.getConversationId(
identifier
) as string
)
);
let conversationIds: Array<string>;
if (isIncoming(message)) {
conversationIds = [
getAuthorId(message, {
conversationSelector,
ourConversationId,
ourNumber,
ourAci,
}),
].filter(isNotNil);
} else if (!isEmpty(sendStateByConversationId)) {
if (isMessageJustForMe(sendStateByConversationId, ourConversationId)) {
conversationIds = [ourConversationId];
} else {
conversationIds = Object.keys(sendStateByConversationId).filter(
id => id !== ourConversationId
);
}
} else {
const messageConversation = window.ConversationController.get(
message.conversationId
);
const conversationRecipients = messageConversation
? getRecipients(messageConversation.attributes) || []
: [];
// Older messages don't have the recipients included on the message, so we fall
// back to the conversation's current recipients
conversationIds = conversationRecipients
.map((id: string) =>
window.ConversationController.getConversationId(id)
)
.filter(isNotNil);
}
// This will make the error message for outgoing key errors a bit nicer
const allErrors = (messageErrors ?? []).map(error => {
if (error.name === OUTGOING_KEY_ERROR) {
return {
...error,
message: i18n('icu:newIdentity'),
};
}
return error;
});
// If an error has a specific number it's associated with, we'll show it next to
// that contact. Otherwise, it will be a standalone entry.
const errors = allErrors.filter(error =>
Boolean(error.serviceId || error.number)
);
const errorsGroupedById = groupBy(allErrors, error => {
const serviceId = error.serviceId || error.number;
if (!serviceId) {
return null;
}
return window.ConversationController.getConversationId(serviceId);
});
const contacts: ReadonlyArray<SmartMessageDetailContact> =
conversationIds.map(id => {
const errorsForContact = getOwn(errorsGroupedById, id);
const isOutgoingKeyError = Boolean(
errorsForContact?.some(error => error.name === OUTGOING_KEY_ERROR)
);
let isUnidentifiedDelivery = false;
if (hasUnidentifiedDeliveryIndicators) {
isUnidentifiedDelivery = isIncoming(message)
? Boolean(unidentifiedDeliveryReceived)
: unidentifiedDeliveriesSet.has(id);
}
const sendState = getOwn(sendStateByConversationId, id);
let status = sendState?.status;
// If a message was only sent to yourself (Note to Self or a lonely group), it
// is shown read.
if (id === ourConversationId && status && isSent(status)) {
status = SendStatus.Read;
}
const statusTimestamp = sendState?.updatedAt;
return {
...conversationSelector(id),
errors: errorsForContact,
isOutgoingKeyError,
isUnidentifiedDelivery,
status,
statusTimestamp:
statusTimestamp === message.timestamp
? undefined
: statusTimestamp,
};
if (message.deletedForEveryone) {
errors = [];
contacts = getDeleteDetailRecipients(message, {
conversationSelector,
});
} else {
const allErrors = getMessageDetailErrors(message, i18n);
// If an error has a specific number it's associated with, we'll show
// it next to that contact. Otherwise, it will be a standalone entry.
errors = allErrors.filter(error =>
Boolean(error.serviceId || error.number)
);
contacts = getMessageDetailRecipients(message, allErrors, {
conversationSelector,
hasUnidentifiedDeliveryIndicators,
ourAci,
ourConversationId,
ourNumber,
});
}
return {
contacts,
+23 -5
View File
@@ -8,7 +8,7 @@ import { getIntl } from '../selectors/user.std.js';
import { useGlobalModalActions } from '../ducks/globalModals.preload.js';
import DeleteMessagesModal from '../../components/DeleteMessagesModal.dom.js';
import { strictAssert } from '../../util/assert.std.js';
import { canDeleteMessagesForEveryone } from '../selectors/message.preload.js';
import { getMessagesCanDeleteForEveryone } from '../selectors/message.preload.js';
import { useConversationsActions } from '../ducks/conversations.preload.js';
import { useToastActions } from '../ducks/toast.preload.js';
import {
@@ -16,6 +16,9 @@ import {
getLastSelectedMessage,
} from '../selectors/conversations.dom.js';
import { getDeleteMessagesProps } from '../selectors/globalModals.std.js';
import { getHasSeenAdminDeleteEducationDialog } from '../selectors/items.dom.js';
import { useItemsActions } from '../ducks/items.preload.js';
import { itemStorage } from '../../textsecure/Storage.preload.js';
export const SmartDeleteMessagesModal = memo(
function SmartDeleteMessagesModal() {
@@ -29,18 +32,26 @@ export const SmartDeleteMessagesModal = memo(
const conversation = conversationSelector(conversationId);
const { isMe } = conversation;
const getCanDeleteForEveryone = useCallback(
const getDeleteForEveryoneType = useCallback(
(state: StateType) => {
return canDeleteMessagesForEveryone(state, { messageIds, isMe });
return getMessagesCanDeleteForEveryone(state, {
messageIds,
conversation,
ourAci: itemStorage.user.getAci(),
});
},
[messageIds, isMe]
[messageIds, conversation]
);
const canDeleteForEveryone = useSelector(getCanDeleteForEveryone);
const deleteForEveryoneResult = useSelector(getDeleteForEveryoneType);
const lastSelectedMessage = useSelector(getLastSelectedMessage);
const hasSeenAdminDeleteEducationDialog = useSelector(
getHasSeenAdminDeleteEducationDialog
);
const i18n = useSelector(getIntl);
const { toggleDeleteMessagesModal } = useGlobalModalActions();
const { deleteMessages, deleteMessagesForEveryone } =
useConversationsActions();
const { onSeenAdminDeleteEducationDialog } = useItemsActions();
const { showToast } = useToastActions();
const messageCount = deleteMessagesProps.messageIds.length;
@@ -69,15 +80,22 @@ export const SmartDeleteMessagesModal = memo(
onDelete?.();
}, [deleteMessagesForEveryone, messageIds, onDelete]);
const { canDeleteForEveryone, needsAdminDelete, isDeletingOwnMessages } =
deleteForEveryoneResult;
return (
<DeleteMessagesModal
isMe={isMe}
canDeleteForEveryone={canDeleteForEveryone}
needsAdminDelete={needsAdminDelete}
isDeletingOwnMessages={isDeletingOwnMessages}
hasSeenAdminDeleteEducationDialog={hasSeenAdminDeleteEducationDialog}
i18n={i18n}
messageCount={messageCount}
onClose={handleClose}
onDeleteForMe={handleDeleteForMe}
onDeleteForEveryone={handleDeleteForEveryone}
onSeenAdminDeleteEducationDialog={onSeenAdminDeleteEducationDialog}
showToast={showToast}
/>
);
+2
View File
@@ -58,6 +58,7 @@ export const SmartMessageDetail = memo(function SmartMessageDetail({
markAttachmentAsCorrupted,
messageExpanded,
openGiftBadge,
retryDeleteForEveryone,
retryMessageSend,
sendPollVote,
saveAttachment,
@@ -126,6 +127,7 @@ export const SmartMessageDetail = memo(function SmartMessageDetail({
message={message}
messageExpanded={messageExpanded}
openGiftBadge={openGiftBadge}
retryDeleteForEveryone={retryDeleteForEveryone}
retryMessageSend={retryMessageSend}
sendPollVote={sendPollVote}
pushPanelForConversation={pushPanelForConversation}
@@ -11,12 +11,12 @@ import type {
} from '../../../model-types.d.ts';
import type { ConversationType } from '../../../state/ducks/conversations.preload.js';
import {
generateGroupId,
getDefaultConversation,
getDefaultGroup,
} from '../../../test-helpers/getDefaultConversation.std.js';
import {
canDeleteForEveryone,
canReact,
canReply,
cleanBodyForDirectionCheck,
@@ -26,6 +26,14 @@ import {
isIncoming,
isOutgoing,
} from '../../../state/selectors/message.preload.js';
import type {
DeleteForEveryoneConversation,
DeleteForEveryoneMessage,
} from '../../../util/canDeleteForEveryone.preload.js';
import { canSendDeleteForEveryone } from '../../../util/canDeleteForEveryone.preload.js';
import type { AciString } from '../../../types/ServiceId.std.js';
import { SIGNAL_ACI } from '../../../types/SignalConversation.std.js';
import { OUR_ACI, setupBasics } from '../../backup/helpers.preload.js';
describe('state/selectors/messages', () => {
let ourConversationId: string;
@@ -68,99 +76,168 @@ describe('state/selectors/messages', () => {
});
describe('canDeleteForEveryone', () => {
it('returns false for incoming messages', () => {
const message = {
type: 'incoming' as const,
sent_at: Date.now() - 1000,
};
const isMe = false;
const GROUP_ID = generateGroupId();
const otherAci = uuid() as AciString;
assert.isFalse(canDeleteForEveryone(message, isMe));
function getDefaultMessage(): DeleteForEveryoneMessage {
return {
type: 'outgoing',
sourceServiceId: OUR_ACI,
sent_at: Date.now() - 1000,
serverTimestamp: undefined,
deletedForEveryone: undefined,
sms: undefined,
};
}
function getDefaultConvo(): DeleteForEveryoneConversation {
return {
id: uuid(),
e164: undefined,
serviceId: undefined,
groupId: undefined,
groupVersion: undefined,
};
}
beforeEach(async () => {
await setupBasics();
});
it('returns false for messages that were already deleted for everyone', () => {
const message = {
type: 'outgoing' as const,
deletedForEveryone: true,
sent_at: Date.now() - 1000,
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Read,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Delivered,
updatedAt: Date.now(),
},
},
};
const isMe = false;
assert.isFalse(canDeleteForEveryone(message, isMe));
it('rejects Signal conversation', () => {
const result = canSendDeleteForEveryone({
targetMessage: getDefaultMessage(),
targetConversation: { ...getDefaultConvo(), serviceId: SIGNAL_ACI },
ourAci: OUR_ACI,
isDeleterGroupAdmin: false,
});
assert.deepStrictEqual(result, {
ok: false,
reason: 'signal conversation',
});
});
it('returns false for messages that were are too old to delete', () => {
const message = {
type: 'outgoing' as const,
sent_at: Date.now() - moment.duration(25, 'hours').asMilliseconds(),
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Read,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Delivered,
updatedAt: Date.now(),
},
},
};
const isMe = false;
assert.isFalse(canDeleteForEveryone(message, isMe));
it('rejects Note to Self', () => {
const result = canSendDeleteForEveryone({
targetMessage: getDefaultMessage(),
targetConversation: { ...getDefaultConvo(), serviceId: OUR_ACI },
ourAci: OUR_ACI,
isDeleterGroupAdmin: false,
});
assert.deepStrictEqual(result, {
ok: false,
reason: 'note to self conversation',
});
});
it("returns false for messages that haven't been sent to anyone", () => {
const message = {
type: 'outgoing' as const,
sent_at: Date.now() - 1000,
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Failed,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Pending,
updatedAt: Date.now(),
},
},
};
const isMe = false;
assert.isFalse(canDeleteForEveryone(message, isMe));
it('rejects already deleted messages', () => {
const result = canSendDeleteForEveryone({
targetMessage: { ...getDefaultMessage(), deletedForEveryone: true },
targetConversation: getDefaultConvo(),
ourAci: OUR_ACI,
isDeleterGroupAdmin: false,
});
assert.deepStrictEqual(result, {
ok: false,
reason: 'already deleted',
});
});
it('returns true for messages that meet all criteria for deletion', () => {
const message = {
type: 'outgoing' as const,
sent_at: Date.now() - 1000,
sendStateByConversationId: {
[ourConversationId]: {
status: SendStatus.Sent,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Delivered,
updatedAt: Date.now(),
},
[uuid()]: {
status: SendStatus.Failed,
updatedAt: Date.now(),
},
},
};
const isMe = false;
it('rejects SMS messages', () => {
const result = canSendDeleteForEveryone({
targetMessage: { ...getDefaultMessage(), sms: true },
targetConversation: getDefaultConvo(),
ourAci: OUR_ACI,
isDeleterGroupAdmin: false,
});
assert.deepStrictEqual(result, {
ok: false,
reason: 'sms message',
});
});
assert.isTrue(canDeleteForEveryone(message, isMe));
it('rejects messages that are too old', () => {
const result = canSendDeleteForEveryone({
targetMessage: {
...getDefaultMessage(),
sent_at: Date.now() - moment.duration(25, 'hours').asMilliseconds(),
},
targetConversation: getDefaultConvo(),
ourAci: OUR_ACI,
isDeleterGroupAdmin: false,
});
assert.deepStrictEqual(result, {
ok: false,
reason: 'no permission',
});
});
it('rejects when deleter is not the author', () => {
const result = canSendDeleteForEveryone({
targetMessage: {
...getDefaultMessage(),
type: 'incoming',
sourceServiceId: otherAci,
},
targetConversation: getDefaultConvo(),
ourAci: OUR_ACI,
isDeleterGroupAdmin: false,
});
assert.deepStrictEqual(result, {
ok: false,
reason: 'no permission',
});
});
it('accepts with isAdminDelete=false for own recent message', () => {
const result = canSendDeleteForEveryone({
targetMessage: getDefaultMessage(),
targetConversation: getDefaultConvo(),
ourAci: OUR_ACI,
isDeleterGroupAdmin: false,
});
assert.deepStrictEqual(result, {
ok: true,
needsAdminDelete: false,
});
});
it('accepts with isAdminDelete=true for admin deleting others message', () => {
const result = canSendDeleteForEveryone({
targetMessage: {
...getDefaultMessage(),
type: 'incoming',
sourceServiceId: otherAci,
},
targetConversation: {
...getDefaultConvo(),
groupId: GROUP_ID,
groupVersion: 2,
},
ourAci: OUR_ACI,
isDeleterGroupAdmin: true,
});
assert.deepStrictEqual(result, {
ok: true,
needsAdminDelete: true,
});
});
it('prefers normal delete over admin delete for own messages', () => {
const result = canSendDeleteForEveryone({
targetMessage: getDefaultMessage(),
targetConversation: {
...getDefaultConvo(),
groupId: GROUP_ID,
groupVersion: 2,
},
ourAci: OUR_ACI,
isDeleterGroupAdmin: true,
});
assert.deepStrictEqual(result, {
ok: true,
needsAdminDelete: false,
});
});
});
+1
View File
@@ -123,6 +123,7 @@ function* createRecords({
storiesDisabled: false,
hasSeenGroupStoryEducationSheet: true,
hasCompletedUsernameOnboarding: true,
hasSeenAdminDeleteEducationDialog: false,
phoneNumberSharingMode:
Backups.AccountData.PhoneNumberSharingMode.EVERYBODY,
defaultChatStyle: {
@@ -9,6 +9,7 @@ import type { ConversationType } from '../state/ducks/conversations.preload.js';
import type { ServiceIdString } from '../types/ServiceId.std.js';
import { generateAci } from '../types/ServiceId.std.js';
import type { GroupListItemConversationType } from '../components/conversationList/GroupListItem.dom.js';
import { toBase64 } from '../Bytes.std.js';
import { getRandomColor } from './getRandomColor.std.js';
import { ConversationColors } from '../types/Colors.std.js';
import { StorySendMode } from '../types/Stories.std.js';
@@ -16,6 +17,12 @@ import { getAvatarPlaceholderGradient } from '../utils/getAvatarPlaceholderGradi
const { sample } = lodash;
export function generateGroupId(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return toBase64(bytes);
}
export const getAvatarPath = (): string =>
sample([
'/fixtures/kitten-1-64-64.jpg',
@@ -83,7 +90,7 @@ export function getDefaultGroup(
color: getRandomColor(),
conversationColor: ConversationColors[0],
groupDescription: casual.sentence,
groupId: generateUuid(),
groupId: generateGroupId(),
groupLink: casual.url,
groupVersion: 2,
id: generateUuid(),
+24 -9
View File
@@ -202,6 +202,12 @@ export const singleProtoJobDataSchema = z.object({
export type SingleProtoJobData = z.infer<typeof singleProtoJobDataSchema>;
export type SendDeleteForEveryoneType = Readonly<{
isAdminDelete: boolean;
targetSentTimestamp: number;
targetAuthorAci: AciString;
}>;
export type SharedMessageOptionsType = Readonly<{
// required
timestamp: number;
@@ -210,7 +216,7 @@ export type SharedMessageOptionsType = Readonly<{
body?: string;
bodyRanges?: ReadonlyArray<RawBodyRange>;
contact?: ReadonlyArray<EmbeddedContactWithUploadedAvatar>;
deletedForEveryoneTimestamp?: number;
deleteForEveryone?: SendDeleteForEveryoneType;
expireTimer?: DurationInSeconds;
flags?: number;
groupCallUpdate?: GroupCallUpdateType;
@@ -306,7 +312,7 @@ class Message {
dataMessage?: Proto.DataMessage;
deletedForEveryoneTimestamp?: number;
deleteForEveryone?: SendDeleteForEveryoneType;
groupCallUpdate?: GroupCallUpdateType;
@@ -333,7 +339,7 @@ class Message {
this.pollCreate = options.pollCreate;
this.pollTerminate = options.pollTerminate;
this.timestamp = options.timestamp;
this.deletedForEveryoneTimestamp = options.deletedForEveryoneTimestamp;
this.deleteForEveryone = options.deleteForEveryone;
this.groupCallUpdate = options.groupCallUpdate;
this.storyContext = options.storyContext;
// Polls
@@ -609,10 +615,19 @@ class Message {
if (this.isViewOnce) {
proto.isViewOnce = true;
}
if (this.deletedForEveryoneTimestamp) {
proto.delete = {
targetSentTimestamp: Long.fromNumber(this.deletedForEveryoneTimestamp),
};
if (this.deleteForEveryone) {
const { isAdminDelete, targetSentTimestamp, targetAuthorAci } =
this.deleteForEveryone;
if (isAdminDelete) {
proto.adminDelete = {
targetSentTimestamp: Long.fromNumber(targetSentTimestamp),
targetAuthorAciBinary: uuidToBytes(targetAuthorAci),
};
} else {
proto.delete = {
targetSentTimestamp: Long.fromNumber(targetSentTimestamp),
};
}
}
if (this.bodyRanges) {
proto.requiredProtocolVersion =
@@ -1144,10 +1159,10 @@ export class MessageSender {
options: Readonly<GroupMessageOptionsType>
): MessageOptionsType {
const {
deleteForEveryone,
attachments,
bodyRanges,
contact,
deletedForEveryoneTimestamp,
expireTimer,
flags,
groupCallUpdate,
@@ -1191,11 +1206,11 @@ export class MessageSender {
);
return {
deleteForEveryone,
attachments,
bodyRanges,
body,
contact,
deletedForEveryoneTimestamp,
expireTimer,
expireTimerVersion: undefined,
flags,
+6
View File
@@ -212,6 +212,11 @@ export type ProcessedDelete = {
targetSentTimestamp?: number;
};
export type ProcessedAdminDelete = Readonly<{
targetSentTimestamp: number;
targetAuthorAci: AciString;
}>;
export type ProcessedBodyRange = RawBodyRange;
export type ProcessedGroupCallUpdate = Proto.DataMessage.IGroupCallUpdate;
@@ -259,6 +264,7 @@ export type ProcessedDataMessage = {
pollVote?: ProcessedPollVote;
pollTerminate?: ProcessedPollTerminate;
delete?: ProcessedDelete;
adminDelete?: ProcessedAdminDelete;
bodyRanges?: ReadonlyArray<ProcessedBodyRange>;
groupCallUpdate?: ProcessedGroupCallUpdate;
storyContext?: ProcessedStoryContext;
@@ -29,6 +29,7 @@ import type {
ProcessedPollVote,
ProcessedPollTerminate,
ProcessedDelete,
ProcessedAdminDelete,
ProcessedGiftBadge,
ProcessedStoryContext,
ProcessedPinMessage,
@@ -424,6 +425,25 @@ export function processDelete(
};
}
export function processAdminDelete(
adminDelete?: Proto.DataMessage.IAdminDelete | null
): ProcessedAdminDelete | undefined {
if (!adminDelete) {
return undefined;
}
const targetSentTimestamp = adminDelete.targetSentTimestamp?.toNumber();
strictAssert(targetSentTimestamp, 'AdminDelete missing targetSentTimestamp');
const targetAuthorAci = fromAciUuidBytes(adminDelete.targetAuthorAciBinary);
strictAssert(targetAuthorAci, 'AdminDelete missing targetAuthorAciBinary');
return {
targetSentTimestamp,
targetAuthorAci,
};
}
export function processGiftBadge(
giftBadge: Proto.DataMessage.IGiftBadge | null | undefined
): ProcessedGiftBadge | undefined {
@@ -534,6 +554,7 @@ export function processDataMessage(
pollVote: processPollVote(message.pollVote),
pollTerminate: processPollTerminate(message.pollTerminate),
delete: processDelete(message.delete),
adminDelete: processAdminDelete(message.adminDelete),
bodyRanges: filterAndClean(message.bodyRanges),
groupCallUpdate: dropNull(message.groupCallUpdate),
storyContext: processStoryContext(message.storyContext),
+1
View File
@@ -109,6 +109,7 @@ export const messageAttrsToPreserveAfterErase = [
// all other, non-TS-required fields to preserve
'canReplyToStory',
'deletedForEveryone',
'deletedForEveryoneByAdminAci',
'deletedForEveryoneFailed',
'deletedForEveryoneSendStatus',
'deletedForEveryoneTimestamp',
+1
View File
@@ -119,6 +119,7 @@ export type StorageAccessType = {
showStickerPickerHint: boolean;
showStickersIntroduction: boolean;
seenPinMessageDisappearingMessagesWarningCount: number;
hasSeenAdminDeleteEducationDialog: boolean;
signedKeyId: number;
signedKeyIdPNI: number;
signedKeyUpdateTime: number;
+266
View 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,
});
}
+1 -1
View File
@@ -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}`
+67 -42
View File
@@ -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
View 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'));
}
+60 -2
View File
@@ -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
View 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);
}
+19
View File
@@ -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;
}
+2 -2
View File
@@ -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
View 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;
}
+4 -2
View File
@@ -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;
})
);
+7 -4
View File
@@ -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 }
)
+39 -24
View File
@@ -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(),
});
}