From eed6241f42e1a68201a4aca05c845f2d5fd439c5 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Wed, 8 Feb 2023 00:43:35 +0100 Subject: [PATCH] Composer: Introduce Signals (#2378) --- .eslintrc | 2 +- src/components/middle/ActionMessage.tsx | 2 +- src/components/middle/HeaderMenuContainer.tsx | 2 +- .../middle/composer/AttachmentModal.tsx | 109 +++--- src/components/middle/composer/Composer.tsx | 311 ++++++++++-------- .../middle/composer/CustomEmojiTooltip.tsx | 13 +- .../middle/composer/MessageInput.tsx | 46 ++- .../middle/composer/StickerTooltip.tsx | 10 +- .../middle/composer/WebPagePreview.tsx | 40 +-- .../composer/hooks/useBotCommandTooltip.ts | 70 ++-- .../composer/hooks/useCustomEmojiTooltip.ts | 114 ++++--- .../middle/composer/hooks/useDraft.ts | 132 ++++---- .../middle/composer/hooks/useEditing.ts | 19 +- .../middle/composer/hooks/useEmojiTooltip.ts | 180 +++++----- .../composer/hooks/useInlineBotTooltip.ts | 106 +++--- .../composer/hooks/useInputCustomEmojis.ts | 9 +- .../composer/hooks/useMentionTooltip.ts | 163 ++++----- .../composer/hooks/useStickerTooltip.ts | 68 ++-- src/hooks/useAsyncResolvers.ts | 16 + src/hooks/useDebouncedCallback.ts | 4 +- src/hooks/useDerivedSignal.ts | 43 +++ src/hooks/useDerivedState.ts | 60 ++++ src/hooks/useGetSelectionRange.ts | 38 +++ src/hooks/useOnSelectionChange.ts | 28 -- src/hooks/useRunDebounced.ts | 5 +- src/hooks/useRunThrottled.ts | 5 +- src/hooks/useSignal.ts | 8 + src/hooks/useSignalEffect.ts | 23 ++ src/hooks/useStateRef.ts | 2 +- src/hooks/useThrottledCallback.ts | 15 +- src/lib/teact/teact.ts | 42 ++- src/util/selection.ts | 19 +- src/util/signals.ts | 81 +++++ 33 files changed, 1060 insertions(+), 725 deletions(-) create mode 100644 src/hooks/useAsyncResolvers.ts create mode 100644 src/hooks/useDerivedSignal.ts create mode 100644 src/hooks/useDerivedState.ts create mode 100644 src/hooks/useGetSelectionRange.ts delete mode 100644 src/hooks/useOnSelectionChange.ts create mode 100644 src/hooks/useSignal.ts create mode 100644 src/hooks/useSignalEffect.ts create mode 100644 src/util/signals.ts diff --git a/.eslintrc b/.eslintrc index e45a0b8d4..bc91cf62e 100644 --- a/.eslintrc +++ b/.eslintrc @@ -43,7 +43,7 @@ "react-hooks/exhaustive-deps": [ "error", { - "additionalHooks": "(useSyncEffect|useAsync|useDebouncedCallback|useThrottledCallback|useEffectWithPrevDeps|useLayoutEffectWithPrevDeps)$" + "additionalHooks": "(useSyncEffect|useAsync|useDebouncedCallback|useThrottledCallback|useEffectWithPrevDeps|useLayoutEffectWithPrevDeps|useDerivedState|useDerivedSignal|useThrottledResolver|useDebouncedResolver)$" } ], "arrow-body-style": "off", diff --git a/src/components/middle/ActionMessage.tsx b/src/components/middle/ActionMessage.tsx index ab98d0852..d9b7492ce 100644 --- a/src/components/middle/ActionMessage.tsx +++ b/src/components/middle/ActionMessage.tsx @@ -164,7 +164,7 @@ const ActionMessage: FC = ({ }); }; - // TODO: Refactoring for action rendering + // TODO Refactoring for action rendering const shouldSkipRender = isInsideTopic && message.content.action?.text === 'TopicWasCreatedAction'; if (shouldSkipRender) { return ; diff --git a/src/components/middle/HeaderMenuContainer.tsx b/src/components/middle/HeaderMenuContainer.tsx index c667c45b0..433dbac32 100644 --- a/src/components/middle/HeaderMenuContainer.tsx +++ b/src/components/middle/HeaderMenuContainer.tsx @@ -234,7 +234,7 @@ const HeaderMenuContainer: FC = ({ const handleEnterVoiceChatClick = useCallback(() => { if (canCreateVoiceChat) { - // TODO show popup to schedule + // TODO Show popup to schedule createGroupCall({ chatId, }); diff --git a/src/components/middle/composer/AttachmentModal.tsx b/src/components/middle/composer/AttachmentModal.tsx index 59242afc8..1100f650c 100644 --- a/src/components/middle/composer/AttachmentModal.tsx +++ b/src/components/middle/composer/AttachmentModal.tsx @@ -6,6 +6,7 @@ import { getActions, withGlobal } from '../../../global'; import type { FC } from '../../../lib/teact/teact'; import type { ApiAttachment, ApiChatMember, ApiSticker } from '../../../api/types'; import type { GlobalState } from '../../../global/types'; +import type { Signal } from '../../../util/signals'; import { BASE_EMOJI_KEYWORD_LANG, @@ -29,10 +30,11 @@ import useEmojiTooltip from './hooks/useEmojiTooltip'; import useLang from '../../../hooks/useLang'; import useFlag from '../../../hooks/useFlag'; import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; -import { useStateRef } from '../../../hooks/useStateRef'; import useCustomEmojiTooltip from './hooks/useCustomEmojiTooltip'; import useAppLayout from '../../../hooks/useAppLayout'; import useScrolledState from '../../../hooks/useScrolledState'; +import useGetSelectionRange from '../../../hooks/useGetSelectionRange'; +import useDerivedState from '../../../hooks/useDerivedState'; import Button from '../../ui/Button'; import Modal from '../../ui/Modal'; @@ -51,11 +53,12 @@ export type OwnProps = { chatId: string; threadId: number; attachments: ApiAttachment[]; - caption: string; + getHtml: Signal; canShowCustomSendMenu?: boolean; isReady?: boolean; shouldSchedule?: boolean; shouldSuggestCompression?: boolean; + isForCurrentMessageList?: boolean; onCaptionUpdate: (html: string) => void; onSend: (sendCompressed: boolean, sendGrouped: boolean) => void; onFileAppend: (files: File[], isSpoiler?: boolean) => void; @@ -79,13 +82,13 @@ type StateProps = { }; const DROP_LEAVE_TIMEOUT_MS = 150; -const CAPTION_SYMBOLS_LEFT_THRESHOLD = 100; +const MAX_LEFT_CHARS_TO_SHOW = 100; const AttachmentModal: FC = ({ chatId, threadId, attachments, - caption, + getHtml, canShowCustomSendMenu, captionLimit, isReady, @@ -100,6 +103,7 @@ const AttachmentModal: FC = ({ customEmojiForEmoji, attachmentSettings, shouldSuggestCompression, + isForCurrentMessageList, onAttachmentsUpdate, onCaptionUpdate, onSend, @@ -109,10 +113,14 @@ const AttachmentModal: FC = ({ onSendScheduled, }) => { const { addRecentCustomEmoji, addRecentEmoji, updateAttachmentSettings } = getActions(); + const lang = useLang(); - const captionRef = useStateRef(caption); + // eslint-disable-next-line no-null/no-null - const mainButtonRef = useStateRef(null); + const mainButtonRef = useRef(null); + // eslint-disable-next-line no-null/no-null + const inputRef = useRef(null); + const hideTimeoutRef = useRef(); const prevAttachments = usePrevious(attachments); const renderingAttachments = attachments.length ? attachments : prevAttachments; @@ -132,6 +140,7 @@ const AttachmentModal: FC = ({ const { handleScroll: handleCaptionScroll, isAtBeginning: isCaptionNotScrolled } = useScrolledState(); const isOpen = Boolean(attachments.length); + const renderingIsOpen = Boolean(renderingAttachments?.length); const [isHovered, markHovered, unmarkHovered] = useFlag(); const [hasMedia, hasOnlyMedia] = useMemo(() => { @@ -148,42 +157,51 @@ const AttachmentModal: FC = ({ return [hasOneSpoiler, false]; }, [renderingAttachments]); - const { - isMentionTooltipOpen, closeMentionTooltip, insertMention, mentionFilteredUsers, - } = useMentionTooltip( - isOpen, - `#${EDITABLE_INPUT_MODAL_ID}`, - onCaptionUpdate, - groupChatMembers, - undefined, - currentUserId, - ); - - const { isCustomEmojiTooltipOpen, insertCustomEmoji } = useCustomEmojiTooltip( - Boolean(shouldSuggestCustomEmoji) && isOpen, - `#${EDITABLE_INPUT_MODAL_ID}`, - caption, - onCaptionUpdate, - customEmojiForEmoji, - !isReady, - ); + const getSelectionRange = useGetSelectionRange(`#${EDITABLE_INPUT_MODAL_ID}`); const { isEmojiTooltipOpen, filteredEmojis, filteredCustomEmojis, insertEmoji, - insertCustomEmoji: insertCustomEmojiFromEmojiTooltip, closeEmojiTooltip, } = useEmojiTooltip( - isOpen, - captionRef, - recentEmojis, - EDITABLE_INPUT_MODAL_ID, + Boolean(isReady && isForCurrentMessageList && renderingIsOpen), + getHtml, onCaptionUpdate, + EDITABLE_INPUT_MODAL_ID, + recentEmojis, baseEmojiKeywords, emojiKeywords, - !isReady, + ); + + const { + isCustomEmojiTooltipOpen, + insertCustomEmoji, + closeCustomEmojiTooltip, + } = useCustomEmojiTooltip( + Boolean(isReady && isForCurrentMessageList && renderingIsOpen && shouldSuggestCustomEmoji), + getHtml, + onCaptionUpdate, + getSelectionRange, + inputRef, + customEmojiForEmoji, + ); + + const { + isMentionTooltipOpen, + closeMentionTooltip, + insertMention, + mentionFilteredUsers, + } = useMentionTooltip( + Boolean(isReady && isForCurrentMessageList && renderingIsOpen), + getHtml, + onCaptionUpdate, + getSelectionRange, + inputRef, + groupChatMembers, + undefined, + currentUserId, ); useEffect(() => (isOpen ? captureEscKeyListener(onClear) : undefined), [isOpen, onClear]); @@ -324,10 +342,12 @@ const AttachmentModal: FC = ({ ); }, [isMobile]); - const leftChars = useMemo(() => { - const captionLeftBeforeLimit = captionLimit - getHtmlTextLength(caption); - return captionLeftBeforeLimit <= CAPTION_SYMBOLS_LEFT_THRESHOLD ? captionLeftBeforeLimit : undefined; - }, [caption, captionLimit]); + const leftChars = useDerivedState(() => { + if (!renderingIsOpen) return undefined; + + const leftCharsBeforeLimit = captionLimit - getHtmlTextLength(getHtml()); + return leftCharsBeforeLimit <= MAX_LEFT_CHARS_TO_SHOW ? leftCharsBeforeLimit : undefined; + }, [captionLimit, getHtml, renderingIsOpen]); const isQuickGallery = shouldSendCompressed && hasOnlyMedia; @@ -475,39 +495,42 @@ const AttachmentModal: FC = ({ >
diff --git a/src/components/middle/composer/Composer.tsx b/src/components/middle/composer/Composer.tsx index dc18fd36e..8799605c5 100644 --- a/src/components/middle/composer/Composer.tsx +++ b/src/components/middle/composer/Composer.tsx @@ -28,7 +28,8 @@ import { EDITABLE_INPUT_ID, REPLIES_USER_ID, SEND_MESSAGE_ACTION_INTERVAL, - EDITABLE_INPUT_CSS_SELECTOR, MAX_UPLOAD_FILEPART_SIZE, + EDITABLE_INPUT_CSS_SELECTOR, + MAX_UPLOAD_FILEPART_SIZE, } from '../../../config'; import { IS_VOICE_RECORDING_SUPPORTED, IS_IOS } from '../../../util/environment'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; @@ -81,6 +82,7 @@ import { buildCustomEmojiHtml } from './helpers/customEmoji'; import { processMessageInputForCustomEmoji } from '../../../util/customEmojiManager'; import { getTextWithEntitiesAsHtml } from '../../common/helpers/renderTextWithEntities'; +import useSignal from '../../../hooks/useSignal'; import useFlag from '../../../hooks/useFlag'; import usePrevious from '../../../hooks/usePrevious'; import useStickerTooltip from './hooks/useStickerTooltip'; @@ -89,7 +91,6 @@ import useLang from '../../../hooks/useLang'; import useSendMessageAction from '../../../hooks/useSendMessageAction'; import useInterval from '../../../hooks/useInterval'; import useSyncEffect from '../../../hooks/useSyncEffect'; -import { useStateRef } from '../../../hooks/useStateRef'; import useVoiceRecording from './hooks/useVoiceRecording'; import useClipboardPaste from './hooks/useClipboardPaste'; import useDraft from './hooks/useDraft'; @@ -101,6 +102,9 @@ import useBotCommandTooltip from './hooks/useBotCommandTooltip'; import useSchedule from '../../../hooks/useSchedule'; import useCustomEmojiTooltip from './hooks/useCustomEmojiTooltip'; import useAttachmentModal from './hooks/useAttachmentModal'; +import useGetSelectionRange from '../../../hooks/useGetSelectionRange'; +import useDerivedState from '../../../hooks/useDerivedState'; +import { useStateRef } from '../../../hooks/useStateRef'; import DeleteMessageModal from '../../common/DeleteMessageModal.async'; import Button from '../../ui/Button'; @@ -291,12 +295,16 @@ const Composer: FC = ({ addRecentCustomEmoji, showNotification, } = getActions(); + const lang = useLang(); // eslint-disable-next-line no-null/no-null const appendixRef = useRef(null); - const [html, setInnerHtml] = useState(''); - const htmlRef = useStateRef(html); + // eslint-disable-next-line no-null/no-null + const inputRef = useRef(null); + + const [getHtml, setHtml] = useSignal(''); + const getSelectionRange = useGetSelectionRange(EDITABLE_INPUT_CSS_SELECTOR); const lastMessageSendTimeSeconds = useRef(); const prevDropAreaState = usePrevious(dropAreaState); const { width: windowWidth } = windowSize.get(); @@ -307,12 +315,7 @@ const Composer: FC = ({ const [isSymbolMenuForced, forceShowSymbolMenu, cancelForceShowSymbolMenu] = useFlag(); const sendMessageAction = useSendMessageAction(chatId, threadId); - const setHtml = useCallback((newHtml: string) => { - setInnerHtml(newHtml); - requestAnimationFrame(() => { - processMessageInputForCustomEmoji(); - }); - }, []); + useEffect(processMessageInputForCustomEmoji, [getHtml]); const customEmojiNotificationNumber = useRef(0); @@ -350,6 +353,7 @@ const Composer: FC = ({ }, []); const [attachments, setAttachments] = useState([]); + const hasAttachments = Boolean(attachments.length); const { shouldSuggestCompression, @@ -393,48 +397,12 @@ const Composer: FC = ({ } }, [activeVoiceRecording, sendMessageAction]); + const isEditingRef = useStateRef(Boolean(editingMessage)); useEffect(() => { - if (!html || editingMessage) return; - sendMessageAction({ type: 'typing' }); - }, [editingMessage, html, sendMessageAction]); - - const { - isMentionTooltipOpen, closeMentionTooltip, insertMention, mentionFilteredUsers, - } = useMentionTooltip( - !attachments.length, - EDITABLE_INPUT_CSS_SELECTOR, - setHtml, - groupChatMembers, - topInlineBotIds, - currentUserId, - ); - - const { - isOpen: isInlineBotTooltipOpen, - id: inlineBotId, - isGallery: isInlineBotTooltipGallery, - switchPm: inlineBotSwitchPm, - results: inlineBotResults, - closeTooltip: closeInlineBotTooltip, - help: inlineBotHelp, - loadMore: loadMoreForInlineBot, - } = useInlineBotTooltip( - Boolean(!attachments.length && lastSyncTime), - chatId, - html, - inlineBots, - ); - - const { - isOpen: isBotCommandTooltipOpen, - close: closeBotCommandTooltip, - filteredBotCommands: botTooltipCommands, - } = useBotCommandTooltip( - Boolean((botCommands && botCommands.length) || (chatBotCommands && chatBotCommands.length)), - html, - botCommands, - chatBotCommands, - ); + if (getHtml() && !isEditingRef.current) { + sendMessageAction({ type: 'typing' }); + } + }, [getHtml, isEditingRef, sendMessageAction]); const { canSendStickers, canSendGifs, canAttachMedia, canAttachPolls, canAttachEmbedLinks, @@ -443,36 +411,85 @@ const Composer: FC = ({ const isAdmin = chat && isChatAdmin(chat); const slowMode = getChatSlowModeOptions(chat); - const { isStickerTooltipOpen, closeStickerTooltip } = useStickerTooltip( - Boolean(shouldSuggestStickers && canSendStickers && !attachments.length), - html, - stickersForEmoji, - !isReady, - ); - const { isCustomEmojiTooltipOpen, closeCustomEmojiTooltip, insertCustomEmoji } = useCustomEmojiTooltip( - Boolean(shouldSuggestCustomEmoji && !attachments.length), - EDITABLE_INPUT_CSS_SELECTOR, - html, - setHtml, - customEmojiForEmoji, - !isReady, - ); const { isEmojiTooltipOpen, closeEmojiTooltip, filteredEmojis, filteredCustomEmojis, insertEmoji, - insertCustomEmoji: insertCustomEmojiFromEmojiTooltip, } = useEmojiTooltip( - Boolean(shouldSuggestStickers && canSendStickers && !attachments.length), - htmlRef, - recentEmojis, - undefined, + Boolean(isReady && isForCurrentMessageList && shouldSuggestStickers && !hasAttachments), + getHtml, setHtml, + undefined, + recentEmojis, baseEmojiKeywords, emojiKeywords, - !isReady, + ); + + const { + isCustomEmojiTooltipOpen, + closeCustomEmojiTooltip, + insertCustomEmoji, + } = useCustomEmojiTooltip( + Boolean(isReady && isForCurrentMessageList && shouldSuggestCustomEmoji && !hasAttachments), + getHtml, + setHtml, + getSelectionRange, + inputRef, + customEmojiForEmoji, + ); + + const { + isStickerTooltipOpen, + closeStickerTooltip, + } = useStickerTooltip( + Boolean(isReady && isForCurrentMessageList && shouldSuggestStickers && canSendStickers && !hasAttachments), + getHtml, + stickersForEmoji, + ); + + const { + isMentionTooltipOpen, + closeMentionTooltip, + insertMention, + mentionFilteredUsers, + } = useMentionTooltip( + Boolean(isReady && isForCurrentMessageList && !hasAttachments), + getHtml, + setHtml, + getSelectionRange, + inputRef, + groupChatMembers, + topInlineBotIds, + currentUserId, + ); + + const { + isOpen: isInlineBotTooltipOpen, + botId: inlineBotId, + isGallery: isInlineBotTooltipGallery, + switchPm: inlineBotSwitchPm, + results: inlineBotResults, + closeTooltip: closeInlineBotTooltip, + help: inlineBotHelp, + loadMore: loadMoreForInlineBot, + } = useInlineBotTooltip( + Boolean(isReady && isForCurrentMessageList && !hasAttachments && lastSyncTime), + chatId, + getHtml, + inlineBots, + ); + + const { + isOpen: isBotCommandTooltipOpen, + close: closeBotCommandTooltip, + filteredBotCommands: botTooltipCommands, + } = useBotCommandTooltip( + Boolean(isReady && isForCurrentMessageList && ((botCommands && botCommands?.length) || chatBotCommands?.length)), + getHtml, + botCommands, + chatBotCommands, ); const insertHtmlAndUpdateCursor = useCallback((newHtml: string, inputId: string = EDITABLE_INPUT_ID) => { @@ -493,13 +510,13 @@ const Composer: FC = ({ } } - setHtml(`${htmlRef.current!}${newHtml}`); + setHtml(`${getHtml()}${newHtml}`); // If selection is outside of input, set cursor at the end of input requestAnimationFrame(() => { focusEditableElement(messageInput); }); - }, [htmlRef, setHtml]); + }, [getHtml, setHtml]); const insertFormattedTextAndUpdateCursor = useCallback(( text: ApiFormattedText, inputId: string = EDITABLE_INPUT_ID, @@ -530,18 +547,22 @@ const Composer: FC = ({ } } - setHtml(deleteLastCharacterOutsideSelection(htmlRef.current!)); - }, [htmlRef, setHtml]); + setHtml(deleteLastCharacterOutsideSelection(getHtml())); + }, [getHtml, setHtml]); + + useDraft(draft, chatId, threadId, getHtml, setHtml, editingMessage, lastSyncTime); const resetComposer = useCallback((shouldPreserveInput = false) => { if (!shouldPreserveInput) { setHtml(''); } + setAttachments(MEMO_EMPTY_ARRAY); - closeStickerTooltip(); - closeCustomEmojiTooltip(); - closeMentionTooltip(); + closeEmojiTooltip(); + closeCustomEmojiTooltip(); + closeStickerTooltip(); + closeMentionTooltip(); if (isMobile) { // @optimization @@ -550,19 +571,35 @@ const Composer: FC = ({ closeSymbolMenu(); } }, [ - closeStickerTooltip, closeCustomEmojiTooltip, closeMentionTooltip, closeEmojiTooltip, - closeSymbolMenu, setHtml, isMobile, + setHtml, isMobile, closeStickerTooltip, closeCustomEmojiTooltip, closeMentionTooltip, closeEmojiTooltip, + closeSymbolMenu, ]); - // Handle chat change (ref is used to avoid redundant effect calls) - const stopRecordingVoiceRef = useRef(); - stopRecordingVoiceRef.current = stopRecordingVoice; + const [handleEditComplete, handleEditCancel, shouldForceShowEditing] = useEditing( + getHtml, + setHtml, + editingMessage, + resetComposer, + openDeleteModal, + chatId, + threadId, + messageListType, + draft, + editingDraft, + replyingToId, + ); + + // Handle chat change (should be placed after `useDraft` and `useEditing`) + const resetComposerRef = useStateRef(resetComposer); + const stopRecordingVoiceRef = useStateRef(stopRecordingVoice); useEffect(() => { return () => { - stopRecordingVoiceRef.current!(); - resetComposer(); + // eslint-disable-next-line react-hooks/exhaustive-deps + stopRecordingVoiceRef.current(); + // eslint-disable-next-line react-hooks/exhaustive-deps + resetComposerRef.current(); }; - }, [chatId, threadId, resetComposer, stopRecordingVoiceRef]); + }, [chatId, threadId, resetComposerRef, stopRecordingVoiceRef]); const showCustomEmojiPremiumNotification = useCallback(() => { const notificationNumber = customEmojiNotificationNumber.current; @@ -588,26 +625,12 @@ const Composer: FC = ({ customEmojiNotificationNumber.current = Number(!notificationNumber); }, [currentUserId, lang, showNotification]); - const [handleEditComplete, handleEditCancel, shouldForceShowEditing] = useEditing( - htmlRef, - setHtml, - editingMessage, - resetComposer, - openDeleteModal, - chatId, - threadId, - messageListType, - draft, - editingDraft, - replyingToId, - ); - - const mainButtonState = useMemo(() => { + const mainButtonState = useDerivedState(() => { if (editingMessage && shouldForceShowEditing) { return MainButtonState.Edit; } - if (IS_VOICE_RECORDING_SUPPORTED && !activeVoiceRecording && !(html && !attachments.length) && !isForwarding) { + if (IS_VOICE_RECORDING_SUPPORTED && !activeVoiceRecording && !isForwarding && !(getHtml() && !hasAttachments)) { return MainButtonState.Record; } @@ -617,8 +640,7 @@ const Composer: FC = ({ return MainButtonState.Send; }, [ - activeVoiceRecording, attachments.length, editingMessage, html, isForwarding, shouldForceShowEditing, - shouldSchedule, + activeVoiceRecording, editingMessage, getHtml, hasAttachments, isForwarding, shouldForceShowEditing, shouldSchedule, ]); const canShowCustomSendMenu = !shouldSchedule; @@ -629,7 +651,6 @@ const Composer: FC = ({ handleContextMenuHide, } = useContextMenuHandlers(mainButtonRef, !(mainButtonState === MainButtonState.Send && canShowCustomSendMenu)); - useDraft(draft, chatId, threadId, htmlRef, setHtml, editingMessage, lastSyncTime); useClipboardPaste( isForCurrentMessageList, insertFormattedTextAndUpdateCursor, @@ -703,7 +724,7 @@ const Composer: FC = ({ sendGrouped = attachmentSettings.shouldSendGrouped, isSilent, scheduledAt, - } : { + }: { attachments: ApiAttachment[]; sendCompressed?: boolean; sendGrouped?: boolean; @@ -714,7 +735,7 @@ const Composer: FC = ({ return; } - const { text, entities } = parseMessageInput(htmlRef.current!); + const { text, entities } = parseMessageInput(getHtml()); if (!text && !attachmentsToSend.length) { return; } @@ -740,8 +761,8 @@ const Composer: FC = ({ resetComposer(); }); }, [ - attachmentSettings, connectionState, htmlRef, validateTextLength, checkSlowMode, sendMessage, clearDraft, chatId, - resetComposer, + attachmentSettings.shouldCompress, attachmentSettings.shouldSendGrouped, connectionState, getHtml, + validateTextLength, checkSlowMode, sendMessage, clearDraft, chatId, resetComposer, ]); const handleSendAttachments = useCallback(( @@ -778,7 +799,7 @@ const Composer: FC = ({ } } - const { text, entities } = parseMessageInput(htmlRef.current!); + const { text, entities } = parseMessageInput(getHtml()); if (currentAttachments.length) { sendAttachments({ @@ -827,7 +848,7 @@ const Composer: FC = ({ resetComposer(); }); }, [ - connectionState, attachments, activeVoiceRecording, htmlRef, isForwarding, validateTextLength, clearDraft, + connectionState, attachments, activeVoiceRecording, getHtml, isForwarding, validateTextLength, clearDraft, chatId, stopRecordingVoice, sendAttachments, checkSlowMode, sendMessage, forwardMessages, resetComposer, ]); @@ -1205,9 +1226,13 @@ const Composer: FC = ({ : mainButtonState === MainButtonState.Schedule ? handleSendScheduled : handleSend; - const isBotMenuButtonCommands = botMenuButton && botMenuButton?.type === 'commands'; - const shouldDisplayBotCommands = isChatWithBot && isBotMenuButtonCommands && botCommands !== false - && !activeVoiceRecording && !editingMessage; + const withBotMenuButton = isChatWithBot && botMenuButton?.type === 'webApp' && !editingMessage; + const isBotMenuButtonOpen = useDerivedState(() => { + return withBotMenuButton && !getHtml() && !activeVoiceRecording; + }, [withBotMenuButton, getHtml, activeVoiceRecording]); + + const withBotCommands = isChatWithBot && botMenuButton?.type === 'commands' && !editingMessage + && botCommands !== false && !activeVoiceRecording; return (
@@ -1224,9 +1249,10 @@ const Composer: FC = ({ threadId={threadId} canShowCustomSendMenu={canShowCustomSendMenu} attachments={attachments} - caption={attachments.length ? html : ''} + getHtml={getHtml} isReady={isReady} shouldSuggestCompression={shouldSuggestCompression} + isForCurrentMessageList={isForCurrentMessageList} onCaptionUpdate={onCaptionUpdate} onSendSilent={handleSendSilentAttachments} onSend={handleSendAttachments} @@ -1260,9 +1286,9 @@ const Composer: FC = ({ /> = ({ isGallery={isInlineBotTooltipGallery} inlineBotResults={inlineBotResults} switchPm={inlineBotSwitchPm} - onSelectResult={handleInlineBotSelect} loadMore={loadMoreForInlineBot} - onClose={closeInlineBotTooltip} isSavedMessages={isChatWithSelf} canSendGifs={canSendGifs} isCurrentUserPremium={isCurrentUserPremium} + onSelectResult={handleInlineBotSelect} + onClose={closeInlineBotTooltip} /> = ({
- {isChatWithBot && botMenuButton && botMenuButton.type === 'webApp' && !editingMessage - && ( - - )} - {shouldDisplayBotCommands && ( + {withBotMenuButton && ( + + )} + {withBotCommands && ( = ({ )} 0} + canAutoFocus={isReady && isForCurrentMessageList && !hasAttachments} + noFocusInterception={hasAttachments} shouldSuppressFocus={isMobile && isSymbolMenuOpen} shouldSuppressTextFormatter={isEmojiTooltipOpen || isMentionTooltipOpen || isInlineBotTooltipOpen} onUpdate={setHtml} @@ -1439,22 +1466,24 @@ const Composer: FC = ({ isOpen={isCustomEmojiTooltipOpen} onCustomEmojiSelect={insertCustomEmoji} addRecentCustomEmoji={addRecentCustomEmoji} + onClose={closeCustomEmojiTooltip} /> ( const keyboardMessage = botKeyboardMessageId ? selectChatMessage(global, chatId, botKeyboardMessageId) : undefined; const { currentUserId } = global; const defaultSendAsId = chat?.fullInfo ? chat?.fullInfo?.sendAsId || currentUserId : undefined; - const sendAsId = chat?.sendAsPeerIds && defaultSendAsId - && chat.sendAsPeerIds.some((peer) => peer.id === defaultSendAsId) ? defaultSendAsId - : (chat?.adminRights?.anonymous ? chat?.id : undefined); + const sendAsId = chat?.sendAsPeerIds && defaultSendAsId && ( + chat.sendAsPeerIds.some((peer) => peer.id === defaultSendAsId) + ? defaultSendAsId + : (chat?.adminRights?.anonymous ? chat?.id : undefined) + ); const sendAsUser = sendAsId ? selectUser(global, sendAsId) : undefined; const sendAsChat = !sendAsUser && sendAsId ? selectChat(global, sendAsId) : undefined; const requestedDraftText = selectRequestedDraftText(global, chatId); diff --git a/src/components/middle/composer/CustomEmojiTooltip.tsx b/src/components/middle/composer/CustomEmojiTooltip.tsx index 1c4cbe6d5..275a03514 100644 --- a/src/components/middle/composer/CustomEmojiTooltip.tsx +++ b/src/components/middle/composer/CustomEmojiTooltip.tsx @@ -25,8 +25,9 @@ import styles from './CustomEmojiTooltip.module.scss'; export type OwnProps = { chatId: string; isOpen: boolean; - onCustomEmojiSelect: (customEmoji: ApiSticker) => void; addRecentCustomEmoji: GlobalActions['addRecentCustomEmoji']; + onCustomEmojiSelect: (customEmoji: ApiSticker) => void; + onClose: NoneToVoidFunction; }; type StateProps = { @@ -39,11 +40,12 @@ const INTERSECTION_THROTTLE = 200; const CustomEmojiTooltip: FC = ({ isOpen, + addRecentCustomEmoji, + onCustomEmojiSelect, + onClose, customEmoji, isSavedMessages, isCurrentUserPremium, - onCustomEmojiSelect, - addRecentCustomEmoji, }) => { const { clearCustomEmojiForEmoji } = getActions(); @@ -59,9 +61,7 @@ const CustomEmojiTooltip: FC = ({ observe: observeIntersection, } = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE }); - useEffect(() => ( - isOpen ? captureEscKeyListener(clearCustomEmojiForEmoji) : undefined - ), [isOpen, clearCustomEmojiForEmoji]); + useEffect(() => (isOpen ? captureEscKeyListener(onClose) : undefined), [isOpen, onClose]); const handleCustomEmojiSelect = useCallback((ce: ApiSticker) => { if (!isOpen) return; @@ -111,6 +111,7 @@ export default memo(withGlobal( const { stickers: customEmoji } = global.customEmojis.forEmoji; const isSavedMessages = selectIsChatWithSelf(global, chatId); const isCurrentUserPremium = selectIsCurrentUserPremium(global); + return { customEmoji, isSavedMessages, isCurrentUserPremium }; }, )(CustomEmojiTooltip)); diff --git a/src/components/middle/composer/MessageInput.tsx b/src/components/middle/composer/MessageInput.tsx index 384bcd0fb..5b0788105 100644 --- a/src/components/middle/composer/MessageInput.tsx +++ b/src/components/middle/composer/MessageInput.tsx @@ -1,11 +1,12 @@ -import type { ChangeEvent } from 'react'; +import type { RefObject, ChangeEvent } from 'react'; import type { FC } from '../../../lib/teact/teact'; import React, { - useEffect, useRef, memo, useState, useCallback, + useEffect, useRef, memo, useState, useCallback, useLayoutEffect, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { IAnchorPosition, ISettings } from '../../../types'; +import type { Signal } from '../../../util/signals'; import { EDITABLE_INPUT_ID } from '../../../config'; import { @@ -21,12 +22,12 @@ import parseEmojiOnlyString from '../../../util/parseEmojiOnlyString'; import { isSelectionInsideInput } from './helpers/selection'; import renderText from '../../common/helpers/renderText'; -import useLayoutEffectWithPrevDeps from '../../../hooks/useLayoutEffectWithPrevDeps'; import useFlag from '../../../hooks/useFlag'; import { isHeavyAnimating } from '../../../hooks/useHeavyAnimationCheck'; import useLang from '../../../hooks/useLang'; import useInputCustomEmojis from './hooks/useInputCustomEmojis'; import useAppLayout from '../../../hooks/useAppLayout'; +import useDerivedState from '../../../hooks/useDerivedState'; import TextFormatter from './TextFormatter'; @@ -39,12 +40,14 @@ const SCROLLER_CLASS = 'input-scroller'; const INPUT_WRAPPER_CLASS = 'message-input-wrapper'; type OwnProps = { + ref?: RefObject; id: string; chatId: string; threadId: number; isAttachmentModalInput?: boolean; editableInputId?: string; - html: string; + isActive: boolean; + getHtml: Signal; placeholder: string; forcedPlaceholder?: string; noFocusInterception?: boolean; @@ -89,12 +92,14 @@ function clearSelection() { } const MessageInput: FC = ({ + ref, id, chatId, captionLimit, isAttachmentModalInput, editableInputId, - html, + isActive, + getHtml, placeholder, forcedPlaceholder, canAutoFocus, @@ -115,7 +120,11 @@ const MessageInput: FC = ({ } = getActions(); // eslint-disable-next-line no-null/no-null - const inputRef = useRef(null); + let inputRef = useRef(null); + if (ref) { + inputRef = ref; + } + // eslint-disable-next-line no-null/no-null const selectionTimeoutRef = useRef(null); // eslint-disable-next-line no-null/no-null @@ -137,7 +146,7 @@ const MessageInput: FC = ({ const [isTextFormatterDisabled, setIsTextFormatterDisabled] = useState(false); const { isMobile } = useAppLayout(); - useInputCustomEmojis(html, inputRef, sharedCanvasRef, sharedCanvasHqRef, absoluteContainerRef); + useInputCustomEmojis(getHtml, inputRef, sharedCanvasRef, sharedCanvasHqRef, absoluteContainerRef); const maxInputHeight = isMobile ? 256 : 416; const updateInputHeight = useCallback((willSend = false) => { @@ -173,7 +182,10 @@ const MessageInput: FC = ({ updateInputHeight(false); }, [isAttachmentModalInput, updateInputHeight]); - useLayoutEffectWithPrevDeps(([prevHtml]) => { + const htmlRef = useRef(getHtml()); + useLayoutEffect(() => { + const html = isActive ? getHtml() : ''; + if (html !== inputRef.current!.innerHTML) { inputRef.current!.innerHTML = html; } @@ -182,10 +194,12 @@ const MessageInput: FC = ({ cloneRef.current!.innerHTML = html; } - if (prevHtml !== undefined && prevHtml !== html) { - updateInputHeight(!html.length); + if (html !== htmlRef.current) { + htmlRef.current = html; + + updateInputHeight(!html); } - }, [html, updateInputHeight]); + }, [getHtml, isActive, updateInputHeight]); const chatIdRef = useRef(chatId); chatIdRef.current = chatId; @@ -311,7 +325,9 @@ const MessageInput: FC = ({ // https://levelup.gitconnected.com/javascript-events-handlers-keyboard-and-load-events-1b3e46a6b0c3#1960 const { isComposing } = e; - if (!isComposing && !html.length && (e.metaKey || e.ctrlKey)) { + const html = getHtml(); + + if (!isComposing && !html && (e.metaKey || e.ctrlKey)) { const targetIndexDelta = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : undefined; if (targetIndexDelta) { e.preventDefault(); @@ -334,7 +350,7 @@ const MessageInput: FC = ({ closeTextFormatter(); onSend(); } - } else if (!isComposing && e.key === 'ArrowUp' && !html.length && !e.metaKey && !e.ctrlKey && !e.altKey) { + } else if (!isComposing && e.key === 'ArrowUp' && !html && !e.metaKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); editLastMessage(); } else { @@ -472,9 +488,11 @@ const MessageInput: FC = ({ }; }, [shouldSuppressFocus]); + const isTouched = useDerivedState(() => Boolean(isActive && getHtml()), [isActive, getHtml]); + const className = buildClassName( 'form-control', - html.length > 0 && 'touched', + isTouched && 'touched', shouldSuppressFocus && 'focus-disabled', ); diff --git a/src/components/middle/composer/StickerTooltip.tsx b/src/components/middle/composer/StickerTooltip.tsx index 9fa182dc8..5b34e6b1b 100644 --- a/src/components/middle/composer/StickerTooltip.tsx +++ b/src/components/middle/composer/StickerTooltip.tsx @@ -1,6 +1,6 @@ import type { FC } from '../../../lib/teact/teact'; import React, { memo, useEffect, useRef } from '../../../lib/teact/teact'; -import { getActions, withGlobal } from '../../../global'; +import { withGlobal } from '../../../global'; import type { ApiSticker } from '../../../api/types'; @@ -23,6 +23,7 @@ export type OwnProps = { threadId?: number; isOpen: boolean; onStickerSelect: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void; + onClose: NoneToVoidFunction; }; type StateProps = { @@ -37,13 +38,12 @@ const StickerTooltip: FC = ({ chatId, threadId, isOpen, + onStickerSelect, + onClose, stickers, isSavedMessages, - onStickerSelect, isCurrentUserPremium, }) => { - const { clearStickersForEmoji } = getActions(); - // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); const { shouldRender, transitionClassNames } = useShowTransition(isOpen, undefined, undefined, false); @@ -55,7 +55,7 @@ const StickerTooltip: FC = ({ observe: observeIntersection, } = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE }); - useEffect(() => (isOpen ? captureEscKeyListener(clearStickersForEmoji) : undefined), [isOpen, clearStickersForEmoji]); + useEffect(() => (isOpen ? captureEscKeyListener(onClose) : undefined), [isOpen, onClose]); const handleMouseMove = () => { sendMessageAction({ type: 'chooseSticker' }); diff --git a/src/components/middle/composer/WebPagePreview.tsx b/src/components/middle/composer/WebPagePreview.tsx index e69e36c85..923fc92a6 100644 --- a/src/components/middle/composer/WebPagePreview.tsx +++ b/src/components/middle/composer/WebPagePreview.tsx @@ -1,3 +1,4 @@ +import type { Signal } from '../../../util/signals'; import type { FC } from '../../../lib/teact/teact'; import React, { memo, useCallback, useEffect } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; @@ -8,12 +9,14 @@ import type { ISettings } from '../../../types'; import { RE_LINK_TEMPLATE } from '../../../config'; import { selectTabState, selectNoWebPage, selectTheme } from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; import parseMessageInput from '../../../util/parseMessageInput'; import useSyncEffect from '../../../hooks/useSyncEffect'; import useShowTransition from '../../../hooks/useShowTransition'; import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; -import useDebouncedMemo from '../../../hooks/useDebouncedMemo'; -import buildClassName from '../../../util/buildClassName'; +import useDerivedState from '../../../hooks/useDerivedState'; +import useDerivedSignal from '../../../hooks/useDerivedSignal'; +import { useDebouncedResolver } from '../../../hooks/useAsyncResolvers'; import WebPage from '../message/WebPage'; import Button from '../../ui/Button'; @@ -23,8 +26,8 @@ import './WebPagePreview.scss'; type OwnProps = { chatId: string; threadId: number; - messageText: string; - disabled?: boolean; + getHtml: Signal; + isDisabled?: boolean; }; type StateProps = { @@ -39,8 +42,8 @@ const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i'); const WebPagePreview: FC = ({ chatId, threadId, - messageText, - disabled, + getHtml, + isDisabled, webPagePreview, noWebPage, theme, @@ -51,39 +54,36 @@ const WebPagePreview: FC = ({ toggleMessageWebPage, } = getActions(); - const link = useDebouncedMemo(() => { - const { text, entities } = parseMessageInput(messageText); - + const detectLinkDebounced = useDebouncedResolver(() => { + const { text, entities } = parseMessageInput(getHtml()); const linkEntity = entities?.find((entity): entity is ApiMessageEntityTextUrl => ( entity.type === ApiMessageEntityTypes.TextUrl )); - if (linkEntity) { - return linkEntity.url; - } - const textMatch = text.match(RE_LINK); - if (textMatch) { - return textMatch[0]; - } + return linkEntity?.url || text.match(RE_LINK)?.[0]; + }, [getHtml], DEBOUNCE_MS, true); - return undefined; - }, DEBOUNCE_MS, [messageText]); + const getLink = useDerivedSignal(detectLinkDebounced, [detectLinkDebounced, getHtml], true); useEffect(() => { + const link = getLink(); + if (link) { loadWebPagePreview({ text: link }); } else { clearWebPagePreview(); toggleMessageWebPage({ chatId, threadId }); } - }, [chatId, toggleMessageWebPage, clearWebPagePreview, link, loadWebPagePreview, threadId]); + }, [getLink, chatId, threadId, clearWebPagePreview, loadWebPagePreview, toggleMessageWebPage]); useSyncEffect(() => { clearWebPagePreview(); toggleMessageWebPage({ chatId, threadId }); }, [chatId, clearWebPagePreview, threadId, toggleMessageWebPage]); - const isShown = Boolean(webPagePreview && messageText.length && !noWebPage && !disabled); + const isShown = useDerivedState(() => { + return Boolean(webPagePreview && getHtml() && !noWebPage && !isDisabled); + }, [isDisabled, getHtml, noWebPage, webPagePreview]); const { shouldRender, transitionClassNames } = useShowTransition(isShown); const renderingWebPage = useCurrentOrPrev(webPagePreview, true); diff --git a/src/components/middle/composer/hooks/useBotCommandTooltip.ts b/src/components/middle/composer/hooks/useBotCommandTooltip.ts index 58687c201..ed9dde28c 100644 --- a/src/components/middle/composer/hooks/useBotCommandTooltip.ts +++ b/src/components/middle/composer/hooks/useBotCommandTooltip.ts @@ -1,68 +1,56 @@ -import { - useCallback, useEffect, useState, -} from '../../../../lib/teact/teact'; +import { useEffect, useState } from '../../../../lib/teact/teact'; import type { ApiBotCommand } from '../../../../api/types'; +import type { Signal } from '../../../../util/signals'; import { prepareForRegExp } from '../helpers/prepareForRegExp'; -import { throttle } from '../../../../util/schedulers'; import useFlag from '../../../../hooks/useFlag'; +import useDerivedSignal from '../../../../hooks/useDerivedSignal'; +import { useThrottledResolver } from '../../../../hooks/useAsyncResolvers'; -const runThrottled = throttle((cb) => cb(), 500, true); -const RE_COMMAND = /^[\w@]{1,32}\s?/i; +const RE_COMMAND = /^\/([\w@]{1,32}\s?)?/i; + +const THROTTLE = 300; export default function useBotCommandTooltip( - isAllowed: boolean, - html: string, + isEnabled: boolean, + getHtml: Signal, botCommands?: ApiBotCommand[] | false, chatBotCommands?: ApiBotCommand[], ) { - const [isOpen, markIsOpen, unmarkIsOpen] = useFlag(); const [filteredBotCommands, setFilteredBotCommands] = useState(); + const [isManuallyClosed, markManuallyClosed, unmarkManuallyClosed] = useFlag(false); - const getFilteredCommands = useCallback((filter) => { - if (!botCommands && !chatBotCommands) { - setFilteredBotCommands(undefined); + const detectCommandThrottled = useThrottledResolver(() => { + const html = getHtml(); + return isEnabled && html.startsWith('/') ? prepareForRegExp(html).match(RE_COMMAND)?.[0].trim() : undefined; + }, [getHtml, isEnabled], THROTTLE); - return; - } - - runThrottled(() => { - const nextFilteredBotCommands = (botCommands || chatBotCommands || []) - .filter(({ command }) => !filter || command.includes(filter)); - setFilteredBotCommands( - nextFilteredBotCommands && nextFilteredBotCommands.length ? nextFilteredBotCommands : undefined, - ); - }); - }, [botCommands, chatBotCommands]); + const getCommand = useDerivedSignal( + detectCommandThrottled, [detectCommandThrottled, getHtml], true, + ); useEffect(() => { - if (!isAllowed || !html.length) { + const command = getCommand(); + const commands = botCommands || chatBotCommands; + if (!command || !commands) { setFilteredBotCommands(undefined); return; } - const shouldShowCommands = html.startsWith('/'); + const filter = command.substring(1); + const nextFilteredBotCommands = commands.filter((c) => !filter || c.command.includes(filter)); - if (shouldShowCommands) { - const filter = prepareForRegExp(html.substr(1)).match(RE_COMMAND); - getFilteredCommands(filter ? filter[0] : ''); - } else { - setFilteredBotCommands(undefined); - } - }, [getFilteredCommands, html, isAllowed, unmarkIsOpen]); + setFilteredBotCommands( + nextFilteredBotCommands?.length ? nextFilteredBotCommands : undefined, + ); + }, [getCommand, botCommands, chatBotCommands]); - useEffect(() => { - if (filteredBotCommands && filteredBotCommands.length && html.length > 0) { - markIsOpen(); - } else { - unmarkIsOpen(); - } - }, [filteredBotCommands, html.length, markIsOpen, unmarkIsOpen]); + useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]); return { - isOpen, - close: unmarkIsOpen, + isOpen: Boolean(filteredBotCommands?.length && !isManuallyClosed), + close: markManuallyClosed, filteredBotCommands, }; } diff --git a/src/components/middle/composer/hooks/useCustomEmojiTooltip.ts b/src/components/middle/composer/hooks/useCustomEmojiTooltip.ts index 5de2e933c..d942eec74 100644 --- a/src/components/middle/composer/hooks/useCustomEmojiTooltip.ts +++ b/src/components/middle/composer/hooks/useCustomEmojiTooltip.ts @@ -1,90 +1,100 @@ -import { useCallback, useEffect, useState } from '../../../../lib/teact/teact'; -import { getActions } from '../../../../global'; +import type { RefObject } from 'react'; +import { useCallback, useEffect } from '../../../../lib/teact/teact'; +import twemojiRegex from '../../../../lib/twemojiRegex'; import type { ApiSticker } from '../../../../api/types'; +import type { Signal } from '../../../../util/signals'; +import { getActions } from '../../../../global'; import { EMOJI_IMG_REGEX } from '../../../../config'; import { IS_EMOJI_SUPPORTED } from '../../../../util/environment'; import { getHtmlBeforeSelection } from '../../../../util/selection'; import focusEditableElement from '../../../../util/focusEditableElement'; -import twemojiRegex from '../../../../lib/twemojiRegex'; import { buildCustomEmojiHtml } from '../helpers/customEmoji'; -import useOnSelectionChange from '../../../../hooks/useOnSelectionChange'; -import useCacheBuster from '../../../../hooks/useCacheBuster'; +import useDerivedState from '../../../../hooks/useDerivedState'; +import useFlag from '../../../../hooks/useFlag'; +import useDerivedSignal from '../../../../hooks/useDerivedSignal'; +import { useThrottledResolver } from '../../../../hooks/useAsyncResolvers'; +const THROTTLE = 300; const RE_ENDS_ON_EMOJI = new RegExp(`(${twemojiRegex.source})$`, 'g'); -const ENDS_ON_EMOJI_IMG_REGEX = new RegExp(`${EMOJI_IMG_REGEX.source}$`, 'g'); +const RE_ENDS_ON_EMOJI_IMG = new RegExp(`${EMOJI_IMG_REGEX.source}$`, 'g'); export default function useCustomEmojiTooltip( - isAllowed: boolean, - inputSelector: string, - html: string, - onUpdateHtml: (html: string) => void, - stickers?: ApiSticker[], - isDisabled = false, + isEnabled: boolean, + getHtml: Signal, + setHtml: (html: string) => void, + getSelectionRange: Signal, + inputRef: RefObject, + customEmojis?: ApiSticker[], ) { const { loadCustomEmojiForEmoji, clearCustomEmojiForEmoji } = getActions(); - const [htmlBeforeSelection, setHtmlBeforeSelection] = useState(''); + const [isManuallyClosed, markManuallyClosed, unmarkManuallyClosed] = useFlag(false); - const [cacheBuster, updateCacheBuster] = useCacheBuster(); + const extractLastEmojiThrottled = useThrottledResolver(() => { + const html = getHtml(); + if (!isEnabled || !html || !getSelectionRange()?.collapsed) return undefined; - const handleSelectionChange = useCallback((range: Range) => { - if (range.collapsed) { - updateCacheBuster(); // Update tooltip on cursor move - } - }, [updateCacheBuster]); + const hasEmoji = html.match(IS_EMOJI_SUPPORTED ? twemojiRegex : EMOJI_IMG_REGEX); + if (!hasEmoji) return undefined; - useOnSelectionChange(inputSelector, handleSelectionChange); + const htmlBeforeSelection = getHtmlBeforeSelection(inputRef.current!); + + return htmlBeforeSelection.match(IS_EMOJI_SUPPORTED ? RE_ENDS_ON_EMOJI : RE_ENDS_ON_EMOJI_IMG)?.[0]; + }, [getHtml, getSelectionRange, inputRef, isEnabled], THROTTLE); + + const getLastEmoji = useDerivedSignal( + extractLastEmojiThrottled, [extractLastEmojiThrottled, getHtml, getSelectionRange], true, + ); + + const isActive = useDerivedState(() => Boolean(getLastEmoji()), [getLastEmoji]); + const hasCustomEmojis = Boolean(customEmojis?.length); useEffect(() => { - if (!html) { - setHtmlBeforeSelection(''); - return; - } - setHtmlBeforeSelection(getHtmlBeforeSelection(document.querySelector(inputSelector)!)); - }, [html, inputSelector, cacheBuster]); + if (!isEnabled) return; - const lastEmojiText = htmlBeforeSelection.match(IS_EMOJI_SUPPORTED ? RE_ENDS_ON_EMOJI : ENDS_ON_EMOJI_IMG_REGEX)?.[0]; - const hasStickers = Boolean(stickers?.length && lastEmojiText); - - useEffect(() => { - if (isDisabled) return; - - if (isAllowed && lastEmojiText) { - loadCustomEmojiForEmoji({ - emoji: IS_EMOJI_SUPPORTED ? lastEmojiText : lastEmojiText.match(/.+alt="(.+)"/)?.[1]!, - }); - } else if (hasStickers || !lastEmojiText) { + const lastEmoji = getLastEmoji(); + if (lastEmoji) { + if (!hasCustomEmojis) { + loadCustomEmojiForEmoji({ + emoji: IS_EMOJI_SUPPORTED ? lastEmoji : lastEmoji.match(/.+alt="(.+)"/)?.[1]!, + }); + } + } else { clearCustomEmojiForEmoji(); } - // We omit `hasStickers` here to prevent re-fetching after manually closing tooltip (via ). - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lastEmojiText, clearCustomEmojiForEmoji, loadCustomEmojiForEmoji, isAllowed, isDisabled]); + }, [isEnabled, getLastEmoji, hasCustomEmojis, clearCustomEmojiForEmoji, loadCustomEmojiForEmoji]); const insertCustomEmoji = useCallback((emoji: ApiSticker) => { - if (!lastEmojiText) return; - const containerEl = document.querySelector(inputSelector)!; - const regexText = IS_EMOJI_SUPPORTED ? lastEmojiText + const lastEmoji = getLastEmoji(); + if (!isEnabled || !lastEmoji) return; + + const inputEl = inputRef.current!; + const htmlBeforeSelection = getHtmlBeforeSelection(inputEl); + const regexText = IS_EMOJI_SUPPORTED + ? lastEmoji // Escape regexp special chars - : lastEmojiText.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); + : lastEmoji.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); const regex = new RegExp(`(${regexText})\\1*$`, ''); const matched = htmlBeforeSelection.match(regex)![0]; - const count = matched.length / lastEmojiText.length; - + const count = matched.length / lastEmoji.length; const newHtml = htmlBeforeSelection.replace(regex, buildCustomEmojiHtml(emoji).repeat(count)); - const htmlAfterSelection = containerEl.innerHTML.substring(htmlBeforeSelection.length); - onUpdateHtml(`${newHtml}${htmlAfterSelection}`); + const htmlAfterSelection = inputEl.innerHTML.substring(htmlBeforeSelection.length); + + setHtml(`${newHtml}${htmlAfterSelection}`); requestAnimationFrame(() => { - focusEditableElement(containerEl, true, true); + focusEditableElement(inputEl, true, true); }); - }, [htmlBeforeSelection, inputSelector, lastEmojiText, onUpdateHtml]); + }, [getLastEmoji, isEnabled, inputRef, setHtml]); + + useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]); return { - isCustomEmojiTooltipOpen: hasStickers, - closeCustomEmojiTooltip: clearCustomEmojiForEmoji, + isCustomEmojiTooltipOpen: Boolean(isActive && hasCustomEmojis && !isManuallyClosed), + closeCustomEmojiTooltip: markManuallyClosed, insertCustomEmoji, }; } diff --git a/src/components/middle/composer/hooks/useDraft.ts b/src/components/middle/composer/hooks/useDraft.ts index b72ff300a..3904aba48 100644 --- a/src/components/middle/composer/hooks/useDraft.ts +++ b/src/components/middle/composer/hooks/useDraft.ts @@ -1,70 +1,72 @@ -import { useCallback, useEffect, useMemo } from '../../../../lib/teact/teact'; +import { useCallback, useEffect } from '../../../../lib/teact/teact'; import { getActions } from '../../../../global'; import type { ApiFormattedText, ApiMessage } from '../../../../api/types'; -import { ApiMessageEntityTypes } from '../../../../api/types'; +import type { Signal } from '../../../../util/signals'; +import { ApiMessageEntityTypes } from '../../../../api/types'; import { DRAFT_DEBOUNCE, EDITABLE_INPUT_CSS_SELECTOR } from '../../../../config'; -import usePrevious from '../../../../hooks/usePrevious'; -import { debounce } from '../../../../util/schedulers'; +import { IS_TOUCH_ENV } from '../../../../util/environment'; import focusEditableElement from '../../../../util/focusEditableElement'; import parseMessageInput from '../../../../util/parseMessageInput'; +import { getTextWithEntitiesAsHtml } from '../../../common/helpers/renderTextWithEntities'; import useBackgroundMode from '../../../../hooks/useBackgroundMode'; import useBeforeUnload from '../../../../hooks/useBeforeUnload'; -import { IS_TOUCH_ENV } from '../../../../util/environment'; -import { getTextWithEntitiesAsHtml } from '../../../common/helpers/renderTextWithEntities'; +import { useStateRef } from '../../../../hooks/useStateRef'; +import useEffectWithPrevDeps from '../../../../hooks/useEffectWithPrevDeps'; +import useRunDebounced from '../../../../hooks/useRunDebounced'; -// Used to avoid running debounced callbacks when chat changes. -let currentChatId: string | undefined; -let currentThreadId: number | undefined; +let isFrozen = false; + +function freeze() { + isFrozen = true; + requestAnimationFrame(() => { + isFrozen = false; + }); +} const useDraft = ( draft: ApiFormattedText | undefined, chatId: string, threadId: number, - htmlRef: { current: string }, + getHtml: Signal, setHtml: (html: string) => void, editedMessage: ApiMessage | undefined, lastSyncTime?: number, ) => { const { saveDraft, clearDraft, loadCustomEmojis } = getActions(); - const prevDraft = usePrevious(draft); - const updateDraft = useCallback((draftChatId: string, draftThreadId: number) => { - const currentHtml = htmlRef.current; - if (currentHtml === undefined || editedMessage || !lastSyncTime) return; - if (currentHtml.length) { - saveDraft({ chatId: draftChatId, threadId: draftThreadId, draft: parseMessageInput(currentHtml!) }); + const isEditing = Boolean(editedMessage); + + const updateDraft = useCallback((prevState: { chatId?: string; threadId?: number } = {}) => { + if (isEditing || !lastSyncTime) return; + + const html = getHtml(); + + if (html) { + saveDraft({ + chatId: prevState.chatId ?? chatId, + threadId: prevState.threadId ?? threadId, + draft: parseMessageInput(html), + }); } else { - clearDraft({ chatId: draftChatId, threadId: draftThreadId }); + clearDraft({ + chatId: prevState.chatId ?? chatId, + threadId: prevState.threadId ?? threadId, + }); } - }, [clearDraft, editedMessage, htmlRef, lastSyncTime, saveDraft]); + }, [chatId, threadId, isEditing, lastSyncTime, getHtml, saveDraft, clearDraft]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const runDebouncedForSaveDraft = useMemo(() => debounce((cb) => cb(), DRAFT_DEBOUNCE, false), [chatId]); - - const prevChatId = usePrevious(chatId); - const prevThreadId = usePrevious(threadId); - - // Save draft on chat change - useEffect(() => { - currentChatId = chatId; - currentThreadId = threadId; - - return () => { - currentChatId = undefined; - currentThreadId = undefined; - - updateDraft(chatId, threadId); - }; - }, [chatId, threadId, updateDraft]); + const updateDraftRef = useStateRef(updateDraft); + const runDebouncedForSaveDraft = useRunDebounced(DRAFT_DEBOUNCE, true, undefined, [chatId, threadId]); // Restore draft on chat change - useEffect(() => { + useEffectWithPrevDeps(([prevChatId, prevThreadId, prevDraft]) => { if (chatId === prevChatId && threadId === prevThreadId) { if (!draft && prevDraft) { setHtml(''); } + return; } @@ -87,39 +89,49 @@ const useDraft = ( } }); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - chatId, threadId, draft, setHtml, updateDraft, prevChatId, prevThreadId, editedMessage, prevDraft, loadCustomEmojis, - ]); + chatId, threadId, draft, setHtml, editedMessage, loadCustomEmojis, + ] as const); - const html = htmlRef.current; - // Update draft when input changes - const prevHtml = usePrevious(html); + // Save draft on chat change useEffect(() => { - if (!chatId || !threadId || prevChatId !== chatId || prevThreadId !== threadId || prevHtml === html) { + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + if (!isEditing) { + // eslint-disable-next-line react-hooks/exhaustive-deps + updateDraftRef.current({ chatId, threadId }); + } + + freeze(); + }; + }, [chatId, threadId, isEditing, updateDraftRef]); + + const chatIdRef = useStateRef(chatId); + const threadIdRef = useStateRef(threadId); + useEffect(() => { + if (isFrozen) { return; } - if (html.length) { - runDebouncedForSaveDraft(() => { - if (currentChatId !== chatId || currentThreadId !== threadId) { - return; - } + if (!getHtml()) { + updateDraftRef.current(); - updateDraft(chatId, threadId); - }); - } else { - updateDraft(chatId, threadId); + return; } - }, [chatId, html, prevChatId, prevHtml, prevThreadId, runDebouncedForSaveDraft, threadId, updateDraft]); - const handleBlur = useCallback(() => { - if (chatId && threadId) { - updateDraft(chatId, threadId); - } - }, [chatId, threadId, updateDraft]); + const scopedShatId = chatIdRef.current; + const scopedThreadId = threadIdRef.current; - useBackgroundMode(handleBlur); - useBeforeUnload(handleBlur); + runDebouncedForSaveDraft(() => { + if (chatIdRef.current === scopedShatId && threadIdRef.current === scopedThreadId) { + updateDraftRef.current(); + } + }); + }, [chatIdRef, getHtml, runDebouncedForSaveDraft, threadIdRef, updateDraftRef]); + + useBackgroundMode(updateDraft); + useBeforeUnload(updateDraft); }; export default useDraft; diff --git a/src/components/middle/composer/hooks/useEditing.ts b/src/components/middle/composer/hooks/useEditing.ts index b5ad9652e..13f78c93e 100644 --- a/src/components/middle/composer/hooks/useEditing.ts +++ b/src/components/middle/composer/hooks/useEditing.ts @@ -3,6 +3,7 @@ import { getActions } from '../../../../global'; import type { ApiFormattedText, ApiMessage } from '../../../../api/types'; import type { MessageListType } from '../../../../global/types'; +import type { Signal } from '../../../../util/signals'; import useEffectWithPrevDeps from '../../../../hooks/useEffectWithPrevDeps'; import { EDITABLE_INPUT_CSS_SELECTOR } from '../../../../config'; @@ -15,7 +16,7 @@ import useBackgroundMode from '../../../../hooks/useBackgroundMode'; import useBeforeUnload from '../../../../hooks/useBeforeUnload'; const useEditing = ( - htmlRef: { current: string }, + getHtml: Signal, setHtml: (html: string) => void, editedMessage: ApiMessage | undefined, resetComposer: (shouldPreserveInput?: boolean) => void, @@ -47,6 +48,7 @@ const useEditing = ( const text = !prevEditedMessage && editingDraft?.text.length ? editingDraft : editedMessage.content.text; const html = getTextWithEntitiesAsHtml(text); + setHtml(html); setShouldForceShowEditing(true); // `fastRaf` would execute syncronously in this case @@ -62,14 +64,14 @@ const useEditing = ( useEffect(() => { if (!editedMessage) return undefined; return () => { - // eslint-disable-next-line react-hooks/exhaustive-deps - const edited = parseMessageInput(htmlRef.current!); + const edited = parseMessageInput(getHtml()); const update = edited.text.length ? edited : undefined; + setEditingDraft({ chatId, threadId, type, text: update, }); }; - }, [chatId, editedMessage, htmlRef, setEditingDraft, threadId, type]); + }, [chatId, editedMessage, getHtml, setEditingDraft, threadId, type]); const restoreNewDraftAfterEditing = useCallback(() => { if (!draft) return; @@ -91,7 +93,7 @@ const useEditing = ( }, [resetComposer, restoreNewDraftAfterEditing]); const handleEditComplete = useCallback(() => { - const { text, entities } = parseMessageInput(htmlRef.current!); + const { text, entities } = parseMessageInput(getHtml()); if (!editedMessage) { return; @@ -109,16 +111,17 @@ const useEditing = ( resetComposer(); restoreNewDraftAfterEditing(); - }, [editMessage, editedMessage, htmlRef, openDeleteModal, resetComposer, restoreNewDraftAfterEditing]); + }, [editMessage, editedMessage, getHtml, openDeleteModal, resetComposer, restoreNewDraftAfterEditing]); const handleBlur = useCallback(() => { if (!editedMessage) return; - const edited = parseMessageInput(htmlRef.current!); + const edited = parseMessageInput(getHtml()); const update = edited.text.length ? edited : undefined; + setEditingDraft({ chatId, threadId, type, text: update, }); - }, [chatId, editedMessage, htmlRef, setEditingDraft, threadId, type]); + }, [chatId, editedMessage, getHtml, setEditingDraft, threadId, type]); useBackgroundMode(handleBlur); useBeforeUnload(handleBlur); diff --git a/src/components/middle/composer/hooks/useEmojiTooltip.ts b/src/components/middle/composer/hooks/useEmojiTooltip.ts index 064fee334..2d96fa601 100644 --- a/src/components/middle/composer/hooks/useEmojiTooltip.ts +++ b/src/components/middle/composer/hooks/useEmojiTooltip.ts @@ -1,10 +1,10 @@ -import { - useCallback, useEffect, useState, -} from '../../../../lib/teact/teact'; +import { useCallback, useEffect, useState } from '../../../../lib/teact/teact'; import { getGlobal } from '../../../../global'; import type { ApiSticker } from '../../../../api/types'; import type { EmojiData, EmojiModule, EmojiRawData } from '../../../../util/emoji'; +import { uncompressEmoji } from '../../../../util/emoji'; +import type { Signal } from '../../../../util/signals'; import { EDITABLE_INPUT_CSS_SELECTOR, EDITABLE_INPUT_ID } from '../../../../config'; import { @@ -12,7 +12,6 @@ import { } from '../../../../util/iteratees'; import { MEMO_EMPTY_ARRAY } from '../../../../util/memo'; import { prepareForRegExp } from '../helpers/prepareForRegExp'; -import { uncompressEmoji } from '../../../../util/emoji'; import focusEditableElement from '../../../../util/focusEditableElement'; import memoized from '../../../../util/memoized'; import renderText from '../../../common/helpers/renderText'; @@ -20,7 +19,8 @@ import { selectCustomEmojiForEmojis } from '../../../../global/selectors'; import { buildCustomEmojiHtml } from '../helpers/customEmoji'; import useFlag from '../../../../hooks/useFlag'; -import useDebouncedCallback from '../../../../hooks/useDebouncedCallback'; +import useDerivedSignal from '../../../../hooks/useDerivedSignal'; +import { useThrottledResolver } from '../../../../hooks/useAsyncResolvers'; interface Library { keywords: string[]; @@ -37,7 +37,7 @@ let RE_EMOJI_SEARCH: RegExp; const EMOJIS_LIMIT = 36; const FILTER_MIN_LENGTH = 2; -const DEBOUNCE = 300; +const THROTTLE = 300; const prepareRecentEmojisMemo = memoized(prepareRecentEmojis); const prepareLibraryMemo = memoized(prepareLibrary); @@ -51,69 +51,94 @@ try { } export default function useEmojiTooltip( - isAllowed: boolean, - htmlRef: { current: string }, - recentEmojiIds: string[], + isEnabled: boolean, + getHtml: Signal, + setHtml: (html: string) => void, inputId = EDITABLE_INPUT_ID, - onUpdateHtml: (html: string) => void, + recentEmojiIds: string[], baseEmojiKeywords?: Record, emojiKeywords?: Record, - isDisabled = false, ) { - const [isOpen, markIsOpen, unmarkIsOpen] = useFlag(); + const [isManuallyClosed, markManuallyClosed, unmarkManuallyClosed] = useFlag(false); + const [byId, setById] = useState | undefined>(); - const [shouldForceInsertEmoji, setShouldForceInsertEmoji] = useState(false); - const [filteredEmojis, setFilteredEmojisInner] = useState(MEMO_EMPTY_ARRAY); + const [filteredEmojis, setFilteredEmojis] = useState(MEMO_EMPTY_ARRAY); const [filteredCustomEmojis, setFilteredCustomEmojis] = useState(MEMO_EMPTY_ARRAY); - const setFilteredEmojis = useDebouncedCallback((emojis: Emoji[]) => { - setFilteredEmojisInner(emojis); - }, [], DEBOUNCE); - - // Initialize data on first render. + // Initialize data on first render useEffect(() => { - if (isDisabled) return; - const exec = () => { + if (!isEnabled) return; + + function exec() { setById(emojiData.emojis); - }; + } if (emojiData) { exec(); } else { - ensureEmojiData() - .then(exec); + ensureEmojiData().then(exec); } - }, [isDisabled]); + }, [isEnabled]); - const html = htmlRef.current; - useEffect(() => { - if (isDisabled) return; + const detectEmojiCodeThrottled = useThrottledResolver(() => { + const html = getHtml(); + return isEnabled && html.includes(':') ? prepareForRegExp(html).match(RE_EMOJI_SEARCH)?.[0].trim() : undefined; + }, [getHtml, isEnabled], THROTTLE); + + const getEmojiCode = useDerivedSignal( + detectEmojiCodeThrottled, [detectEmojiCodeThrottled, getHtml], true, + ); + + const updateFiltered = useCallback((emojis: Emoji[]) => { + setFilteredEmojis(emojis); + + if (emojis === MEMO_EMPTY_ARRAY) { + setFilteredCustomEmojis(MEMO_EMPTY_ARRAY); + return; + } + + const nativeEmojis = emojis.map((emoji) => emoji.native); const customEmojis = uniqueByField( - selectCustomEmojiForEmojis(getGlobal(), filteredEmojis.map((emoji) => emoji.native)), + selectCustomEmojiForEmojis(getGlobal(), nativeEmojis), 'id', ); setFilteredCustomEmojis(customEmojis); - }, [filteredEmojis, isDisabled]); + }, []); + + const insertEmoji = useCallback((emoji: string | ApiSticker, isForce = false) => { + const html = getHtml(); + if (!html) return; + + const atIndex = html.lastIndexOf(':', isForce ? html.lastIndexOf(':') - 1 : undefined); + + if (atIndex !== -1) { + const emojiHtml = typeof emoji === 'string' ? renderText(emoji, ['emoji_html']) : buildCustomEmojiHtml(emoji); + setHtml(`${html.substring(0, atIndex)}${emojiHtml}`); + + const messageInput = inputId === EDITABLE_INPUT_ID + ? document.querySelector(EDITABLE_INPUT_CSS_SELECTOR)! + : document.getElementById(inputId) as HTMLDivElement; + + requestAnimationFrame(() => { + focusEditableElement(messageInput, true, true); + }); + } + + updateFiltered(MEMO_EMPTY_ARRAY); + }, [getHtml, setHtml, inputId, updateFiltered]); useEffect(() => { - if (!isAllowed || !html || !byId || isDisabled) { - unmarkIsOpen(); + const emojiCode = getEmojiCode(); + if (!emojiCode || !byId) { + updateFiltered(MEMO_EMPTY_ARRAY); return; } - const code = html.includes(':') && getEmojiCode(html); - if (!code) { - setFilteredEmojis(MEMO_EMPTY_ARRAY); - unmarkIsOpen(); - return; - } + const newShouldAutoInsert = emojiCode.length > 2 && emojiCode.endsWith(':'); - const forceSend = code.length > 2 && code.endsWith(':'); - const filter = code.substr(1, forceSend ? code.length - 2 : undefined); + const filter = emojiCode.substring(1, newShouldAutoInsert ? 1 + emojiCode.length - 2 : undefined); let matched: Emoji[] = MEMO_EMPTY_ARRAY; - setShouldForceInsertEmoji(forceSend); - if (!filter) { matched = prepareRecentEmojisMemo(byId, recentEmojiIds, EMOJIS_LIMIT); } else if (filter.length >= FILTER_MIN_LENGTH) { @@ -121,79 +146,30 @@ export default function useEmojiTooltip( matched = searchInLibraryMemo(library, filter, EMOJIS_LIMIT); } - if (matched.length) { - if (!forceSend) { - markIsOpen(); - } - setFilteredEmojis(matched); + if (!matched.length) { + return; + } + + if (newShouldAutoInsert) { + insertEmoji(matched[0].native, true); } else { - unmarkIsOpen(); + updateFiltered(matched); } }, [ - byId, html, isAllowed, markIsOpen, recentEmojiIds, unmarkIsOpen, setShouldForceInsertEmoji, - isDisabled, baseEmojiKeywords, emojiKeywords, setFilteredEmojis, + baseEmojiKeywords, byId, getEmojiCode, emojiKeywords, insertEmoji, recentEmojiIds, updateFiltered, ]); - const insertEmoji = useCallback((textEmoji: string, isForce?: boolean) => { - const currentHtml = htmlRef.current; - const atIndex = currentHtml.lastIndexOf(':', isForce ? currentHtml.lastIndexOf(':') - 1 : undefined); - if (atIndex !== -1) { - onUpdateHtml(`${currentHtml.substr(0, atIndex)}${renderText(textEmoji, ['emoji_html'])}`); - let messageInput: HTMLDivElement; - if (inputId === EDITABLE_INPUT_ID) { - messageInput = document.querySelector(EDITABLE_INPUT_CSS_SELECTOR)!; - } else { - messageInput = document.getElementById(inputId) as HTMLDivElement; - } - requestAnimationFrame(() => { - focusEditableElement(messageInput, true, true); - }); - } - - unmarkIsOpen(); - }, [htmlRef, inputId, onUpdateHtml, unmarkIsOpen]); - - const insertCustomEmoji = useCallback((emoji: ApiSticker, isForce?: boolean) => { - const currentHtml = htmlRef.current; - const atIndex = currentHtml.lastIndexOf(':', isForce ? currentHtml.lastIndexOf(':') - 1 : undefined); - if (atIndex !== -1) { - onUpdateHtml(`${currentHtml.substr(0, atIndex)}${buildCustomEmojiHtml(emoji)}`); - let messageInput: HTMLDivElement; - if (inputId === EDITABLE_INPUT_ID) { - messageInput = document.querySelector(EDITABLE_INPUT_CSS_SELECTOR)!; - } else { - messageInput = document.getElementById(inputId) as HTMLDivElement; - } - requestAnimationFrame(() => { - focusEditableElement(messageInput, true, true); - }); - } - - unmarkIsOpen(); - }, [htmlRef, inputId, onUpdateHtml, unmarkIsOpen]); - - useEffect(() => { - if (isOpen && shouldForceInsertEmoji && filteredEmojis.length) { - insertEmoji(filteredEmojis[0].native, true); - } - }, [filteredEmojis, insertEmoji, isOpen, shouldForceInsertEmoji]); + useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]); return { - isEmojiTooltipOpen: isOpen, - closeEmojiTooltip: unmarkIsOpen, + isEmojiTooltipOpen: Boolean(filteredEmojis.length || filteredCustomEmojis.length) && !isManuallyClosed, + closeEmojiTooltip: markManuallyClosed, filteredEmojis, filteredCustomEmojis, insertEmoji, - insertCustomEmoji, }; } -function getEmojiCode(html: string) { - const emojis = prepareForRegExp(html).match(RE_EMOJI_SEARCH); - - return emojis ? emojis[0].trim() : undefined; -} - async function ensureEmojiData() { if (!emojiDataPromise) { emojiDataPromise = import('emoji-data-ios/emoji-data.json') as unknown as Promise; diff --git a/src/components/middle/composer/hooks/useInlineBotTooltip.ts b/src/components/middle/composer/hooks/useInlineBotTooltip.ts index a34557c2b..b28d91abe 100644 --- a/src/components/middle/composer/hooks/useInlineBotTooltip.ts +++ b/src/components/middle/composer/hooks/useInlineBotTooltip.ts @@ -1,11 +1,17 @@ import { useCallback, useEffect } from '../../../../lib/teact/teact'; -import { getActions } from '../../../../global'; -import type { InlineBotSettings } from '../../../../types'; -import useFlag from '../../../../hooks/useFlag'; -import usePrevious from '../../../../hooks/usePrevious'; -import useDebouncedMemo from '../../../../hooks/useDebouncedMemo'; -const DEBOUNCE_MS = 300; +import type { InlineBotSettings } from '../../../../types'; +import type { Signal } from '../../../../util/signals'; + +import { getActions } from '../../../../global'; +import memoized from '../../../../util/memoized'; + +import useFlag from '../../../../hooks/useFlag'; +import useDerivedState from '../../../../hooks/useDerivedState'; +import useSyncEffect from '../../../../hooks/useSyncEffect'; +import { useThrottledResolver } from '../../../../hooks/useAsyncResolvers'; + +const THROTTLE = 300; const INLINE_BOT_QUERY_REGEXP = /^@([a-z0-9_]{1,32})[\u00A0\u0020]+(.*)/i; const HAS_NEW_LINE = /^@([a-z0-9_]{1,32})[\u00A0\u0020]+\n{2,}/i; const MEMO_NO_RESULT = { @@ -18,20 +24,40 @@ const MEMO_NO_RESULT = { const tempEl = document.createElement('div'); export default function useInlineBotTooltip( - isAllowed: boolean, + isEnabled: boolean, chatId: string, - html: string, + getHtml: Signal, inlineBots?: Record, ) { const { queryInlineBot, resetInlineBot, resetAllInlineBots } = getActions(); - const [isOpen, markIsOpen, unmarkIsOpen] = useFlag(); + const [isManuallyClosed, markManuallyClosed, unmarkManuallyClosed] = useFlag(false); + + const extractBotQueryThrottled = useThrottledResolver(() => { + const html = getHtml(); + return isEnabled && html.startsWith('@') ? parseBotQuery(html) : MEMO_NO_RESULT; + }, [getHtml, isEnabled], THROTTLE); const { username, query, canShowHelp, usernameLowered, - } = useDebouncedMemo(() => parseBotQuery(html), DEBOUNCE_MS, [html]) || {}; - const prevQuery = usePrevious(query); - const prevUsername = usePrevious(username); - const inlineBotData = usernameLowered ? inlineBots?.[usernameLowered] : undefined; + } = useDerivedState(extractBotQueryThrottled, [extractBotQueryThrottled, getHtml], true); + + useSyncEffect(([prevUsername]) => { + if (prevUsername) { + resetInlineBot({ username: prevUsername }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [username, resetInlineBot] as const); + + useEffect(() => { + if (!usernameLowered) return; + + queryInlineBot({ + chatId, username: usernameLowered, query, + }); + }, [chatId, query, queryInlineBot, usernameLowered]); + + useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]); + const { id: botId, switchPm, @@ -39,13 +65,9 @@ export default function useInlineBotTooltip( results, isGallery, help, - } = inlineBotData || {}; + } = (usernameLowered && inlineBots?.[usernameLowered]) || {}; - useEffect(() => { - if (prevQuery !== query) { - unmarkIsOpen(); - } - }, [prevQuery, query, unmarkIsOpen]); + const isOpen = Boolean((results?.length || switchPm) && !isManuallyClosed); useEffect(() => { if (!isOpen && !username) { @@ -53,44 +75,33 @@ export default function useInlineBotTooltip( } }, [isOpen, resetAllInlineBots, username]); - useEffect(() => { - if (isAllowed && usernameLowered && chatId) { - queryInlineBot({ chatId, username: usernameLowered, query: query! }); - } - }, [query, isAllowed, queryInlineBot, chatId, usernameLowered]); - const loadMore = useCallback(() => { - if (isAllowed && usernameLowered && chatId) { - queryInlineBot({ - chatId, username: usernameLowered, query: query!, offset, - }); - } - }, [isAllowed, usernameLowered, chatId, queryInlineBot, query, offset]); + if (!usernameLowered) return; - useEffect(() => { - if (isAllowed && botId && (switchPm || (results?.length))) { - markIsOpen(); - } else { - unmarkIsOpen(); - } - }, [botId, isAllowed, markIsOpen, results, switchPm, unmarkIsOpen]); - - if (prevUsername !== username) { - resetInlineBot({ username: prevUsername! }); - } + queryInlineBot({ + chatId, username: usernameLowered, query, offset, + }); + }, [chatId, offset, query, queryInlineBot, usernameLowered]); return { isOpen, - id: botId, + botId, isGallery, switchPm, results, - closeTooltip: unmarkIsOpen, + closeTooltip: markManuallyClosed, help: canShowHelp && help ? `@${username} ${help}` : undefined, loadMore, }; } +const buildQueryStateMemo = memoized((username: string, query: string, canShowHelp: boolean) => ({ + username, + query, + canShowHelp, + usernameLowered: username.toLowerCase(), +})); + function parseBotQuery(html: string) { if (!html.startsWith('@')) { return MEMO_NO_RESULT; @@ -102,12 +113,7 @@ function parseBotQuery(html: string) { return MEMO_NO_RESULT; } - return { - username: result[1], - query: result[2], - canShowHelp: result[2] === '' && !text.match(HAS_NEW_LINE), - usernameLowered: result[1].toLowerCase(), - }; + return buildQueryStateMemo(result[1], result[2], result[2] === '' && !text.match(HAS_NEW_LINE)); } function getPlainText(html: string) { diff --git a/src/components/middle/composer/hooks/useInputCustomEmojis.ts b/src/components/middle/composer/hooks/useInputCustomEmojis.ts index 60a3599f6..763fa92ef 100644 --- a/src/components/middle/composer/hooks/useInputCustomEmojis.ts +++ b/src/components/middle/composer/hooks/useInputCustomEmojis.ts @@ -4,6 +4,7 @@ import { import RLottie from '../../../../lib/rlottie/RLottie'; import type { ApiSticker } from '../../../../api/types'; +import type { Signal } from '../../../../util/signals'; import { getGlobal } from '../../../../global'; import { selectIsAlwaysHighPriorityEmoji } from '../../../../global/selectors'; @@ -31,7 +32,7 @@ type CustomEmojiPlayer = { }; export default function useInputCustomEmojis( - html: string, + getHtml: Signal, inputRef: React.RefObject, sharedCanvasRef: React.RefObject, sharedCanvasHqRef: React.RefObject, @@ -115,13 +116,13 @@ export default function useInputCustomEmojis( }, [synchronizeElements]); useEffect(() => { - if (!html || !inputRef.current || !sharedCanvasRef.current) { + if (!getHtml() || !inputRef.current || !sharedCanvasRef.current) { removeContainers(Array.from(mapRef.current.keys())); return; } synchronizeElements(); - }, [html, inputRef, removeContainers, sharedCanvasRef, synchronizeElements]); + }, [getHtml, synchronizeElements, inputRef, removeContainers, sharedCanvasRef]); useResizeObserver(sharedCanvasRef, synchronizeElements, true); @@ -157,7 +158,7 @@ function createPlayer({ position, isHq, isMobile, -} : { +}: { customEmoji: ApiSticker; sharedCanvasRef: React.RefObject; sharedCanvasHqRef: React.RefObject; diff --git a/src/components/middle/composer/hooks/useMentionTooltip.ts b/src/components/middle/composer/hooks/useMentionTooltip.ts index bd1af7e3e..8e8d79368 100644 --- a/src/components/middle/composer/hooks/useMentionTooltip.ts +++ b/src/components/middle/composer/hooks/useMentionTooltip.ts @@ -1,109 +1,94 @@ +import type { RefObject } from 'react'; import { useCallback, useEffect, useState, } from '../../../../lib/teact/teact'; import { getGlobal } from '../../../../global'; import type { ApiChatMember, ApiUser } from '../../../../api/types'; -import { ApiMessageEntityTypes } from '../../../../api/types'; +import type { Signal } from '../../../../util/signals'; +import { ApiMessageEntityTypes } from '../../../../api/types'; import { filterUsersByName, getMainUsername, getUserFirstOrLastName } from '../../../../global/helpers'; import { prepareForRegExp } from '../helpers/prepareForRegExp'; import focusEditableElement from '../../../../util/focusEditableElement'; import { pickTruthy, unique } from '../../../../util/iteratees'; -import { throttle } from '../../../../util/schedulers'; import { getHtmlBeforeSelection } from '../../../../util/selection'; import useFlag from '../../../../hooks/useFlag'; -import useCacheBuster from '../../../../hooks/useCacheBuster'; -import useOnSelectionChange from '../../../../hooks/useOnSelectionChange'; +import useDerivedSignal from '../../../../hooks/useDerivedSignal'; +import { useThrottledResolver } from '../../../../hooks/useAsyncResolvers'; + +const THROTTLE = 300; -const runThrottled = throttle((cb) => cb(), 500, true); let RE_USERNAME_SEARCH: RegExp; - try { RE_USERNAME_SEARCH = /(^|\s)@[-_\p{L}\p{M}\p{N}]*$/gui; } catch (e) { - // Support for older versions of firefox + // Support for older versions of Firefox RE_USERNAME_SEARCH = /(^|\s)@[-_\d\wа-яё]*$/gi; } export default function useMentionTooltip( - canSuggestMembers: boolean | undefined, - inputSelector: string, - onUpdateHtml: (html: string) => void, + isEnabled: boolean, + getHtml: Signal, + setHtml: (html: string) => void, + getSelectionRange: Signal, + inputRef: RefObject, groupChatMembers?: ApiChatMember[], topInlineBotIds?: string[], currentUserId?: string, ) { - const [isOpen, markIsOpen, unmarkIsOpen] = useFlag(); - const [htmlBeforeSelection, setHtmlBeforeSelection] = useState(''); - const [usersToMention, setUsersToMention] = useState(); + const [filteredUsers, setFilteredUsers] = useState(); + const [isManuallyClosed, markManuallyClosed, unmarkManuallyClosed] = useFlag(false); + + const extractUsernameTagThrottled = useThrottledResolver(() => { + const html = getHtml(); + if (!isEnabled || !getSelectionRange()?.collapsed || !html.includes('@')) return undefined; + + const htmlBeforeSelection = getHtmlBeforeSelection(inputRef.current!); + + return prepareForRegExp(htmlBeforeSelection).match(RE_USERNAME_SEARCH)?.[0].trim(); + }, [isEnabled, getHtml, getSelectionRange, inputRef], THROTTLE); + + const getUsernameTag = useDerivedSignal( + extractUsernameTagThrottled, [extractUsernameTagThrottled, getHtml, getSelectionRange], true, + ); + + const getWithInlineBots = useDerivedSignal(() => { + return isEnabled && getHtml().startsWith('@'); + }, [getHtml, isEnabled]); + + useEffect(() => { + const usernameTag = getUsernameTag(); + + if (!usernameTag || !(groupChatMembers || topInlineBotIds)) { + setFilteredUsers(undefined); + return; + } - const updateFilteredUsers = useCallback((filter, withInlineBots: boolean) => { // No need for expensive global updates on users, so we avoid them const usersById = getGlobal().users.byId; - - if (!(groupChatMembers || topInlineBotIds) || !usersById) { - setUsersToMention(undefined); - + if (!usersById) { + setFilteredUsers(undefined); return; } - runThrottled(() => { - const memberIds = groupChatMembers?.reduce((acc: string[], member) => { - if (member.userId !== currentUserId) { - acc.push(member.userId); - } + const memberIds = groupChatMembers?.reduce((acc: string[], member) => { + if (member.userId !== currentUserId) { + acc.push(member.userId); + } - return acc; - }, []); + return acc; + }, []); - const filteredIds = filterUsersByName(unique([ - ...((withInlineBots && topInlineBotIds) || []), - ...(memberIds || []), - ]), usersById, filter); + const filter = usernameTag.substring(1); + const filteredIds = filterUsersByName(unique([ + ...((getWithInlineBots() && topInlineBotIds) || []), + ...(memberIds || []), + ]), usersById, filter); - setUsersToMention(Object.values(pickTruthy(usersById, filteredIds))); - }); - }, [currentUserId, groupChatMembers, topInlineBotIds]); - - const [cacheBuster, updateCacheBuster] = useCacheBuster(); - - const handleSelectionChange = useCallback((range: Range) => { - if (range.collapsed) { - updateCacheBuster(); // Update tooltip on cursor move - } - }, [updateCacheBuster]); - - useOnSelectionChange(inputSelector, handleSelectionChange); - - useEffect(() => { - setHtmlBeforeSelection(getHtmlBeforeSelection(document.querySelector(inputSelector)!)); - }, [inputSelector, cacheBuster]); - - useEffect(() => { - if (!canSuggestMembers || !htmlBeforeSelection.length) { - unmarkIsOpen(); - return; - } - - const usernameFilter = htmlBeforeSelection.includes('@') && getUsernameFilter(htmlBeforeSelection); - - if (usernameFilter) { - const filter = usernameFilter ? usernameFilter.substr(1) : ''; - updateFilteredUsers(filter, canSuggestInlineBots(htmlBeforeSelection)); - } else { - unmarkIsOpen(); - } - }, [canSuggestMembers, updateFilteredUsers, markIsOpen, unmarkIsOpen, htmlBeforeSelection]); - - useEffect(() => { - if (usersToMention?.length) { - markIsOpen(); - } else { - unmarkIsOpen(); - } - }, [markIsOpen, unmarkIsOpen, usersToMention]); + setFilteredUsers(Object.values(pickTruthy(usersById, filteredIds))); + }, [currentUserId, groupChatMembers, topInlineBotIds, getUsernameTag, getWithInlineBots]); const insertMention = useCallback((user: ApiUser, forceFocus = false) => { if (!user.usernames && !getUserFirstOrLastName(user)) { @@ -111,7 +96,7 @@ export default function useMentionTooltip( } const mainUsername = getMainUsername(user); - const insertedHtml = mainUsername + const htmlToInsert = mainUsername ? `@${mainUsername}` : `${getUserFirstOrLastName(user)}`; - const containerEl = document.querySelector(inputSelector)!; + const inputEl = inputRef.current!; + const htmlBeforeSelection = getHtmlBeforeSelection(inputEl); const fixedHtmlBeforeSelection = cleanWebkitNewLines(htmlBeforeSelection); - const atIndex = fixedHtmlBeforeSelection.lastIndexOf('@'); + if (atIndex !== -1) { - const newHtml = `${fixedHtmlBeforeSelection.substr(0, atIndex)}${insertedHtml} `; - const htmlAfterSelection = cleanWebkitNewLines(containerEl.innerHTML).substring(fixedHtmlBeforeSelection.length); - onUpdateHtml(`${newHtml}${htmlAfterSelection}`); + const newHtml = `${fixedHtmlBeforeSelection.substr(0, atIndex)}${htmlToInsert} `; + const htmlAfterSelection = cleanWebkitNewLines(inputEl.innerHTML).substring(fixedHtmlBeforeSelection.length); + + setHtml(`${newHtml}${htmlAfterSelection}`); requestAnimationFrame(() => { - focusEditableElement(containerEl, forceFocus); + focusEditableElement(inputEl, forceFocus); }); } - unmarkIsOpen(); - }, [htmlBeforeSelection, inputSelector, onUpdateHtml, unmarkIsOpen]); + setFilteredUsers(undefined); + }, [inputRef, setHtml]); + + useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]); return { - isMentionTooltipOpen: isOpen, - closeMentionTooltip: unmarkIsOpen, + isMentionTooltipOpen: Boolean(filteredUsers?.length && !isManuallyClosed), + closeMentionTooltip: markManuallyClosed, insertMention, - mentionFilteredUsers: usersToMention, + mentionFilteredUsers: filteredUsers, }; } -function getUsernameFilter(html: string) { - const username = prepareForRegExp(html).match(RE_USERNAME_SEARCH); - - return username ? username[0].trim() : undefined; -} - // Webkit replaces the line break with the `

` or `
` code. // It is necessary to clean the html to a single form before processing. function cleanWebkitNewLines(html: string) { return html.replace(/
(
|)?<\/div>/gi, '
'); } - -function canSuggestInlineBots(html: string) { - return html.startsWith('@'); -} diff --git a/src/components/middle/composer/hooks/useStickerTooltip.ts b/src/components/middle/composer/hooks/useStickerTooltip.ts index d38449826..dc1e63232 100644 --- a/src/components/middle/composer/hooks/useStickerTooltip.ts +++ b/src/components/middle/composer/hooks/useStickerTooltip.ts @@ -1,45 +1,69 @@ -import { useEffect, useMemo } from '../../../../lib/teact/teact'; -import { getActions } from '../../../../global'; +import { useEffect } from '../../../../lib/teact/teact'; import type { ApiSticker } from '../../../../api/types'; +import type { Signal } from '../../../../util/signals'; +import { getActions } from '../../../../global'; import { EMOJI_IMG_REGEX } from '../../../../config'; import { IS_EMOJI_SUPPORTED } from '../../../../util/environment'; import parseEmojiOnlyString from '../../../../util/parseEmojiOnlyString'; +import twemojiRegex from '../../../../lib/twemojiRegex'; import { prepareForRegExp } from '../helpers/prepareForRegExp'; +import useDerivedState from '../../../../hooks/useDerivedState'; +import useFlag from '../../../../hooks/useFlag'; +import useDerivedSignal from '../../../../hooks/useDerivedSignal'; + +const MAX_LENGTH = 8; const STARTS_ENDS_ON_EMOJI_IMG_REGEX = new RegExp(`^${EMOJI_IMG_REGEX.source}$`, 'g'); export default function useStickerTooltip( - isAllowed: boolean, - html: string, + isEnabled: boolean, + getHtml: Signal, stickers?: ApiSticker[], - isDisabled = false, ) { - const cleanHtml = useMemo(() => prepareForRegExp(html).trim(), [html]); const { loadStickersForEmoji, clearStickersForEmoji } = getActions(); - const isSingleEmoji = ( - (IS_EMOJI_SUPPORTED && parseEmojiOnlyString(cleanHtml) === 1) - || (!IS_EMOJI_SUPPORTED && Boolean(html.match(STARTS_ENDS_ON_EMOJI_IMG_REGEX))) - ); - const hasStickers = Boolean(stickers?.length) && isSingleEmoji; + + const [isManuallyClosed, markManuallyClosed, unmarkManuallyClosed] = useFlag(false); + + const getSingleEmoji = useDerivedSignal(() => { + const html = getHtml(); + if (!isEnabled || !html || (IS_EMOJI_SUPPORTED && html.length > MAX_LENGTH)) return undefined; + + const hasEmoji = html.match(IS_EMOJI_SUPPORTED ? twemojiRegex : EMOJI_IMG_REGEX); + if (!hasEmoji) return undefined; + + const cleanHtml = prepareForRegExp(html); + const isSingleEmoji = cleanHtml && ( + (IS_EMOJI_SUPPORTED && parseEmojiOnlyString(cleanHtml) === 1) + || (!IS_EMOJI_SUPPORTED && Boolean(html.match(STARTS_ENDS_ON_EMOJI_IMG_REGEX))) + ); + + return isSingleEmoji + ? (IS_EMOJI_SUPPORTED ? cleanHtml : cleanHtml.match(/alt="(.+)"/)?.[1]!) + : undefined; + }, [getHtml, isEnabled]); + + const isActive = useDerivedState(() => Boolean(getSingleEmoji()), [getSingleEmoji]); + const hasStickers = Boolean(stickers?.length); useEffect(() => { - if (isDisabled) return; + if (!isEnabled) return; - if (isAllowed && isSingleEmoji) { - loadStickersForEmoji({ - emoji: IS_EMOJI_SUPPORTED ? cleanHtml : cleanHtml.match(/alt="(.+)"/)?.[1]!, - }); - } else if (hasStickers || !isSingleEmoji) { + const singleEmoji = getSingleEmoji(); + if (singleEmoji) { + if (!hasStickers) { + loadStickersForEmoji({ emoji: singleEmoji }); + } + } else { clearStickersForEmoji(); } - // We omit `hasStickers` here to prevent re-fetching after manually closing tooltip (via ). - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [html, isSingleEmoji, clearStickersForEmoji, loadStickersForEmoji, isAllowed, isDisabled]); + }, [isEnabled, getSingleEmoji, hasStickers, loadStickersForEmoji, clearStickersForEmoji]); + + useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]); return { - isStickerTooltipOpen: hasStickers, - closeStickerTooltip: clearStickersForEmoji, + isStickerTooltipOpen: Boolean(isActive && hasStickers && !isManuallyClosed), + closeStickerTooltip: markManuallyClosed, }; } diff --git a/src/hooks/useAsyncResolvers.ts b/src/hooks/useAsyncResolvers.ts new file mode 100644 index 000000000..42f76c944 --- /dev/null +++ b/src/hooks/useAsyncResolvers.ts @@ -0,0 +1,16 @@ +import useThrottledCallback from './useThrottledCallback'; +import useDebouncedCallback from './useDebouncedCallback'; + +export function useThrottledResolver(resolver: () => T, deps: any[], ms: number, noFirst = false) { + return useThrottledCallback((setValue: (newValue: T) => void) => { + setValue(resolver()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps, ms, noFirst); +} + +export function useDebouncedResolver(resolver: () => T, deps: any[], ms: number, noFirst = false, noLast = false) { + return useDebouncedCallback((setValue: (newValue: T) => void) => { + setValue(resolver()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps, ms, noFirst, noLast); +} diff --git a/src/hooks/useDebouncedCallback.ts b/src/hooks/useDebouncedCallback.ts index 517a00a99..ac9f94623 100644 --- a/src/hooks/useDebouncedCallback.ts +++ b/src/hooks/useDebouncedCallback.ts @@ -6,8 +6,8 @@ export default function useDebouncedCallback( fn: T, deps: any[], ms: number, - noFirst?: boolean, - noLast?: boolean, + noFirst = false, + noLast = false, ) { // eslint-disable-next-line react-hooks/exhaustive-deps const fnMemo = useCallback(fn, deps); diff --git a/src/hooks/useDerivedSignal.ts b/src/hooks/useDerivedSignal.ts new file mode 100644 index 000000000..c528ccc14 --- /dev/null +++ b/src/hooks/useDerivedSignal.ts @@ -0,0 +1,43 @@ +import type { Signal } from '../util/signals'; + +import useSyncEffect from './useSyncEffect'; +import useSignal from './useSignal'; +import { useStateRef } from './useStateRef'; +import { useSignalEffect } from './useSignalEffect'; + +type SyncResolver = () => T; +type AsyncResolver = (setter: (newValue: T) => void) => void; +type Resolver = + SyncResolver + | AsyncResolver; + +function useDerivedSignal(resolver: SyncResolver, dependencies: readonly any[]): Signal; +function useDerivedSignal(resolver: AsyncResolver, dependencies: readonly any[], isAsync: true): Signal; +function useDerivedSignal(dependency: T): Signal; + +function useDerivedSignal(resolverOrDependency: Resolver | T, dependencies?: readonly any[], isAsync = false) { + const resolver = dependencies ? resolverOrDependency as Resolver : () => (resolverOrDependency as T); + dependencies ??= [resolverOrDependency]; + + const [getValue, setValue] = useSignal(); + const resolverRef = useStateRef(resolver); + + function runCurrentResolver() { + const currentResolver = resolverRef.current; + if (isAsync) { + (currentResolver as AsyncResolver)(setValue); + } else { + setValue((currentResolver as SyncResolver)()); + } + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + useSyncEffect(runCurrentResolver, dependencies); + + // eslint-disable-next-line react-hooks/exhaustive-deps + useSignalEffect(runCurrentResolver, dependencies); + + return getValue as Signal; +} + +export default useDerivedSignal; diff --git a/src/hooks/useDerivedState.ts b/src/hooks/useDerivedState.ts new file mode 100644 index 000000000..3b1480284 --- /dev/null +++ b/src/hooks/useDerivedState.ts @@ -0,0 +1,60 @@ +import { useRef } from '../lib/teact/teact'; + +import type { Signal } from '../util/signals'; + +import useForceUpdate from './useForceUpdate'; +import useSyncEffect from './useSyncEffect'; +import { useStateRef } from './useStateRef'; +import { useSignalEffect } from './useSignalEffect'; + +type SyncResolver = () => T; +type AsyncResolver = (setter: (newValue: T) => void) => void; +type Resolver = + SyncResolver + | AsyncResolver; + +function useDerivedState(resolver: SyncResolver, dependencies: readonly any[]): T; +function useDerivedState(resolver: AsyncResolver, dependencies: readonly any[], isAsync: true): T; +function useDerivedState(signal: Signal): T; + +function useDerivedState(resolverOrSignal: Resolver | T, dependencies?: readonly any[], isAsync = false) { + const resolver = dependencies ? resolverOrSignal as Resolver : () => ((resolverOrSignal as Signal)()); + dependencies ??= [resolverOrSignal]; + + const valueRef = useRef(); + const forceUpdate = useForceUpdate(); + const resolverRef = useStateRef(resolver); + + function runCurrentResolver(isSync = false) { + const currentResolver = resolverRef.current; + if (isAsync) { + (currentResolver as AsyncResolver)((newValue) => { + if (valueRef.current !== newValue) { + valueRef.current = newValue; + forceUpdate(); + } + }); + } else { + const newValue = (currentResolver as SyncResolver)(); + if (valueRef.current !== newValue) { + valueRef.current = newValue; + + if (!isSync) { + forceUpdate(); + } + } + } + } + + useSyncEffect(() => { + runCurrentResolver(true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, dependencies); + + // eslint-disable-next-line react-hooks/exhaustive-deps + useSignalEffect(runCurrentResolver, dependencies); + + return valueRef.current as T; +} + +export default useDerivedState; diff --git a/src/hooks/useGetSelectionRange.ts b/src/hooks/useGetSelectionRange.ts new file mode 100644 index 000000000..bf5968522 --- /dev/null +++ b/src/hooks/useGetSelectionRange.ts @@ -0,0 +1,38 @@ +import { useEffect } from '../lib/teact/teact'; + +import useSignal from './useSignal'; + +export default function useGetSelectionRange(inputSelector: string) { + const [getRange, setRange] = useSignal(); + + useEffect(() => { + function onSelectionChange() { + const selection = window.getSelection(); + if (!selection?.rangeCount) return; + + const range = selection.getRangeAt(0); + if (!range) { + return; + } + + const inputEl = document.querySelector(inputSelector); + if (!inputEl) { + return; + } + + const { commonAncestorContainer } = range; + const element = commonAncestorContainer instanceof Element + ? commonAncestorContainer + : commonAncestorContainer.parentElement!; + if (element.closest(inputSelector)) { + setRange(range); + } + } + + document.addEventListener('selectionchange', onSelectionChange); + + return () => document.removeEventListener('selectionchange', onSelectionChange); + }, [inputSelector, setRange]); + + return getRange; +} diff --git a/src/hooks/useOnSelectionChange.ts b/src/hooks/useOnSelectionChange.ts deleted file mode 100644 index 0ede87432..000000000 --- a/src/hooks/useOnSelectionChange.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useEffect } from '../lib/teact/teact'; - -export default function useOnSelectionChange( - inputSelector: string, callback: (range: Range) => void, -) { - useEffect(() => { - function onSelectionChange() { - const selection = window.getSelection(); - if (!selection) return; - - const inputEl = document.querySelector(inputSelector); - if (!inputEl) { - return; - } - - for (let i = 0; i < selection.rangeCount; i++) { - const range = selection.getRangeAt(i); - const ancestor = range.commonAncestorContainer; - if (inputEl.contains(ancestor)) { - callback(range); - } - } - } - - document.addEventListener('selectionchange', onSelectionChange); - return () => document.removeEventListener('selectionchange', onSelectionChange); - }, [callback, inputSelector]); -} diff --git a/src/hooks/useRunDebounced.ts b/src/hooks/useRunDebounced.ts index 432c464b2..7cdcfb7d4 100644 --- a/src/hooks/useRunDebounced.ts +++ b/src/hooks/useRunDebounced.ts @@ -1,7 +1,8 @@ import useDebouncedCallback from './useDebouncedCallback'; -export default function useRunDebounced(ms: number, noFirst?: boolean, noLast?: boolean) { +export default function useRunDebounced(ms: number, noFirst?: boolean, noLast?: boolean, deps: any = []) { return useDebouncedCallback((cb: NoneToVoidFunction) => { cb(); - }, [], ms, noFirst, noLast); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps, ms, noFirst, noLast); } diff --git a/src/hooks/useRunThrottled.ts b/src/hooks/useRunThrottled.ts index f9c343c86..9a1602e44 100644 --- a/src/hooks/useRunThrottled.ts +++ b/src/hooks/useRunThrottled.ts @@ -1,7 +1,8 @@ import useThrottledCallback from './useThrottledCallback'; -export default function useRunThrottled(ms: number, noFirst?: boolean) { +export default function useRunThrottled(ms: number, noFirst?: boolean, deps: any = []) { return useThrottledCallback((cb: NoneToVoidFunction) => { cb(); - }, [], ms, noFirst); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps, ms, noFirst); } diff --git a/src/hooks/useSignal.ts b/src/hooks/useSignal.ts new file mode 100644 index 000000000..0cc490b2c --- /dev/null +++ b/src/hooks/useSignal.ts @@ -0,0 +1,8 @@ +import { useRef } from '../lib/teact/teact'; +import { createSignal } from '../util/signals'; + +export default function useSignal(initial?: T) { + const signalRef = useRef>>(); + signalRef.current ??= createSignal(initial); + return signalRef.current; +} diff --git a/src/hooks/useSignalEffect.ts b/src/hooks/useSignalEffect.ts new file mode 100644 index 000000000..222d54160 --- /dev/null +++ b/src/hooks/useSignalEffect.ts @@ -0,0 +1,23 @@ +import { useRef } from '../lib/teact/teact'; +import { cleanupEffect, isSignal } from '../util/signals'; +import useEffectOnce from './useEffectOnce'; + +export function useSignalEffect(effect: NoneToVoidFunction, dependencies: readonly any[]) { + // The is extracted from `useEffectOnce` to run before all effects + const isFirstRun = useRef(true); + if (isFirstRun.current) { + isFirstRun.current = false; + + dependencies?.forEach((dependency) => { + if (isSignal(dependency)) { + dependency.subscribe(effect); + } + }); + } + + useEffectOnce(() => { + return () => { + cleanupEffect(effect); + }; + }); +} diff --git a/src/hooks/useStateRef.ts b/src/hooks/useStateRef.ts index efc4ef807..07e6aad6e 100644 --- a/src/hooks/useStateRef.ts +++ b/src/hooks/useStateRef.ts @@ -3,7 +3,7 @@ import { useRef } from '../lib/teact/teact'; import useSyncEffect from './useSyncEffect'; // Allows to use state value as "silent" dependency in hooks (not causing updates). -// Useful for state values that update frequently (such as controlled input value). +// Also useful for state values that update frequently (such as controlled input value). export function useStateRef(value: T) { const ref = useRef(value); diff --git a/src/hooks/useThrottledCallback.ts b/src/hooks/useThrottledCallback.ts index f9f651ea6..a97573b05 100644 --- a/src/hooks/useThrottledCallback.ts +++ b/src/hooks/useThrottledCallback.ts @@ -1,17 +1,22 @@ import { useCallback, useMemo } from '../lib/teact/teact'; -import { throttle } from '../util/schedulers'; +import type { fastRaf } from '../util/schedulers'; +import { throttle, throttleWithRaf } from '../util/schedulers'; export default function useThrottledCallback( fn: T, deps: any[], - ms: number, - noFirst?: boolean, + msOrRaf: number | typeof fastRaf, + noFirst = false, ) { // eslint-disable-next-line react-hooks/exhaustive-deps const fnMemo = useCallback(fn, deps); return useMemo(() => { - return throttle(fnMemo, ms, !noFirst); - }, [fnMemo, ms, noFirst]); + if (typeof msOrRaf === 'number') { + return throttle(fnMemo, msOrRaf, !noFirst); + } else { + return throttleWithRaf(fnMemo); + } + }, [fnMemo, msOrRaf, noFirst]); } diff --git a/src/lib/teact/teact.ts b/src/lib/teact/teact.ts index c9228dac6..de0e36546 100644 --- a/src/lib/teact/teact.ts +++ b/src/lib/teact/teact.ts @@ -10,6 +10,7 @@ import { orderBy } from '../../util/iteratees'; import { getUnequalProps } from '../../util/arePropsShallowEqual'; import { handleError } from '../../util/handleError'; import { incrementOverlayCounter } from '../../util/debugOverlay'; +import { isSignal } from '../../util/signals'; export type Props = AnyLiteral; export type FC

= (props: P) => any; @@ -81,7 +82,9 @@ interface ComponentInstance { cursor: number; byCursor: { dependencies?: readonly any[]; + schedule: NoneToVoidFunction; cleanup?: NoneToVoidFunction; + releaseSignals?: NoneToVoidFunction; }[]; }; memos: { @@ -436,15 +439,15 @@ export function unmountComponent(componentInstance: ComponentInstance) { idsToExcludeFromUpdate.add(componentInstance.id); componentInstance.hooks.effects.byCursor.forEach((effect) => { - if (effect.cleanup) { - try { - effect.cleanup(); - } catch (err: any) { - handleError(err); - } finally { - effect.cleanup = undefined; - } + try { + effect.cleanup?.(); + } catch (err: any) { + handleError(err); + } finally { + effect.cleanup = undefined; } + + effect.releaseSignals?.(); }); componentInstance.isMounted = false; @@ -455,7 +458,9 @@ export function unmountComponent(componentInstance: ComponentInstance) { // We need to remove all references to DOM objects. We also clean all other references, just in case function helpGc(componentInstance: ComponentInstance) { componentInstance.hooks.effects.byCursor.forEach((hook) => { + hook.schedule = undefined as any; hook.cleanup = undefined as any; + hook.releaseSignals = undefined as any; hook.dependencies = undefined; }); @@ -667,11 +672,32 @@ function useEffectBase( schedule(); } + const isFirstRun = !byCursor[cursor]; + byCursor[cursor] = { ...byCursor[cursor], dependencies, + schedule, }; + function setupSignals() { + const cleanups = dependencies?.filter(isSignal).map((signal) => signal.subscribe(() => { + byCursor[cursor].schedule(); + })); + + if (!cleanups?.length) { + return undefined; + } + + return () => { + cleanups.forEach((cleanup) => cleanup()); + }; + } + + if (isFirstRun) { + byCursor[cursor].releaseSignals = setupSignals(); + } + renderingInstance.hooks.effects.cursor++; } diff --git a/src/util/selection.ts b/src/util/selection.ts index 8635dd830..5dbce7be8 100644 --- a/src/util/selection.ts +++ b/src/util/selection.ts @@ -1,4 +1,4 @@ -const fragmentEl = document.createElement('div'); +const extractorEl = document.createElement('div'); export function insertHtmlInSelection(html: string) { const selection = window.getSelection(); @@ -42,20 +42,9 @@ export function getHtmlBeforeSelection(container?: HTMLElement, useCommonAncesto range.collapse(true); range.setStart(container, 0); - replaceChildren(fragmentEl, range.cloneContents()); - return fragmentEl.innerHTML; -} + extractorEl.innerHTML = ''; + extractorEl.appendChild(range.cloneContents()); -function replaceChildren(el: HTMLElement, nodes?: DocumentFragment) { - if (el.replaceChildren === undefined) { - while (el.lastChild) { - el.removeChild(el.lastChild); - } - if (nodes !== undefined) { - el.append(nodes); - } - } else { - el.replaceChildren(nodes || ''); - } + return extractorEl.innerHTML; } diff --git a/src/util/signals.ts b/src/util/signals.ts new file mode 100644 index 000000000..edae72be3 --- /dev/null +++ b/src/util/signals.ts @@ -0,0 +1,81 @@ +import type { CallbackManager } from './callbacks'; +import { createCallbackManager } from './callbacks'; + +interface SignalState { + value: T; + effects: CallbackManager; +} + +const SIGNAL_MARK = Symbol('SIGNAL_MARK'); + +export type Signal = ((() => T) & { + readonly [SIGNAL_MARK]: symbol; + subscribe: (cb: AnyToVoidFunction) => NoneToVoidFunction; +}); + +export function isSignal(obj: any): obj is Signal { + return typeof obj === 'function' && SIGNAL_MARK in obj; +} + +// A shorthand to unsubscribe effect from all signals +const unsubscribesByEffect = new Map>(); + +let currentEffect: NoneToVoidFunction | undefined; + +export function createSignal(defaultValue?: T) { + const state: SignalState = { + value: defaultValue, + effects: createCallbackManager(), + }; + + function subscribe(effect: NoneToVoidFunction) { + const unsubscribe = state.effects.addCallback(effect); + + if (!unsubscribesByEffect.has(effect)) { + unsubscribesByEffect.set(effect, new Set([unsubscribe])); + } else { + unsubscribesByEffect.get(effect)!.add(unsubscribe); + } + + return () => { + unsubscribe(); + + const unsubscribes = unsubscribesByEffect.get(effect)!; + unsubscribes.delete(unsubscribe); + if (!unsubscribes.size) { + unsubscribesByEffect.delete(effect); + } + }; + } + + function getter() { + if (currentEffect) { + subscribe(currentEffect); + } + + return state.value; + } + + function setter(newValue: T) { + if (state.value === newValue) { + return; + } + + state.value = newValue; + state.effects.runCallbacks(); + } + + const signal = Object.assign(getter as Signal, { + [SIGNAL_MARK]: SIGNAL_MARK, + subscribe, + }); + + return [signal, setter] as const; +} + +export function cleanupEffect(effect: NoneToVoidFunction) { + unsubscribesByEffect.get(effect)?.forEach((unsubscribe) => { + unsubscribe(); + }); + unsubscribesByEffect.delete(effect); +}