Composer: Support editing drafts, restore regular drafts after editing (#1764)
This commit is contained in:
parent
2b55037b08
commit
debbbf339d
@ -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));
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 || []);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user