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