diff --git a/src/components/middle/composer/Composer.tsx b/src/components/middle/composer/Composer.tsx index 111a63cb1..bb5c9ca6a 100644 --- a/src/components/middle/composer/Composer.tsx +++ b/src/components/middle/composer/Composer.tsx @@ -39,6 +39,8 @@ import { selectChatMessage, selectUser, selectCanScheduleUntilOnline, + selectEditingScheduledDraft, + selectEditingDraft, } from '../../../global/selectors'; import { getAllowedAttachmentOptions, @@ -147,6 +149,7 @@ type StateProps = sendAsUser?: ApiUser; sendAsChat?: ApiChat; sendAsId?: string; + editingDraft?: ApiFormattedText; } & Pick; @@ -213,6 +216,7 @@ const Composer: FC = ({ sendAsUser, sendAsChat, sendAsId, + editingDraft, }) => { const { sendMessage, @@ -457,10 +461,27 @@ const Composer: FC = ({ }; }, [chatId, resetComposer, stopRecordingVoiceRef]); - const handleEditComplete = useEditing(htmlRef, setHtml, editingMessage, resetComposer, openDeleteModal); + const [handleEditComplete, handleEditCancel] = useEditing( + htmlRef, + setHtml, + editingMessage, + resetComposer, + openDeleteModal, + chatId, + threadId, + messageListType, + draft, + editingDraft, + ); useDraft(draft, chatId, threadId, htmlRef, setHtml, editingMessage); useClipboardPaste(insertTextAndUpdateCursor, setAttachments, editingMessage); + const handleEmbeddedClear = useCallback(() => { + if (editingMessage) { + handleEditCancel(); + } + }, [editingMessage, handleEditCancel]); + const handleFileSelect = useCallback(async (files: File[], isQuick: boolean) => { setAttachments(await Promise.all(files.map((file) => buildAttachment(file.name, file, isQuick)))); }, []); @@ -965,7 +986,7 @@ const Composer: FC = ({ />
- + ( const sendAsUser = sendAsId ? selectUser(global, sendAsId) : undefined; const sendAsChat = !sendAsUser && sendAsId ? selectChat(global, sendAsId) : undefined; + const editingDraft = messageListType === 'scheduled' + ? selectEditingScheduledDraft(global, chatId) + : selectEditingDraft(global, chatId, threadId); + return { editingMessage: selectEditingMessage(global, chatId, threadId, messageListType), connectionState: global.connectionState, @@ -1225,6 +1250,7 @@ export default memo(withGlobal( sendAsUser, sendAsChat, sendAsId, + editingDraft, }; }, )(Composer)); diff --git a/src/components/middle/composer/ComposerEmbeddedMessage.tsx b/src/components/middle/composer/ComposerEmbeddedMessage.tsx index c9afb9c39..1b0477b36 100644 --- a/src/components/middle/composer/ComposerEmbeddedMessage.tsx +++ b/src/components/middle/composer/ComposerEmbeddedMessage.tsx @@ -38,15 +38,20 @@ type StateProps = { forwardedMessagesCount?: number; }; +type OwnProps = { + onClear?: () => void; +}; + const FORWARD_RENDERING_DELAY = 300; -const ComposerEmbeddedMessage: FC = ({ +const ComposerEmbeddedMessage: FC = ({ replyingToId, editingId, message, sender, shouldAnimate, forwardedMessagesCount, + onClear, }) => { const { setReplyingToId, @@ -76,7 +81,8 @@ const ComposerEmbeddedMessage: FC = ({ } else if (forwardedMessagesCount) { exitForwardMode(); } - }, [replyingToId, editingId, forwardedMessagesCount, setReplyingToId, setEditingId, exitForwardMode]); + onClear?.(); + }, [replyingToId, editingId, forwardedMessagesCount, onClear, setReplyingToId, setEditingId, exitForwardMode]); useEffect(() => (isShown ? captureEscKeyListener(clearEmbedded) : undefined), [isShown, clearEmbedded]); @@ -113,7 +119,7 @@ const ComposerEmbeddedMessage: FC = ({ ); }; -export default memo(withGlobal( +export default memo(withGlobal( (global): StateProps => { const { chatId, threadId, type: messageListType } = selectCurrentMessageList(global) || {}; if (!chatId || !threadId || !messageListType) { diff --git a/src/components/middle/composer/hooks/useDraft.ts b/src/components/middle/composer/hooks/useDraft.ts index c3f4b1535..00400c4be 100644 --- a/src/components/middle/composer/hooks/useDraft.ts +++ b/src/components/middle/composer/hooks/useDraft.ts @@ -29,9 +29,10 @@ const useDraft = ( const updateDraft = useCallback((draftChatId: string, draftThreadId: number) => { const currentHtml = htmlRef.current; - if (currentHtml.length && !editedMessage) { + if (editedMessage) return; + if (currentHtml.length) { saveDraft({ chatId: draftChatId, threadId: draftThreadId, draft: parseMessageInput(currentHtml!) }); - } else { + } else if (currentHtml !== undefined) { clearDraft({ chatId: draftChatId, threadId: draftThreadId }); } }, [clearDraft, editedMessage, htmlRef, saveDraft]); @@ -61,7 +62,7 @@ const useDraft = ( return; } - if (!draft) { + if (editedMessage || !draft) { return; } @@ -73,7 +74,7 @@ const useDraft = ( focusEditableElement(messageInput, true); }); } - }, [chatId, threadId, draft, setHtml, updateDraft, prevChatId, prevThreadId]); + }, [chatId, threadId, draft, setHtml, updateDraft, prevChatId, prevThreadId, editedMessage]); const html = htmlRef.current; // Update draft when input changes diff --git a/src/components/middle/composer/hooks/useEditing.ts b/src/components/middle/composer/hooks/useEditing.ts index fbd8b05cd..bc1421086 100644 --- a/src/components/middle/composer/hooks/useEditing.ts +++ b/src/components/middle/composer/hooks/useEditing.ts @@ -1,40 +1,79 @@ -import { useCallback } from '../../../../lib/teact/teact'; +import { useCallback, useEffect } from '../../../../lib/teact/teact'; import { getActions } from '../../../../global'; -import { ApiMessage } from '../../../../api/types'; +import { ApiFormattedText, ApiMessage } from '../../../../api/types'; +import { MessageListType } from '../../../../global/types'; +import useEffectWithPrevDeps from '../../../../hooks/useEffectWithPrevDeps'; import { EDITABLE_INPUT_ID } from '../../../../config'; import parseMessageInput from '../../../../util/parseMessageInput'; import focusEditableElement from '../../../../util/focusEditableElement'; import { hasMessageMedia } from '../../../../global/helpers'; import { getTextWithEntitiesAsHtml } from '../../../common/helpers/renderTextWithEntities'; -import useOnChange from '../../../../hooks/useOnChange'; +import { fastRaf } from '../../../../util/schedulers'; +import useBackgroundMode from '../../../../hooks/useBackgroundMode'; +import useBeforeUnload from '../../../../hooks/useBeforeUnload'; const useEditing = ( htmlRef: { current: string }, setHtml: (html: string) => void, editedMessage: ApiMessage | undefined, - resetComposer: () => void, + resetComposer: (shouldPreserveInput?: boolean) => void, openDeleteModal: () => void, + chatId: string, + threadId: number, + type: MessageListType, + draft?: ApiFormattedText, + editingDraft?: ApiFormattedText, ) => { - const { editMessage } = getActions(); + const { editMessage, setEditingDraft } = getActions(); - useOnChange(([prevEditedMessage]) => { + useEffectWithPrevDeps(([prevEditedMessage]) => { if (!editedMessage) { - setHtml(''); return; } if (prevEditedMessage?.id === editedMessage.id) { return; } - setHtml(getTextWithEntitiesAsHtml(editedMessage.content.text)); + const html = getTextWithEntitiesAsHtml(editingDraft?.text.length ? editingDraft : editedMessage.content.text); + setHtml(html); + // `fastRaf` would execute syncronously in this case requestAnimationFrame(() => { const messageInput = document.getElementById(EDITABLE_INPUT_ID)!; focusEditableElement(messageInput, true); }); }, [editedMessage, setHtml] as const); + useEffect(() => { + if (!editedMessage) return undefined; + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + const edited = parseMessageInput(htmlRef.current!); + const update = edited.text.length ? edited : undefined; + setEditingDraft({ + chatId, threadId, type, text: update, + }); + }; + }, [chatId, editedMessage, htmlRef, setEditingDraft, threadId, type]); + + const restoreNewDraftAfterEditing = useCallback(() => { + if (!draft) return; + // Run 1 frame after editing draft reset + fastRaf(() => { + setHtml(getTextWithEntitiesAsHtml(draft)); + const messageInput = document.getElementById(EDITABLE_INPUT_ID)!; + requestAnimationFrame(() => { + focusEditableElement(messageInput, true); + }); + }); + }, [draft, setHtml]); + + const handleEditCancel = useCallback(() => { + resetComposer(); + restoreNewDraftAfterEditing(); + }, [resetComposer, restoreNewDraftAfterEditing]); + const handleEditComplete = useCallback(() => { const { text, entities } = parseMessageInput(htmlRef.current!); @@ -54,9 +93,22 @@ const useEditing = ( }); resetComposer(); - }, [editMessage, editedMessage, htmlRef, openDeleteModal, resetComposer]); + restoreNewDraftAfterEditing(); + }, [editMessage, editedMessage, htmlRef, openDeleteModal, resetComposer, restoreNewDraftAfterEditing]); - return handleEditComplete; + const handleBlur = useCallback(() => { + if (!editedMessage) return; + const edited = parseMessageInput(htmlRef.current!); + const update = edited.text.length ? edited : undefined; + setEditingDraft({ + chatId, threadId, type, text: update, + }); + }, [chatId, editedMessage, htmlRef, setEditingDraft, threadId, type]); + + useBackgroundMode(handleBlur); + useBeforeUnload(handleBlur); + + return [handleEditComplete, handleEditCancel]; }; export default useEditing; diff --git a/src/global/actions/api/sync.ts b/src/global/actions/api/sync.ts index 9d4dda3ba..395b710a4 100644 --- a/src/global/actions/api/sync.ts +++ b/src/global/actions/api/sync.ts @@ -3,8 +3,9 @@ import { } from '../../index'; import { - ApiChat, ApiFormattedText, ApiMessage, MAIN_THREAD_ID, + ApiChat, ApiMessage, MAIN_THREAD_ID, } from '../../../api/types'; +import { Thread } from '../../types'; import { DEBUG, MESSAGE_LIST_SLICE, SERVICE_NOTIFICATIONS_USER_ID, @@ -15,16 +16,18 @@ import { updateUsers, updateChats, updateThreadInfos, - replaceThreadParam, updateListedIds, safeReplaceViewportIds, addChatMessagesById, + updateThread, } from '../../reducers'; import { selectCurrentMessageList, selectDraft, selectChatMessage, selectThreadInfo, + selectEditingId, + selectEditingDraft, } from '../../selectors'; import { init as initFolderManager } from '../../../util/folderManager'; @@ -84,11 +87,11 @@ async function loadAndReplaceMessages() { // Memoize drafts const draftChatIds = Object.keys(global.messages.byChatId); - const draftsByChatId = draftChatIds.reduce>((acc, chatId) => { - const draft = selectDraft(global, chatId, MAIN_THREAD_ID); - if (draft) { - acc[chatId] = draft; - } + const draftsByChatId = draftChatIds.reduce>>((acc, chatId) => { + acc[chatId] = {}; + acc[chatId].draft = selectDraft(global, chatId, MAIN_THREAD_ID); + acc[chatId].editingId = selectEditingId(global, chatId, MAIN_THREAD_ID); + acc[chatId].editingDraft = selectEditingDraft(global, chatId, MAIN_THREAD_ID); return acc; }, {}); @@ -183,7 +186,7 @@ async function loadAndReplaceMessages() { // Restore drafts Object.keys(draftsByChatId).forEach((chatId) => { - global = replaceThreadParam(global, chatId, MAIN_THREAD_ID, 'draft', draftsByChatId[chatId]); + global = updateThread(global, chatId, MAIN_THREAD_ID, draftsByChatId[chatId]); }); setGlobal(global); diff --git a/src/global/actions/ui/messages.ts b/src/global/actions/ui/messages.ts index 1bb55fae1..2e2224628 100644 --- a/src/global/actions/ui/messages.ts +++ b/src/global/actions/ui/messages.ts @@ -81,6 +81,16 @@ addActionHandler('setEditingId', (global, actions, payload) => { return replaceThreadParam(global, chatId, threadId, paramName, messageId); }); +addActionHandler('setEditingDraft', (global, actions, payload) => { + const { + text, chatId, threadId, type, + } = payload; + + const paramName = type === 'scheduled' ? 'editingScheduledDraft' : 'editingDraft'; + + return replaceThreadParam(global, chatId, threadId, paramName, text); +}); + addActionHandler('editLastMessage', (global) => { const { chatId, threadId } = selectCurrentMessageList(global) || {}; if (!chatId || !threadId) { diff --git a/src/global/reducers/messages.ts b/src/global/reducers/messages.ts index 851d5ded1..692f3c9e3 100644 --- a/src/global/reducers/messages.ts +++ b/src/global/reducers/messages.ts @@ -71,7 +71,7 @@ function replaceChatMessages(global: GlobalState, chatId: string, newById: Recor }); } -function updateThread( +export function updateThread( global: GlobalState, chatId: string, threadId: number, threadUpdate: Partial, ): GlobalState { const current = global.messages.byChatId[chatId]; diff --git a/src/global/selectors/messages.ts b/src/global/selectors/messages.ts index f411f98d5..923e042d3 100644 --- a/src/global/selectors/messages.ts +++ b/src/global/selectors/messages.ts @@ -133,10 +133,18 @@ export function selectEditingId(global: GlobalState, chatId: string, threadId: n return selectThreadParam(global, chatId, threadId, 'editingId'); } +export function selectEditingDraft(global: GlobalState, chatId: string, threadId: number) { + return selectThreadParam(global, chatId, threadId, 'editingDraft'); +} + export function selectEditingScheduledId(global: GlobalState, chatId: string) { return selectThreadParam(global, chatId, MAIN_THREAD_ID, 'editingScheduledId'); } +export function selectEditingScheduledDraft(global: GlobalState, chatId: string) { + return selectThreadParam(global, chatId, MAIN_THREAD_ID, 'editingScheduledDraft'); +} + export function selectDraft(global: GlobalState, chatId: string, threadId: number) { return selectThreadParam(global, chatId, threadId, 'draft'); } diff --git a/src/global/types.ts b/src/global/types.ts index a0466956a..4ed6abbf2 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -92,6 +92,8 @@ export interface Thread { replyingToId?: number; editingId?: number; editingScheduledId?: number; + editingDraft?: ApiFormattedText; + editingScheduledDraft?: ApiFormattedText; draft?: ApiFormattedText; noWebPage?: boolean; threadInfo?: ApiThreadInfo; @@ -515,6 +517,12 @@ export interface ActionPayloads { type?: MessageListType; shouldReplaceHistory?: boolean; }; + setEditingDraft: { + text?: ApiFormattedText; + chatId: string; + threadId: number; + type: MessageListType; + }; } export type NonTypedActionNames = ( @@ -544,13 +552,14 @@ export type NonTypedActionNames = ( 'markMessageListRead' | 'markMessagesRead' | 'loadMessage' | 'focusMessage' | 'focusLastMessage' | 'sendPollVote' | 'editMessage' | 'deleteHistory' | 'enterMessageSelectMode' | 'toggleMessageSelection' | 'exitMessageSelectMode' | 'openTelegramLink' | 'openChatByUsername' | 'requestThreadInfoUpdate' | 'setScrollOffset' | 'unpinAllMessages' | - 'setReplyingToId' | 'setEditingId' | 'editLastMessage' | 'saveDraft' | 'clearDraft' | 'loadPinnedMessages' | + 'setReplyingToId' | 'editLastMessage' | 'saveDraft' | 'clearDraft' | 'loadPinnedMessages' | 'toggleMessageWebPage' | 'replyToNextMessage' | 'deleteChatUser' | 'deleteChat' | 'sendReaction' | 'reportMessages' | 'sendMessageAction' | 'focusNextReply' | 'openChatByInvite' | 'loadSeenBy' | 'loadSponsoredMessages' | 'viewSponsoredMessage' | 'loadSendAs' | 'saveDefaultSendAs' | 'loadAvailableReactions' | 'stopActiveEmojiInteraction' | 'interactWithAnimatedEmoji' | 'loadReactors' | 'setDefaultReaction' | 'sendDefaultReaction' | 'sendEmojiInteraction' | 'sendWatchingEmojiInteraction' | 'loadMessageReactions' | 'stopActiveReaction' | 'startActiveReaction' | 'copySelectedMessages' | 'copyMessagesByIds' | + 'setEditingId' | // downloads 'downloadSelectedMessages' | 'downloadMessageMedia' | 'cancelMessageMediaDownload' | // scheduled messages diff --git a/src/hooks/useEffectWithPrevDeps.ts b/src/hooks/useEffectWithPrevDeps.ts index e50736c73..43ca51cc8 100644 --- a/src/hooks/useEffectWithPrevDeps.ts +++ b/src/hooks/useEffectWithPrevDeps.ts @@ -1,7 +1,9 @@ import { useEffect } from '../lib/teact/teact'; import usePrevious from './usePrevious'; -const useEffectWithPrevDeps = (cb: (args: T | []) => void, dependencies: T, debugKey?: string) => { +const useEffectWithPrevDeps = ( + cb: (args: T | readonly []) => void, dependencies: T, debugKey?: string, +) => { const prevDeps = usePrevious(dependencies); return useEffect(() => { return cb(prevDeps || []);