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,
selectUser,
selectCanScheduleUntilOnline,
selectEditingScheduledDraft,
selectEditingDraft,
} from '../../../global/selectors';
import {
getAllowedAttachmentOptions,
@ -147,6 +149,7 @@ type StateProps =
sendAsUser?: ApiUser;
sendAsChat?: ApiChat;
sendAsId?: string;
editingDraft?: ApiFormattedText;
}
& Pick<GlobalState, 'connectionState'>;
@ -213,6 +216,7 @@ const Composer: FC<OwnProps & StateProps> = ({
sendAsUser,
sendAsChat,
sendAsId,
editingDraft,
}) => {
const {
sendMessage,
@ -457,10 +461,27 @@ const Composer: FC<OwnProps & StateProps> = ({
};
}, [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<OwnProps & StateProps> = ({
/>
<div id="message-compose">
<div className="svg-appendix" ref={appendixRef} />
<ComposerEmbeddedMessage />
<ComposerEmbeddedMessage onClear={handleEmbeddedClear} />
<WebPagePreview
chatId={chatId}
threadId={threadId}
@ -1187,6 +1208,10 @@ export default memo(withGlobal<OwnProps>(
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<OwnProps>(
sendAsUser,
sendAsChat,
sendAsId,
editingDraft,
};
},
)(Composer));

View File

@ -38,15 +38,20 @@ type StateProps = {
forwardedMessagesCount?: number;
};
type OwnProps = {
onClear?: () => void;
};
const FORWARD_RENDERING_DELAY = 300;
const ComposerEmbeddedMessage: FC<StateProps> = ({
const ComposerEmbeddedMessage: FC<OwnProps & StateProps> = ({
replyingToId,
editingId,
message,
sender,
shouldAnimate,
forwardedMessagesCount,
onClear,
}) => {
const {
setReplyingToId,
@ -76,7 +81,8 @@ const ComposerEmbeddedMessage: FC<StateProps> = ({
} 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<StateProps> = ({
);
};
export default memo(withGlobal(
export default memo(withGlobal<OwnProps>(
(global): StateProps => {
const { chatId, threadId, type: messageListType } = selectCurrentMessageList(global) || {};
if (!chatId || !threadId || !messageListType) {

View File

@ -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

View File

@ -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;

View File

@ -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<Record<string, ApiFormattedText>>((acc, chatId) => {
const draft = selectDraft(global, chatId, MAIN_THREAD_ID);
if (draft) {
acc[chatId] = draft;
}
const draftsByChatId = draftChatIds.reduce<Record<string, Partial<Thread>>>((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);

View File

@ -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) {

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>,
): GlobalState {
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');
}
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');
}

View File

@ -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

View File

@ -1,7 +1,9 @@
import { useEffect } from '../lib/teact/teact';
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);
return useEffect(() => {
return cb(prevDeps || []);