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 + ); }