diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 621243c745..5283c9c8e3 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -6748,6 +6748,14 @@
"messageformat": "You can only select up to {count, plural, one {# message} other {# messages}} to delete for everyone",
"description": "delete selected messages > confirmation modal > deleted for everyone (disabled) > toast > too many messages to 'delete for everyone'"
},
+ "icu:DiscardDraftDialog__title": {
+ "messageformat": "Discard draft?",
+ "description": "Title of confirmation dialog when discarding a draft to edit a message"
+ },
+ "icu:DiscardDraftDialog__description": {
+ "messageformat": "This action can't be undone.",
+ "description": "Description text in confirmation dialog when discarding a draft to edit a message"
+ },
"icu:DeleteAttachmentModal__Title": {
"messageformat": "Delete item?",
"description": "Title of confirmation modal when deleting an attachment from media gallery"
diff --git a/ts/components/CompositionArea.dom.tsx b/ts/components/CompositionArea.dom.tsx
index f9dc0d340e..75b33378e6 100644
--- a/ts/components/CompositionArea.dom.tsx
+++ b/ts/components/CompositionArea.dom.tsx
@@ -90,6 +90,7 @@ import { tw } from '../axo/tw.dom.js';
import type { PollCreateType } from '../types/Polls.dom.js';
import { PollCreateModal } from './PollCreateModal.dom.js';
import { useDocumentKeyDown } from '../hooks/useDocumentKeyDown.dom.js';
+import { hasDraft } from '../util/hasDraft.std.js';
export type OwnProps = Readonly<{
acceptedMessageRequest: boolean | null;
@@ -490,11 +491,18 @@ export const CompositionArea = memo(function CompositionArea({
setAttachmentToEdit(attachment);
}
- const isComposerEmpty =
- !draftAttachments.length && !draftText && !draftEditMessage;
-
const maybeEditMessage = useCallback(() => {
- if (!isComposerEmpty || !lastEditableMessageId) {
+ if (lastEditableMessageId == null) {
+ return false;
+ }
+
+ const hasDraftMessage = hasDraft({
+ draft: draftText,
+ draftAttachments,
+ quotedMessageId,
+ });
+
+ if (hasDraftMessage) {
return false;
}
@@ -502,7 +510,9 @@ export const CompositionArea = memo(function CompositionArea({
return true;
}, [
conversationId,
- isComposerEmpty,
+ draftText,
+ draftAttachments,
+ quotedMessageId,
lastEditableMessageId,
setMessageToEdit,
]);
@@ -601,7 +611,14 @@ export const CompositionArea = memo(function CompositionArea({
setLarge(l => !l);
}, [setLarge]);
- const shouldShowMicrophone = !large && isComposerEmpty;
+ const shouldShowMicrophone =
+ !large &&
+ !hasDraft({
+ draft: draftText,
+ draftAttachments,
+ // ignore quotes, can be sent with voice message
+ quotedMessageId: null,
+ });
const showMediaQualitySelector = draftAttachments.some(isImageAttachment);
diff --git a/ts/components/DiscardDraftDialog.dom.stories.tsx b/ts/components/DiscardDraftDialog.dom.stories.tsx
new file mode 100644
index 0000000000..a40d1720cf
--- /dev/null
+++ b/ts/components/DiscardDraftDialog.dom.stories.tsx
@@ -0,0 +1,23 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React from 'react';
+import { action } from '@storybook/addon-actions';
+import type { Meta } from '@storybook/react';
+import { DiscardDraftDialog } from './DiscardDraftDialog.dom.js';
+
+const { i18n } = window.SignalContext;
+
+export default {
+ title: 'Components/DiscardDraftDialog',
+} satisfies Meta;
+
+export function Default(): React.JSX.Element {
+ return (
+
+ );
+}
diff --git a/ts/components/DiscardDraftDialog.dom.tsx b/ts/components/DiscardDraftDialog.dom.tsx
new file mode 100644
index 0000000000..8b77cc85eb
--- /dev/null
+++ b/ts/components/DiscardDraftDialog.dom.tsx
@@ -0,0 +1,49 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+import React, { useCallback } from 'react';
+import type { LocalizerType } from '../types/I18N.std.js';
+import { AxoAlertDialog } from '../axo/AxoAlertDialog.dom.js';
+
+export type DiscardDraftDialogProps = Readonly<{
+ i18n: LocalizerType;
+ onClose: () => void;
+ onDiscard: () => void;
+}>;
+
+export function DiscardDraftDialog(
+ props: DiscardDraftDialogProps
+): React.JSX.Element {
+ const { i18n, onClose } = props;
+ const handleOpenChange = useCallback(
+ (open: boolean) => {
+ if (!open) {
+ onClose();
+ }
+ },
+ [onClose]
+ );
+
+ return (
+
+
+
+
+ {i18n('icu:DiscardDraftDialog__title')}
+
+
+ {i18n('icu:DiscardDraftDialog__description')}
+
+
+
+ {i18n('icu:cancel')}
+
+ {i18n('icu:discard')}
+
+
+
+
+ );
+}
diff --git a/ts/components/GlobalModalContainer.dom.tsx b/ts/components/GlobalModalContainer.dom.tsx
index bceb4bbaff..c6a06f4d88 100644
--- a/ts/components/GlobalModalContainer.dom.tsx
+++ b/ts/components/GlobalModalContainer.dom.tsx
@@ -5,6 +5,7 @@ import React from 'react';
import type {
CallQualitySurveyPropsType,
DeleteMessagesPropsType,
+ DiscardDraftDialogPropsType,
EditHistoryMessagesType,
EditNicknameAndNoteModalPropsType,
ForwardMessagesPropsType,
@@ -98,6 +99,9 @@ export type PropsType = {
// DeleteMessageModal
deleteMessagesProps: DeleteMessagesPropsType | undefined;
renderDeleteMessagesModal: () => React.JSX.Element;
+ // DiscardDraftDialog
+ discardDraftDialogProps: DiscardDraftDialogPropsType | null;
+ renderDiscardDraftDialog: () => React.JSX.Element;
// DraftGifMessageSendModal
draftGifMessageSendModalProps: SmartDraftGifMessageSendModalProps | null;
renderDraftGifMessageSendModal: () => React.JSX.Element;
@@ -226,6 +230,9 @@ export function GlobalModalContainer({
// DeleteMessageModal
deleteMessagesProps,
renderDeleteMessagesModal,
+ // DiscardDraftDialog
+ discardDraftDialogProps,
+ renderDiscardDraftDialog,
// DraftGifMessageSendModal
draftGifMessageSendModalProps,
renderDraftGifMessageSendModal,
@@ -393,6 +400,10 @@ export function GlobalModalContainer({
return renderDeleteMessagesModal();
}
+ if (discardDraftDialogProps) {
+ return renderDiscardDraftDialog();
+ }
+
if (draftGifMessageSendModalProps) {
return renderDraftGifMessageSendModal();
}
diff --git a/ts/state/ducks/composer.preload.ts b/ts/state/ducks/composer.preload.ts
index acfc41eb42..34005e8bbe 100644
--- a/ts/state/ducks/composer.preload.ts
+++ b/ts/state/ducks/composer.preload.ts
@@ -266,6 +266,7 @@ export const actions = {
cancelJoinRequest,
endPoll,
incrementSendCounter,
+ onClearDraft,
onClearAttachments,
onCloseLinkPreview,
onEditorStateChange,
@@ -304,6 +305,23 @@ export const useComposerActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
+function onClearDraft(conversationId: string): StateThunk {
+ return dispatch => {
+ const conversation = window.ConversationController.get(conversationId);
+ if (!conversation) {
+ throw new Error('onClearDraft: No conversation found');
+ }
+
+ conversation.set({
+ draft: '',
+ draftBodyRanges: [],
+ quotedMessageId: null,
+ });
+
+ dispatch(onClearAttachments(conversation.id));
+ };
+}
+
function onClearAttachments(conversationId: string): NoopActionType {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
diff --git a/ts/state/ducks/conversations.preload.ts b/ts/state/ducks/conversations.preload.ts
index fef3b40b30..bcff03d583 100644
--- a/ts/state/ducks/conversations.preload.ts
+++ b/ts/state/ducks/conversations.preload.ts
@@ -15,6 +15,7 @@ import { createLogger } from '../../logging/log.std.js';
import { calling } from '../../services/calling.preload.js';
import { retryPlaceholders } from '../../services/retryPlaceholders.std.js';
import { getOwn } from '../../util/getOwn.std.js';
+import { hasDraft } from '../../util/hasDraft.std.js';
import { assertDev, strictAssert } from '../../util/assert.std.js';
import { drop } from '../../util/drop.std.js';
import {
@@ -35,10 +36,12 @@ import { instance as libphonenumberInstance } from '../../util/libphonenumberIns
import type {
ShowSendAnywayDialogActionType,
ShowErrorModalActionType,
+ ToggleDiscardDraftDialogActionType,
} from './globalModals.preload.js';
import {
SHOW_SEND_ANYWAY_DIALOG,
SHOW_ERROR_MODAL,
+ TOGGLE_DISCARD_DRAFT_DIALOG,
} from './globalModals.preload.js';
import {
MODIFY_LIST,
@@ -2057,7 +2060,9 @@ function setMessageToEdit(
void,
RootStateType,
unknown,
- SetFocusActionType | ShowErrorModalActionType
+ | SetFocusActionType
+ | ShowErrorModalActionType
+ | ToggleDiscardDraftDialogActionType
> {
return async (dispatch, getState) => {
const conversation = window.ConversationController.get(conversationId);
@@ -2066,6 +2071,14 @@ function setMessageToEdit(
return;
}
+ if (hasDraft(conversation.attributes)) {
+ dispatch({
+ type: TOGGLE_DISCARD_DRAFT_DIALOG,
+ payload: { conversationId, messageId },
+ });
+ return;
+ }
+
const message = (await getMessageById(messageId))?.attributes;
if (!message) {
return;
diff --git a/ts/state/ducks/globalModals.preload.ts b/ts/state/ducks/globalModals.preload.ts
index 895bec34f4..c27e7d4afe 100644
--- a/ts/state/ducks/globalModals.preload.ts
+++ b/ts/state/ducks/globalModals.preload.ts
@@ -78,6 +78,10 @@ export type EditHistoryMessagesType = ReadonlyDeep<
export type EditNicknameAndNoteModalPropsType = ReadonlyDeep<{
conversationId: string;
}>;
+export type DiscardDraftDialogPropsType = ReadonlyDeep<{
+ conversationId: string;
+ messageId: string;
+}>;
export type DeleteMessagesPropsType = ReadonlyDeep<{
conversationId: string;
messageIds: ReadonlyArray;
@@ -130,6 +134,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{
contactModalState?: ContactModalStateType;
criticalIdlePrimaryDeviceModal: boolean;
deleteMessagesProps?: DeleteMessagesPropsType;
+ discardDraftDialogProps: DiscardDraftDialogPropsType | null;
draftGifMessageSendModalProps: SmartDraftGifMessageSendModalProps | null;
debugLogErrorModalProps?: {
description?: string;
@@ -200,6 +205,8 @@ const SHOW_STORIES_SETTINGS = 'globalModals/SHOW_STORIES_SETTINGS';
const HIDE_STORIES_SETTINGS = 'globalModals/HIDE_STORIES_SETTINGS';
const TOGGLE_DELETE_MESSAGES_MODAL =
'globalModals/TOGGLE_DELETE_MESSAGES_MODAL';
+export const TOGGLE_DISCARD_DRAFT_DIALOG =
+ 'globalModals/TOGGLE_DISCARD_DRAFT_DIALOG';
const TOGGLE_DRAFT_GIF_MESSAGE_SEND_MODAL =
'globalModals/TOGGLE_DRAFT_GIF_MESSAGE_SEND_MODAL';
const TOGGLE_FORWARD_MESSAGES_MODAL =
@@ -334,6 +341,11 @@ type ToggleDeleteMessagesModalActionType = ReadonlyDeep<{
payload: DeleteMessagesPropsType | undefined;
}>;
+export type ToggleDiscardDraftDialogActionType = ReadonlyDeep<{
+ type: typeof TOGGLE_DISCARD_DRAFT_DIALOG;
+ payload: DiscardDraftDialogPropsType | null;
+}>;
+
type ToggleDraftGifMessageSendModalActionType = ReadonlyDeep<{
type: typeof TOGGLE_DRAFT_GIF_MESSAGE_SEND_MODAL;
payload: SmartDraftGifMessageSendModalProps | null;
@@ -594,6 +606,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
| ToggleConfirmationModalActionType
| ToggleConfirmLeaveCallModalActionType
| ToggleDeleteMessagesModalActionType
+ | ToggleDiscardDraftDialogActionType
| ToggleDraftGifMessageSendModalActionType
| ToggleEditNicknameAndNoteModalActionType
| ToggleForwardMessagesModalActionType
@@ -658,6 +671,7 @@ export const actions = {
toggleConfirmationModal,
toggleConfirmLeaveCallModal,
toggleDeleteMessagesModal,
+ toggleDiscardDraftDialog,
toggleDraftGifMessageSendModal,
toggleEditNicknameAndNoteModal,
toggleForwardMessagesModal,
@@ -872,6 +886,15 @@ function toggleDeleteMessagesModal(
};
}
+function toggleDiscardDraftDialog(
+ props: DiscardDraftDialogPropsType | null
+): ToggleDiscardDraftDialogActionType {
+ return {
+ type: TOGGLE_DISCARD_DRAFT_DIALOG,
+ payload: props,
+ };
+}
+
function toggleDraftGifMessageSendModal(
props: SmartDraftGifMessageSendModalProps | null
): ToggleDraftGifMessageSendModalActionType {
@@ -1509,6 +1532,7 @@ export function getEmptyState(): GlobalModalsStateType {
callQualitySurveyProps: null,
confirmLeaveCallModalState: null,
criticalIdlePrimaryDeviceModal: false,
+ discardDraftDialogProps: null,
draftGifMessageSendModalProps: null,
editNicknameAndNoteModalProps: null,
isProfileNameWarningModalVisible: false,
@@ -1728,6 +1752,13 @@ export function reducer(
};
}
+ if (action.type === TOGGLE_DISCARD_DRAFT_DIALOG) {
+ return {
+ ...state,
+ discardDraftDialogProps: action.payload,
+ };
+ }
+
if (action.type === TOGGLE_DRAFT_GIF_MESSAGE_SEND_MODAL) {
return {
...state,
diff --git a/ts/state/selectors/globalModals.std.ts b/ts/state/selectors/globalModals.std.ts
index 68f53d41b1..eb7360521b 100644
--- a/ts/state/selectors/globalModals.std.ts
+++ b/ts/state/selectors/globalModals.std.ts
@@ -80,6 +80,11 @@ export const getDeleteMessagesProps = createSelector(
({ deleteMessagesProps }) => deleteMessagesProps
);
+export const getDiscardDraftDialogProps = createSelector(
+ getGlobalModalsState,
+ ({ discardDraftDialogProps }) => discardDraftDialogProps
+);
+
export const getDraftGifMessageSendModalProps = createSelector(
getGlobalModalsState,
({ draftGifMessageSendModalProps }) => draftGifMessageSendModalProps
diff --git a/ts/state/smart/DiscardDraftDialog.preload.tsx b/ts/state/smart/DiscardDraftDialog.preload.tsx
new file mode 100644
index 0000000000..1860b0c458
--- /dev/null
+++ b/ts/state/smart/DiscardDraftDialog.preload.tsx
@@ -0,0 +1,49 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+import React, { memo, useCallback } from 'react';
+import { useSelector } from 'react-redux';
+import { getIntl } from '../selectors/user.std.js';
+import { useGlobalModalActions } from '../ducks/globalModals.preload.js';
+import { useConversationsActions } from '../ducks/conversations.preload.js';
+import { useComposerActions } from '../ducks/composer.preload.js';
+import { getDiscardDraftDialogProps } from '../selectors/globalModals.std.js';
+import { strictAssert } from '../../util/assert.std.js';
+import { DiscardDraftDialog } from '../../components/DiscardDraftDialog.dom.js';
+
+export const SmartDiscardDraftDialog = memo(function SmartDiscardDraftDialog() {
+ const discardDraftDialogProps = useSelector(getDiscardDraftDialogProps);
+ strictAssert(
+ discardDraftDialogProps != null,
+ 'Cannot render discard draft dialog without props'
+ );
+ const { conversationId, messageId } = discardDraftDialogProps;
+
+ const i18n = useSelector(getIntl);
+ const { toggleDiscardDraftDialog } = useGlobalModalActions();
+ const { setMessageToEdit } = useConversationsActions();
+ const { onClearDraft } = useComposerActions();
+
+ const handleClose = useCallback(() => {
+ toggleDiscardDraftDialog(null);
+ }, [toggleDiscardDraftDialog]);
+
+ const handleDiscard = useCallback(() => {
+ toggleDiscardDraftDialog(null);
+ onClearDraft(conversationId);
+ setMessageToEdit(conversationId, messageId);
+ }, [
+ toggleDiscardDraftDialog,
+ conversationId,
+ messageId,
+ setMessageToEdit,
+ onClearDraft,
+ ]);
+
+ return (
+
+ );
+});
diff --git a/ts/state/smart/GlobalModalContainer.preload.tsx b/ts/state/smart/GlobalModalContainer.preload.tsx
index 9c21ebb7b6..001548aaf9 100644
--- a/ts/state/smart/GlobalModalContainer.preload.tsx
+++ b/ts/state/smart/GlobalModalContainer.preload.tsx
@@ -21,6 +21,7 @@ import { getConversationsStoppingSend } from '../selectors/conversations.dom.js'
import { getIntl, getTheme } from '../selectors/user.std.js';
import { useGlobalModalActions } from '../ducks/globalModals.preload.js';
import { SmartDeleteMessagesModal } from './DeleteMessagesModal.preload.js';
+import { SmartDiscardDraftDialog } from './DiscardDraftDialog.preload.js';
import { SmartMessageRequestActionsConfirmation } from './MessageRequestActionsConfirmation.preload.js';
import { getGlobalModalsState } from '../selectors/globalModals.std.js';
import { SmartEditNicknameAndNoteModal } from './EditNicknameAndNoteModal.preload.js';
@@ -87,6 +88,10 @@ function renderDeleteMessagesModal(): React.JSX.Element {
return ;
}
+function renderDiscardDraftDialog(): React.JSX.Element {
+ return ;
+}
+
function renderDraftGifMessageSendModal(): React.JSX.Element {
return ;
}
@@ -166,6 +171,7 @@ export const SmartGlobalModalContainer = memo(
criticalIdlePrimaryDeviceModal,
debugLogErrorModalProps,
deleteMessagesProps,
+ discardDraftDialogProps,
draftGifMessageSendModalProps,
editHistoryMessages,
editNicknameAndNoteModalProps,
@@ -287,6 +293,7 @@ export const SmartGlobalModalContainer = memo(
editNicknameAndNoteModalProps={editNicknameAndNoteModalProps}
errorModalProps={errorModalProps}
deleteMessagesProps={deleteMessagesProps}
+ discardDraftDialogProps={discardDraftDialogProps}
draftGifMessageSendModalProps={draftGifMessageSendModalProps}
forwardMessagesProps={forwardMessagesProps}
groupMemberLabelInfoModalState={groupMemberLabelInfoModalState}
@@ -333,6 +340,7 @@ export const SmartGlobalModalContainer = memo(
renderEditNicknameAndNoteModal={renderEditNicknameAndNoteModal}
renderErrorModal={renderErrorModal}
renderDeleteMessagesModal={renderDeleteMessagesModal}
+ renderDiscardDraftDialog={renderDiscardDraftDialog}
renderDraftGifMessageSendModal={renderDraftGifMessageSendModal}
renderForwardMessagesModal={renderForwardMessagesModal}
renderGroupMemberLabelInfoModal={renderGroupMemberLabelInfoModal}
diff --git a/ts/util/hasDraft.std.ts b/ts/util/hasDraft.std.ts
index 0fee2f713c..48427b15b8 100644
--- a/ts/util/hasDraft.std.ts
+++ b/ts/util/hasDraft.std.ts
@@ -3,10 +3,15 @@
import type { ConversationAttributesType } from '../model-types.d.ts';
-export function hasDraft(attributes: ConversationAttributesType): boolean {
- const draftAttachments = attributes.draftAttachments || [];
-
- return (attributes.draft ||
- attributes.quotedMessageId ||
- draftAttachments.length > 0) as boolean;
+export function hasDraft(
+ attrs: Pick<
+ ConversationAttributesType,
+ 'draft' | 'draftAttachments' | 'quotedMessageId'
+ >
+): boolean {
+ return (
+ (attrs.draft != null && attrs.draft.length > 1) ||
+ (attrs.draftAttachments != null && attrs.draftAttachments.length > 1) ||
+ attrs.quotedMessageId != null
+ );
}