Composer: Support editing drafts, restore regular drafts after editing (#1764)

This commit is contained in:
Alexander Zinchuk 2022-03-19 21:19:38 +01:00
parent 2b55037b08
commit debbbf339d
10 changed files with 147 additions and 30 deletions

View File

@ -39,6 +39,8 @@ import {
selectChatMessage, selectChatMessage,
selectUser, selectUser,
selectCanScheduleUntilOnline, selectCanScheduleUntilOnline,
selectEditingScheduledDraft,
selectEditingDraft,
} from '../../../global/selectors'; } from '../../../global/selectors';
import { import {
getAllowedAttachmentOptions, getAllowedAttachmentOptions,
@ -147,6 +149,7 @@ type StateProps =
sendAsUser?: ApiUser; sendAsUser?: ApiUser;
sendAsChat?: ApiChat; sendAsChat?: ApiChat;
sendAsId?: string; sendAsId?: string;
editingDraft?: ApiFormattedText;
} }
& Pick<GlobalState, 'connectionState'>; & Pick<GlobalState, 'connectionState'>;
@ -213,6 +216,7 @@ const Composer: FC<OwnProps & StateProps> = ({
sendAsUser, sendAsUser,
sendAsChat, sendAsChat,
sendAsId, sendAsId,
editingDraft,
}) => { }) => {
const { const {
sendMessage, sendMessage,
@ -457,10 +461,27 @@ const Composer: FC<OwnProps & StateProps> = ({
}; };
}, [chatId, resetComposer, stopRecordingVoiceRef]); }, [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); useDraft(draft, chatId, threadId, htmlRef, setHtml, editingMessage);
useClipboardPaste(insertTextAndUpdateCursor, setAttachments, editingMessage); useClipboardPaste(insertTextAndUpdateCursor, setAttachments, editingMessage);
const handleEmbeddedClear = useCallback(() => {
if (editingMessage) {
handleEditCancel();
}
}, [editingMessage, handleEditCancel]);
const handleFileSelect = useCallback(async (files: File[], isQuick: boolean) => { const handleFileSelect = useCallback(async (files: File[], isQuick: boolean) => {
setAttachments(await Promise.all(files.map((file) => buildAttachment(file.name, file, isQuick)))); setAttachments(await Promise.all(files.map((file) => buildAttachment(file.name, file, isQuick))));
}, []); }, []);
@ -965,7 +986,7 @@ const Composer: FC<OwnProps & StateProps> = ({
/> />
<div id="message-compose"> <div id="message-compose">
<div className="svg-appendix" ref={appendixRef} /> <div className="svg-appendix" ref={appendixRef} />
<ComposerEmbeddedMessage /> <ComposerEmbeddedMessage onClear={handleEmbeddedClear} />
<WebPagePreview <WebPagePreview
chatId={chatId} chatId={chatId}
threadId={threadId} threadId={threadId}
@ -1187,6 +1208,10 @@ export default memo(withGlobal<OwnProps>(
const sendAsUser = sendAsId ? selectUser(global, sendAsId) : undefined; const sendAsUser = sendAsId ? selectUser(global, sendAsId) : undefined;
const sendAsChat = !sendAsUser && sendAsId ? selectChat(global, sendAsId) : undefined; const sendAsChat = !sendAsUser && sendAsId ? selectChat(global, sendAsId) : undefined;
const editingDraft = messageListType === 'scheduled'
? selectEditingScheduledDraft(global, chatId)
: selectEditingDraft(global, chatId, threadId);
return { return {
editingMessage: selectEditingMessage(global, chatId, threadId, messageListType), editingMessage: selectEditingMessage(global, chatId, threadId, messageListType),
connectionState: global.connectionState, connectionState: global.connectionState,
@ -1225,6 +1250,7 @@ export default memo(withGlobal<OwnProps>(
sendAsUser, sendAsUser,
sendAsChat, sendAsChat,
sendAsId, sendAsId,
editingDraft,
}; };
}, },
)(Composer)); )(Composer));

View File

@ -38,15 +38,20 @@ type StateProps = {
forwardedMessagesCount?: number; forwardedMessagesCount?: number;
}; };
type OwnProps = {
onClear?: () => void;
};
const FORWARD_RENDERING_DELAY = 300; const FORWARD_RENDERING_DELAY = 300;
const ComposerEmbeddedMessage: FC<StateProps> = ({ const ComposerEmbeddedMessage: FC<OwnProps & StateProps> = ({
replyingToId, replyingToId,
editingId, editingId,
message, message,
sender, sender,
shouldAnimate, shouldAnimate,
forwardedMessagesCount, forwardedMessagesCount,
onClear,
}) => { }) => {
const { const {
setReplyingToId, setReplyingToId,
@ -76,7 +81,8 @@ const ComposerEmbeddedMessage: FC<StateProps> = ({
} else if (forwardedMessagesCount) { } else if (forwardedMessagesCount) {
exitForwardMode(); exitForwardMode();
} }
}, [replyingToId, editingId, forwardedMessagesCount, setReplyingToId, setEditingId, exitForwardMode]); onClear?.();
}, [replyingToId, editingId, forwardedMessagesCount, onClear, setReplyingToId, setEditingId, exitForwardMode]);
useEffect(() => (isShown ? captureEscKeyListener(clearEmbedded) : undefined), [isShown, clearEmbedded]); useEffect(() => (isShown ? captureEscKeyListener(clearEmbedded) : undefined), [isShown, clearEmbedded]);
@ -113,7 +119,7 @@ const ComposerEmbeddedMessage: FC<StateProps> = ({
); );
}; };
export default memo(withGlobal( export default memo(withGlobal<OwnProps>(
(global): StateProps => { (global): StateProps => {
const { chatId, threadId, type: messageListType } = selectCurrentMessageList(global) || {}; const { chatId, threadId, type: messageListType } = selectCurrentMessageList(global) || {};
if (!chatId || !threadId || !messageListType) { if (!chatId || !threadId || !messageListType) {

View File

@ -29,9 +29,10 @@ const useDraft = (
const updateDraft = useCallback((draftChatId: string, draftThreadId: number) => { const updateDraft = useCallback((draftChatId: string, draftThreadId: number) => {
const currentHtml = htmlRef.current; const currentHtml = htmlRef.current;
if (currentHtml.length && !editedMessage) { if (editedMessage) return;
if (currentHtml.length) {
saveDraft({ chatId: draftChatId, threadId: draftThreadId, draft: parseMessageInput(currentHtml!) }); saveDraft({ chatId: draftChatId, threadId: draftThreadId, draft: parseMessageInput(currentHtml!) });
} else { } else if (currentHtml !== undefined) {
clearDraft({ chatId: draftChatId, threadId: draftThreadId }); clearDraft({ chatId: draftChatId, threadId: draftThreadId });
} }
}, [clearDraft, editedMessage, htmlRef, saveDraft]); }, [clearDraft, editedMessage, htmlRef, saveDraft]);
@ -61,7 +62,7 @@ const useDraft = (
return; return;
} }
if (!draft) { if (editedMessage || !draft) {
return; return;
} }
@ -73,7 +74,7 @@ const useDraft = (
focusEditableElement(messageInput, true); focusEditableElement(messageInput, true);
}); });
} }
}, [chatId, threadId, draft, setHtml, updateDraft, prevChatId, prevThreadId]); }, [chatId, threadId, draft, setHtml, updateDraft, prevChatId, prevThreadId, editedMessage]);
const html = htmlRef.current; const html = htmlRef.current;
// Update draft when input changes // Update draft when input changes

View File

@ -1,40 +1,79 @@
import { useCallback } from '../../../../lib/teact/teact'; import { useCallback, useEffect } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global'; 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 { EDITABLE_INPUT_ID } from '../../../../config';
import parseMessageInput from '../../../../util/parseMessageInput'; import parseMessageInput from '../../../../util/parseMessageInput';
import focusEditableElement from '../../../../util/focusEditableElement'; import focusEditableElement from '../../../../util/focusEditableElement';
import { hasMessageMedia } from '../../../../global/helpers'; import { hasMessageMedia } from '../../../../global/helpers';
import { getTextWithEntitiesAsHtml } from '../../../common/helpers/renderTextWithEntities'; 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 = ( const useEditing = (
htmlRef: { current: string }, htmlRef: { current: string },
setHtml: (html: string) => void, setHtml: (html: string) => void,
editedMessage: ApiMessage | undefined, editedMessage: ApiMessage | undefined,
resetComposer: () => void, resetComposer: (shouldPreserveInput?: boolean) => void,
openDeleteModal: () => 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) { if (!editedMessage) {
setHtml('');
return; return;
} }
if (prevEditedMessage?.id === editedMessage.id) { if (prevEditedMessage?.id === editedMessage.id) {
return; 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(() => { requestAnimationFrame(() => {
const messageInput = document.getElementById(EDITABLE_INPUT_ID)!; const messageInput = document.getElementById(EDITABLE_INPUT_ID)!;
focusEditableElement(messageInput, true); focusEditableElement(messageInput, true);
}); });
}, [editedMessage, setHtml] as const); }, [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 handleEditComplete = useCallback(() => {
const { text, entities } = parseMessageInput(htmlRef.current!); const { text, entities } = parseMessageInput(htmlRef.current!);
@ -54,9 +93,22 @@ const useEditing = (
}); });
resetComposer(); 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; export default useEditing;

View File

@ -3,8 +3,9 @@ import {
} from '../../index'; } from '../../index';
import { import {
ApiChat, ApiFormattedText, ApiMessage, MAIN_THREAD_ID, ApiChat, ApiMessage, MAIN_THREAD_ID,
} from '../../../api/types'; } from '../../../api/types';
import { Thread } from '../../types';
import { import {
DEBUG, MESSAGE_LIST_SLICE, SERVICE_NOTIFICATIONS_USER_ID, DEBUG, MESSAGE_LIST_SLICE, SERVICE_NOTIFICATIONS_USER_ID,
@ -15,16 +16,18 @@ import {
updateUsers, updateUsers,
updateChats, updateChats,
updateThreadInfos, updateThreadInfos,
replaceThreadParam,
updateListedIds, updateListedIds,
safeReplaceViewportIds, safeReplaceViewportIds,
addChatMessagesById, addChatMessagesById,
updateThread,
} from '../../reducers'; } from '../../reducers';
import { import {
selectCurrentMessageList, selectCurrentMessageList,
selectDraft, selectDraft,
selectChatMessage, selectChatMessage,
selectThreadInfo, selectThreadInfo,
selectEditingId,
selectEditingDraft,
} from '../../selectors'; } from '../../selectors';
import { init as initFolderManager } from '../../../util/folderManager'; import { init as initFolderManager } from '../../../util/folderManager';
@ -84,11 +87,11 @@ async function loadAndReplaceMessages() {
// Memoize drafts // Memoize drafts
const draftChatIds = Object.keys(global.messages.byChatId); const draftChatIds = Object.keys(global.messages.byChatId);
const draftsByChatId = draftChatIds.reduce<Record<string, ApiFormattedText>>((acc, chatId) => { const draftsByChatId = draftChatIds.reduce<Record<string, Partial<Thread>>>((acc, chatId) => {
const draft = selectDraft(global, chatId, MAIN_THREAD_ID); acc[chatId] = {};
if (draft) { acc[chatId].draft = selectDraft(global, chatId, MAIN_THREAD_ID);
acc[chatId] = draft; acc[chatId].editingId = selectEditingId(global, chatId, MAIN_THREAD_ID);
} acc[chatId].editingDraft = selectEditingDraft(global, chatId, MAIN_THREAD_ID);
return acc; return acc;
}, {}); }, {});
@ -183,7 +186,7 @@ async function loadAndReplaceMessages() {
// Restore drafts // Restore drafts
Object.keys(draftsByChatId).forEach((chatId) => { 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); setGlobal(global);

View File

@ -81,6 +81,16 @@ addActionHandler('setEditingId', (global, actions, payload) => {
return replaceThreadParam(global, chatId, threadId, paramName, messageId); 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) => { addActionHandler('editLastMessage', (global) => {
const { chatId, threadId } = selectCurrentMessageList(global) || {}; const { chatId, threadId } = selectCurrentMessageList(global) || {};
if (!chatId || !threadId) { if (!chatId || !threadId) {

View File

@ -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<Thread>, global: GlobalState, chatId: string, threadId: number, threadUpdate: Partial<Thread>,
): GlobalState { ): GlobalState {
const current = global.messages.byChatId[chatId]; const current = global.messages.byChatId[chatId];

View File

@ -133,10 +133,18 @@ export function selectEditingId(global: GlobalState, chatId: string, threadId: n
return selectThreadParam(global, chatId, threadId, 'editingId'); 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) { export function selectEditingScheduledId(global: GlobalState, chatId: string) {
return selectThreadParam(global, chatId, MAIN_THREAD_ID, 'editingScheduledId'); 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) { export function selectDraft(global: GlobalState, chatId: string, threadId: number) {
return selectThreadParam(global, chatId, threadId, 'draft'); return selectThreadParam(global, chatId, threadId, 'draft');
} }

View File

@ -92,6 +92,8 @@ export interface Thread {
replyingToId?: number; replyingToId?: number;
editingId?: number; editingId?: number;
editingScheduledId?: number; editingScheduledId?: number;
editingDraft?: ApiFormattedText;
editingScheduledDraft?: ApiFormattedText;
draft?: ApiFormattedText; draft?: ApiFormattedText;
noWebPage?: boolean; noWebPage?: boolean;
threadInfo?: ApiThreadInfo; threadInfo?: ApiThreadInfo;
@ -515,6 +517,12 @@ export interface ActionPayloads {
type?: MessageListType; type?: MessageListType;
shouldReplaceHistory?: boolean; shouldReplaceHistory?: boolean;
}; };
setEditingDraft: {
text?: ApiFormattedText;
chatId: string;
threadId: number;
type: MessageListType;
};
} }
export type NonTypedActionNames = ( export type NonTypedActionNames = (
@ -544,13 +552,14 @@ export type NonTypedActionNames = (
'markMessageListRead' | 'markMessagesRead' | 'loadMessage' | 'focusMessage' | 'focusLastMessage' | 'sendPollVote' | 'markMessageListRead' | 'markMessagesRead' | 'loadMessage' | 'focusMessage' | 'focusLastMessage' | 'sendPollVote' |
'editMessage' | 'deleteHistory' | 'enterMessageSelectMode' | 'toggleMessageSelection' | 'exitMessageSelectMode' | 'editMessage' | 'deleteHistory' | 'enterMessageSelectMode' | 'toggleMessageSelection' | 'exitMessageSelectMode' |
'openTelegramLink' | 'openChatByUsername' | 'requestThreadInfoUpdate' | 'setScrollOffset' | 'unpinAllMessages' | 'openTelegramLink' | 'openChatByUsername' | 'requestThreadInfoUpdate' | 'setScrollOffset' | 'unpinAllMessages' |
'setReplyingToId' | 'setEditingId' | 'editLastMessage' | 'saveDraft' | 'clearDraft' | 'loadPinnedMessages' | 'setReplyingToId' | 'editLastMessage' | 'saveDraft' | 'clearDraft' | 'loadPinnedMessages' |
'toggleMessageWebPage' | 'replyToNextMessage' | 'deleteChatUser' | 'deleteChat' | 'sendReaction' | 'toggleMessageWebPage' | 'replyToNextMessage' | 'deleteChatUser' | 'deleteChat' | 'sendReaction' |
'reportMessages' | 'sendMessageAction' | 'focusNextReply' | 'openChatByInvite' | 'loadSeenBy' | 'reportMessages' | 'sendMessageAction' | 'focusNextReply' | 'openChatByInvite' | 'loadSeenBy' |
'loadSponsoredMessages' | 'viewSponsoredMessage' | 'loadSendAs' | 'saveDefaultSendAs' | 'loadAvailableReactions' | 'loadSponsoredMessages' | 'viewSponsoredMessage' | 'loadSendAs' | 'saveDefaultSendAs' | 'loadAvailableReactions' |
'stopActiveEmojiInteraction' | 'interactWithAnimatedEmoji' | 'loadReactors' | 'setDefaultReaction' | 'stopActiveEmojiInteraction' | 'interactWithAnimatedEmoji' | 'loadReactors' | 'setDefaultReaction' |
'sendDefaultReaction' | 'sendEmojiInteraction' | 'sendWatchingEmojiInteraction' | 'loadMessageReactions' | 'sendDefaultReaction' | 'sendEmojiInteraction' | 'sendWatchingEmojiInteraction' | 'loadMessageReactions' |
'stopActiveReaction' | 'startActiveReaction' | 'copySelectedMessages' | 'copyMessagesByIds' | 'stopActiveReaction' | 'startActiveReaction' | 'copySelectedMessages' | 'copyMessagesByIds' |
'setEditingId' |
// downloads // downloads
'downloadSelectedMessages' | 'downloadMessageMedia' | 'cancelMessageMediaDownload' | 'downloadSelectedMessages' | 'downloadMessageMedia' | 'cancelMessageMediaDownload' |
// scheduled messages // scheduled messages

View File

@ -1,7 +1,9 @@
import { useEffect } from '../lib/teact/teact'; import { useEffect } from '../lib/teact/teact';
import usePrevious from './usePrevious'; import usePrevious from './usePrevious';
const useEffectWithPrevDeps = <T extends any[]>(cb: (args: T | []) => void, dependencies: T, debugKey?: string) => { const useEffectWithPrevDeps = <T extends readonly any[]>(
cb: (args: T | readonly []) => void, dependencies: T, debugKey?: string,
) => {
const prevDeps = usePrevious<T>(dependencies); const prevDeps = usePrevious<T>(dependencies);
return useEffect(() => { return useEffect(() => {
return cb(prevDeps || []); return cb(prevDeps || []);