Composer: Introduce Signals (#2378)

This commit is contained in:
Alexander Zinchuk 2023-02-08 00:43:35 +01:00
parent ba63b8c2c6
commit eed6241f42
33 changed files with 1060 additions and 725 deletions

View File

@ -43,7 +43,7 @@
"react-hooks/exhaustive-deps": [ "react-hooks/exhaustive-deps": [
"error", "error",
{ {
"additionalHooks": "(useSyncEffect|useAsync|useDebouncedCallback|useThrottledCallback|useEffectWithPrevDeps|useLayoutEffectWithPrevDeps)$" "additionalHooks": "(useSyncEffect|useAsync|useDebouncedCallback|useThrottledCallback|useEffectWithPrevDeps|useLayoutEffectWithPrevDeps|useDerivedState|useDerivedSignal|useThrottledResolver|useDebouncedResolver)$"
} }
], ],
"arrow-body-style": "off", "arrow-body-style": "off",

View File

@ -164,7 +164,7 @@ const ActionMessage: FC<OwnProps & StateProps> = ({
}); });
}; };
// TODO: Refactoring for action rendering // TODO Refactoring for action rendering
const shouldSkipRender = isInsideTopic && message.content.action?.text === 'TopicWasCreatedAction'; const shouldSkipRender = isInsideTopic && message.content.action?.text === 'TopicWasCreatedAction';
if (shouldSkipRender) { if (shouldSkipRender) {
return <span ref={ref} />; return <span ref={ref} />;

View File

@ -234,7 +234,7 @@ const HeaderMenuContainer: FC<OwnProps & StateProps> = ({
const handleEnterVoiceChatClick = useCallback(() => { const handleEnterVoiceChatClick = useCallback(() => {
if (canCreateVoiceChat) { if (canCreateVoiceChat) {
// TODO show popup to schedule // TODO Show popup to schedule
createGroupCall({ createGroupCall({
chatId, chatId,
}); });

View File

@ -6,6 +6,7 @@ import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import type { ApiAttachment, ApiChatMember, ApiSticker } from '../../../api/types'; import type { ApiAttachment, ApiChatMember, ApiSticker } from '../../../api/types';
import type { GlobalState } from '../../../global/types'; import type { GlobalState } from '../../../global/types';
import type { Signal } from '../../../util/signals';
import { import {
BASE_EMOJI_KEYWORD_LANG, BASE_EMOJI_KEYWORD_LANG,
@ -29,10 +30,11 @@ import useEmojiTooltip from './hooks/useEmojiTooltip';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers';
import { useStateRef } from '../../../hooks/useStateRef';
import useCustomEmojiTooltip from './hooks/useCustomEmojiTooltip'; import useCustomEmojiTooltip from './hooks/useCustomEmojiTooltip';
import useAppLayout from '../../../hooks/useAppLayout'; import useAppLayout from '../../../hooks/useAppLayout';
import useScrolledState from '../../../hooks/useScrolledState'; import useScrolledState from '../../../hooks/useScrolledState';
import useGetSelectionRange from '../../../hooks/useGetSelectionRange';
import useDerivedState from '../../../hooks/useDerivedState';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
import Modal from '../../ui/Modal'; import Modal from '../../ui/Modal';
@ -51,11 +53,12 @@ export type OwnProps = {
chatId: string; chatId: string;
threadId: number; threadId: number;
attachments: ApiAttachment[]; attachments: ApiAttachment[];
caption: string; getHtml: Signal<string>;
canShowCustomSendMenu?: boolean; canShowCustomSendMenu?: boolean;
isReady?: boolean; isReady?: boolean;
shouldSchedule?: boolean; shouldSchedule?: boolean;
shouldSuggestCompression?: boolean; shouldSuggestCompression?: boolean;
isForCurrentMessageList?: boolean;
onCaptionUpdate: (html: string) => void; onCaptionUpdate: (html: string) => void;
onSend: (sendCompressed: boolean, sendGrouped: boolean) => void; onSend: (sendCompressed: boolean, sendGrouped: boolean) => void;
onFileAppend: (files: File[], isSpoiler?: boolean) => void; onFileAppend: (files: File[], isSpoiler?: boolean) => void;
@ -79,13 +82,13 @@ type StateProps = {
}; };
const DROP_LEAVE_TIMEOUT_MS = 150; const DROP_LEAVE_TIMEOUT_MS = 150;
const CAPTION_SYMBOLS_LEFT_THRESHOLD = 100; const MAX_LEFT_CHARS_TO_SHOW = 100;
const AttachmentModal: FC<OwnProps & StateProps> = ({ const AttachmentModal: FC<OwnProps & StateProps> = ({
chatId, chatId,
threadId, threadId,
attachments, attachments,
caption, getHtml,
canShowCustomSendMenu, canShowCustomSendMenu,
captionLimit, captionLimit,
isReady, isReady,
@ -100,6 +103,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
customEmojiForEmoji, customEmojiForEmoji,
attachmentSettings, attachmentSettings,
shouldSuggestCompression, shouldSuggestCompression,
isForCurrentMessageList,
onAttachmentsUpdate, onAttachmentsUpdate,
onCaptionUpdate, onCaptionUpdate,
onSend, onSend,
@ -109,10 +113,14 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
onSendScheduled, onSendScheduled,
}) => { }) => {
const { addRecentCustomEmoji, addRecentEmoji, updateAttachmentSettings } = getActions(); const { addRecentCustomEmoji, addRecentEmoji, updateAttachmentSettings } = getActions();
const lang = useLang(); const lang = useLang();
const captionRef = useStateRef(caption);
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const mainButtonRef = useStateRef<HTMLButtonElement | null>(null); const mainButtonRef = useRef<HTMLButtonElement | null>(null);
// eslint-disable-next-line no-null/no-null
const inputRef = useRef<HTMLDivElement>(null);
const hideTimeoutRef = useRef<number>(); const hideTimeoutRef = useRef<number>();
const prevAttachments = usePrevious(attachments); const prevAttachments = usePrevious(attachments);
const renderingAttachments = attachments.length ? attachments : prevAttachments; const renderingAttachments = attachments.length ? attachments : prevAttachments;
@ -132,6 +140,7 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
const { handleScroll: handleCaptionScroll, isAtBeginning: isCaptionNotScrolled } = useScrolledState(); const { handleScroll: handleCaptionScroll, isAtBeginning: isCaptionNotScrolled } = useScrolledState();
const isOpen = Boolean(attachments.length); const isOpen = Boolean(attachments.length);
const renderingIsOpen = Boolean(renderingAttachments?.length);
const [isHovered, markHovered, unmarkHovered] = useFlag(); const [isHovered, markHovered, unmarkHovered] = useFlag();
const [hasMedia, hasOnlyMedia] = useMemo(() => { const [hasMedia, hasOnlyMedia] = useMemo(() => {
@ -148,42 +157,51 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
return [hasOneSpoiler, false]; return [hasOneSpoiler, false];
}, [renderingAttachments]); }, [renderingAttachments]);
const { const getSelectionRange = useGetSelectionRange(`#${EDITABLE_INPUT_MODAL_ID}`);
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 { const {
isEmojiTooltipOpen, isEmojiTooltipOpen,
filteredEmojis, filteredEmojis,
filteredCustomEmojis, filteredCustomEmojis,
insertEmoji, insertEmoji,
insertCustomEmoji: insertCustomEmojiFromEmojiTooltip,
closeEmojiTooltip, closeEmojiTooltip,
} = useEmojiTooltip( } = useEmojiTooltip(
isOpen, Boolean(isReady && isForCurrentMessageList && renderingIsOpen),
captionRef, getHtml,
recentEmojis,
EDITABLE_INPUT_MODAL_ID,
onCaptionUpdate, onCaptionUpdate,
EDITABLE_INPUT_MODAL_ID,
recentEmojis,
baseEmojiKeywords, baseEmojiKeywords,
emojiKeywords, 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]); useEffect(() => (isOpen ? captureEscKeyListener(onClear) : undefined), [isOpen, onClear]);
@ -324,10 +342,12 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
); );
}, [isMobile]); }, [isMobile]);
const leftChars = useMemo(() => { const leftChars = useDerivedState(() => {
const captionLeftBeforeLimit = captionLimit - getHtmlTextLength(caption); if (!renderingIsOpen) return undefined;
return captionLeftBeforeLimit <= CAPTION_SYMBOLS_LEFT_THRESHOLD ? captionLeftBeforeLimit : undefined;
}, [caption, captionLimit]); const leftCharsBeforeLimit = captionLimit - getHtmlTextLength(getHtml());
return leftCharsBeforeLimit <= MAX_LEFT_CHARS_TO_SHOW ? leftCharsBeforeLimit : undefined;
}, [captionLimit, getHtml, renderingIsOpen]);
const isQuickGallery = shouldSendCompressed && hasOnlyMedia; const isQuickGallery = shouldSendCompressed && hasOnlyMedia;
@ -475,39 +495,42 @@ const AttachmentModal: FC<OwnProps & StateProps> = ({
> >
<MentionTooltip <MentionTooltip
isOpen={isMentionTooltipOpen} isOpen={isMentionTooltipOpen}
onClose={closeMentionTooltip}
onInsertUserName={insertMention}
filteredUsers={mentionFilteredUsers} filteredUsers={mentionFilteredUsers}
onInsertUserName={insertMention}
onClose={closeMentionTooltip}
/> />
<EmojiTooltip <EmojiTooltip
isOpen={isEmojiTooltipOpen} isOpen={isEmojiTooltipOpen}
emojis={filteredEmojis} emojis={filteredEmojis}
customEmojis={filteredCustomEmojis} customEmojis={filteredCustomEmojis}
onClose={closeEmojiTooltip}
onEmojiSelect={insertEmoji}
onCustomEmojiSelect={insertCustomEmojiFromEmojiTooltip}
addRecentEmoji={addRecentEmoji} addRecentEmoji={addRecentEmoji}
addRecentCustomEmoji={addRecentCustomEmoji} addRecentCustomEmoji={addRecentCustomEmoji}
onEmojiSelect={insertEmoji}
onCustomEmojiSelect={insertEmoji}
onClose={closeEmojiTooltip}
/> />
<CustomEmojiTooltip <CustomEmojiTooltip
chatId={chatId} chatId={chatId}
isOpen={isCustomEmojiTooltipOpen} isOpen={isCustomEmojiTooltipOpen}
onCustomEmojiSelect={insertCustomEmoji}
addRecentCustomEmoji={addRecentCustomEmoji} addRecentCustomEmoji={addRecentCustomEmoji}
onCustomEmojiSelect={insertCustomEmoji}
onClose={closeCustomEmojiTooltip}
/> />
<div className={styles.caption}> <div className={styles.caption}>
<MessageInput <MessageInput
ref={inputRef}
id="caption-input-text" id="caption-input-text"
chatId={chatId} chatId={chatId}
threadId={threadId} threadId={threadId}
isAttachmentModalInput isAttachmentModalInput
html={caption} isActive={isOpen}
getHtml={getHtml}
editableInputId={EDITABLE_INPUT_MODAL_ID} editableInputId={EDITABLE_INPUT_MODAL_ID}
placeholder={lang('AddCaption')} placeholder={lang('AddCaption')}
onUpdate={onCaptionUpdate} onUpdate={onCaptionUpdate}
onSend={handleSendClick} onSend={handleSendClick}
onScroll={handleCaptionScroll} onScroll={handleCaptionScroll}
canAutoFocus={Boolean(isReady && attachments.length)} canAutoFocus={Boolean(isReady && isForCurrentMessageList && attachments.length)}
captionLimit={leftChars} captionLimit={leftChars}
/> />
<div className={styles.sendWrapper}> <div className={styles.sendWrapper}>

View File

@ -28,7 +28,8 @@ import {
EDITABLE_INPUT_ID, EDITABLE_INPUT_ID,
REPLIES_USER_ID, REPLIES_USER_ID,
SEND_MESSAGE_ACTION_INTERVAL, SEND_MESSAGE_ACTION_INTERVAL,
EDITABLE_INPUT_CSS_SELECTOR, MAX_UPLOAD_FILEPART_SIZE, EDITABLE_INPUT_CSS_SELECTOR,
MAX_UPLOAD_FILEPART_SIZE,
} from '../../../config'; } from '../../../config';
import { IS_VOICE_RECORDING_SUPPORTED, IS_IOS } from '../../../util/environment'; import { IS_VOICE_RECORDING_SUPPORTED, IS_IOS } from '../../../util/environment';
import { MEMO_EMPTY_ARRAY } from '../../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../../util/memo';
@ -81,6 +82,7 @@ import { buildCustomEmojiHtml } from './helpers/customEmoji';
import { processMessageInputForCustomEmoji } from '../../../util/customEmojiManager'; import { processMessageInputForCustomEmoji } from '../../../util/customEmojiManager';
import { getTextWithEntitiesAsHtml } from '../../common/helpers/renderTextWithEntities'; import { getTextWithEntitiesAsHtml } from '../../common/helpers/renderTextWithEntities';
import useSignal from '../../../hooks/useSignal';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import usePrevious from '../../../hooks/usePrevious'; import usePrevious from '../../../hooks/usePrevious';
import useStickerTooltip from './hooks/useStickerTooltip'; import useStickerTooltip from './hooks/useStickerTooltip';
@ -89,7 +91,6 @@ import useLang from '../../../hooks/useLang';
import useSendMessageAction from '../../../hooks/useSendMessageAction'; import useSendMessageAction from '../../../hooks/useSendMessageAction';
import useInterval from '../../../hooks/useInterval'; import useInterval from '../../../hooks/useInterval';
import useSyncEffect from '../../../hooks/useSyncEffect'; import useSyncEffect from '../../../hooks/useSyncEffect';
import { useStateRef } from '../../../hooks/useStateRef';
import useVoiceRecording from './hooks/useVoiceRecording'; import useVoiceRecording from './hooks/useVoiceRecording';
import useClipboardPaste from './hooks/useClipboardPaste'; import useClipboardPaste from './hooks/useClipboardPaste';
import useDraft from './hooks/useDraft'; import useDraft from './hooks/useDraft';
@ -101,6 +102,9 @@ import useBotCommandTooltip from './hooks/useBotCommandTooltip';
import useSchedule from '../../../hooks/useSchedule'; import useSchedule from '../../../hooks/useSchedule';
import useCustomEmojiTooltip from './hooks/useCustomEmojiTooltip'; import useCustomEmojiTooltip from './hooks/useCustomEmojiTooltip';
import useAttachmentModal from './hooks/useAttachmentModal'; 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 DeleteMessageModal from '../../common/DeleteMessageModal.async';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
@ -291,12 +295,16 @@ const Composer: FC<OwnProps & StateProps> = ({
addRecentCustomEmoji, addRecentCustomEmoji,
showNotification, showNotification,
} = getActions(); } = getActions();
const lang = useLang(); const lang = useLang();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const appendixRef = useRef<HTMLDivElement>(null); const appendixRef = useRef<HTMLDivElement>(null);
const [html, setInnerHtml] = useState<string>(''); // eslint-disable-next-line no-null/no-null
const htmlRef = useStateRef(html); const inputRef = useRef<HTMLDivElement>(null);
const [getHtml, setHtml] = useSignal('');
const getSelectionRange = useGetSelectionRange(EDITABLE_INPUT_CSS_SELECTOR);
const lastMessageSendTimeSeconds = useRef<number>(); const lastMessageSendTimeSeconds = useRef<number>();
const prevDropAreaState = usePrevious(dropAreaState); const prevDropAreaState = usePrevious(dropAreaState);
const { width: windowWidth } = windowSize.get(); const { width: windowWidth } = windowSize.get();
@ -307,12 +315,7 @@ const Composer: FC<OwnProps & StateProps> = ({
const [isSymbolMenuForced, forceShowSymbolMenu, cancelForceShowSymbolMenu] = useFlag(); const [isSymbolMenuForced, forceShowSymbolMenu, cancelForceShowSymbolMenu] = useFlag();
const sendMessageAction = useSendMessageAction(chatId, threadId); const sendMessageAction = useSendMessageAction(chatId, threadId);
const setHtml = useCallback((newHtml: string) => { useEffect(processMessageInputForCustomEmoji, [getHtml]);
setInnerHtml(newHtml);
requestAnimationFrame(() => {
processMessageInputForCustomEmoji();
});
}, []);
const customEmojiNotificationNumber = useRef(0); const customEmojiNotificationNumber = useRef(0);
@ -350,6 +353,7 @@ const Composer: FC<OwnProps & StateProps> = ({
}, []); }, []);
const [attachments, setAttachments] = useState<ApiAttachment[]>([]); const [attachments, setAttachments] = useState<ApiAttachment[]>([]);
const hasAttachments = Boolean(attachments.length);
const { const {
shouldSuggestCompression, shouldSuggestCompression,
@ -393,48 +397,12 @@ const Composer: FC<OwnProps & StateProps> = ({
} }
}, [activeVoiceRecording, sendMessageAction]); }, [activeVoiceRecording, sendMessageAction]);
const isEditingRef = useStateRef(Boolean(editingMessage));
useEffect(() => { useEffect(() => {
if (!html || editingMessage) return; if (getHtml() && !isEditingRef.current) {
sendMessageAction({ type: 'typing' }); sendMessageAction({ type: 'typing' });
}, [editingMessage, html, sendMessageAction]); }
}, [getHtml, isEditingRef, 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,
);
const { const {
canSendStickers, canSendGifs, canAttachMedia, canAttachPolls, canAttachEmbedLinks, canSendStickers, canSendGifs, canAttachMedia, canAttachPolls, canAttachEmbedLinks,
@ -443,36 +411,85 @@ const Composer: FC<OwnProps & StateProps> = ({
const isAdmin = chat && isChatAdmin(chat); const isAdmin = chat && isChatAdmin(chat);
const slowMode = getChatSlowModeOptions(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 { const {
isEmojiTooltipOpen, isEmojiTooltipOpen,
closeEmojiTooltip, closeEmojiTooltip,
filteredEmojis, filteredEmojis,
filteredCustomEmojis, filteredCustomEmojis,
insertEmoji, insertEmoji,
insertCustomEmoji: insertCustomEmojiFromEmojiTooltip,
} = useEmojiTooltip( } = useEmojiTooltip(
Boolean(shouldSuggestStickers && canSendStickers && !attachments.length), Boolean(isReady && isForCurrentMessageList && shouldSuggestStickers && !hasAttachments),
htmlRef, getHtml,
recentEmojis,
undefined,
setHtml, setHtml,
undefined,
recentEmojis,
baseEmojiKeywords, baseEmojiKeywords,
emojiKeywords, 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) => { const insertHtmlAndUpdateCursor = useCallback((newHtml: string, inputId: string = EDITABLE_INPUT_ID) => {
@ -493,13 +510,13 @@ const Composer: FC<OwnProps & StateProps> = ({
} }
} }
setHtml(`${htmlRef.current!}${newHtml}`); setHtml(`${getHtml()}${newHtml}`);
// If selection is outside of input, set cursor at the end of input // If selection is outside of input, set cursor at the end of input
requestAnimationFrame(() => { requestAnimationFrame(() => {
focusEditableElement(messageInput); focusEditableElement(messageInput);
}); });
}, [htmlRef, setHtml]); }, [getHtml, setHtml]);
const insertFormattedTextAndUpdateCursor = useCallback(( const insertFormattedTextAndUpdateCursor = useCallback((
text: ApiFormattedText, inputId: string = EDITABLE_INPUT_ID, text: ApiFormattedText, inputId: string = EDITABLE_INPUT_ID,
@ -530,18 +547,22 @@ const Composer: FC<OwnProps & StateProps> = ({
} }
} }
setHtml(deleteLastCharacterOutsideSelection(htmlRef.current!)); setHtml(deleteLastCharacterOutsideSelection(getHtml()));
}, [htmlRef, setHtml]); }, [getHtml, setHtml]);
useDraft(draft, chatId, threadId, getHtml, setHtml, editingMessage, lastSyncTime);
const resetComposer = useCallback((shouldPreserveInput = false) => { const resetComposer = useCallback((shouldPreserveInput = false) => {
if (!shouldPreserveInput) { if (!shouldPreserveInput) {
setHtml(''); setHtml('');
} }
setAttachments(MEMO_EMPTY_ARRAY); setAttachments(MEMO_EMPTY_ARRAY);
closeStickerTooltip();
closeCustomEmojiTooltip();
closeMentionTooltip();
closeEmojiTooltip(); closeEmojiTooltip();
closeCustomEmojiTooltip();
closeStickerTooltip();
closeMentionTooltip();
if (isMobile) { if (isMobile) {
// @optimization // @optimization
@ -550,19 +571,35 @@ const Composer: FC<OwnProps & StateProps> = ({
closeSymbolMenu(); closeSymbolMenu();
} }
}, [ }, [
closeStickerTooltip, closeCustomEmojiTooltip, closeMentionTooltip, closeEmojiTooltip, setHtml, isMobile, closeStickerTooltip, closeCustomEmojiTooltip, closeMentionTooltip, closeEmojiTooltip,
closeSymbolMenu, setHtml, isMobile, closeSymbolMenu,
]); ]);
// Handle chat change (ref is used to avoid redundant effect calls) const [handleEditComplete, handleEditCancel, shouldForceShowEditing] = useEditing(
const stopRecordingVoiceRef = useRef<typeof stopRecordingVoice>(); getHtml,
stopRecordingVoiceRef.current = stopRecordingVoice; 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(() => { useEffect(() => {
return () => { return () => {
stopRecordingVoiceRef.current!(); // eslint-disable-next-line react-hooks/exhaustive-deps
resetComposer(); stopRecordingVoiceRef.current();
// eslint-disable-next-line react-hooks/exhaustive-deps
resetComposerRef.current();
}; };
}, [chatId, threadId, resetComposer, stopRecordingVoiceRef]); }, [chatId, threadId, resetComposerRef, stopRecordingVoiceRef]);
const showCustomEmojiPremiumNotification = useCallback(() => { const showCustomEmojiPremiumNotification = useCallback(() => {
const notificationNumber = customEmojiNotificationNumber.current; const notificationNumber = customEmojiNotificationNumber.current;
@ -588,26 +625,12 @@ const Composer: FC<OwnProps & StateProps> = ({
customEmojiNotificationNumber.current = Number(!notificationNumber); customEmojiNotificationNumber.current = Number(!notificationNumber);
}, [currentUserId, lang, showNotification]); }, [currentUserId, lang, showNotification]);
const [handleEditComplete, handleEditCancel, shouldForceShowEditing] = useEditing( const mainButtonState = useDerivedState(() => {
htmlRef,
setHtml,
editingMessage,
resetComposer,
openDeleteModal,
chatId,
threadId,
messageListType,
draft,
editingDraft,
replyingToId,
);
const mainButtonState = useMemo(() => {
if (editingMessage && shouldForceShowEditing) { if (editingMessage && shouldForceShowEditing) {
return MainButtonState.Edit; 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; return MainButtonState.Record;
} }
@ -617,8 +640,7 @@ const Composer: FC<OwnProps & StateProps> = ({
return MainButtonState.Send; return MainButtonState.Send;
}, [ }, [
activeVoiceRecording, attachments.length, editingMessage, html, isForwarding, shouldForceShowEditing, activeVoiceRecording, editingMessage, getHtml, hasAttachments, isForwarding, shouldForceShowEditing, shouldSchedule,
shouldSchedule,
]); ]);
const canShowCustomSendMenu = !shouldSchedule; const canShowCustomSendMenu = !shouldSchedule;
@ -629,7 +651,6 @@ const Composer: FC<OwnProps & StateProps> = ({
handleContextMenuHide, handleContextMenuHide,
} = useContextMenuHandlers(mainButtonRef, !(mainButtonState === MainButtonState.Send && canShowCustomSendMenu)); } = useContextMenuHandlers(mainButtonRef, !(mainButtonState === MainButtonState.Send && canShowCustomSendMenu));
useDraft(draft, chatId, threadId, htmlRef, setHtml, editingMessage, lastSyncTime);
useClipboardPaste( useClipboardPaste(
isForCurrentMessageList, isForCurrentMessageList,
insertFormattedTextAndUpdateCursor, insertFormattedTextAndUpdateCursor,
@ -703,7 +724,7 @@ const Composer: FC<OwnProps & StateProps> = ({
sendGrouped = attachmentSettings.shouldSendGrouped, sendGrouped = attachmentSettings.shouldSendGrouped,
isSilent, isSilent,
scheduledAt, scheduledAt,
} : { }: {
attachments: ApiAttachment[]; attachments: ApiAttachment[];
sendCompressed?: boolean; sendCompressed?: boolean;
sendGrouped?: boolean; sendGrouped?: boolean;
@ -714,7 +735,7 @@ const Composer: FC<OwnProps & StateProps> = ({
return; return;
} }
const { text, entities } = parseMessageInput(htmlRef.current!); const { text, entities } = parseMessageInput(getHtml());
if (!text && !attachmentsToSend.length) { if (!text && !attachmentsToSend.length) {
return; return;
} }
@ -740,8 +761,8 @@ const Composer: FC<OwnProps & StateProps> = ({
resetComposer(); resetComposer();
}); });
}, [ }, [
attachmentSettings, connectionState, htmlRef, validateTextLength, checkSlowMode, sendMessage, clearDraft, chatId, attachmentSettings.shouldCompress, attachmentSettings.shouldSendGrouped, connectionState, getHtml,
resetComposer, validateTextLength, checkSlowMode, sendMessage, clearDraft, chatId, resetComposer,
]); ]);
const handleSendAttachments = useCallback(( const handleSendAttachments = useCallback((
@ -778,7 +799,7 @@ const Composer: FC<OwnProps & StateProps> = ({
} }
} }
const { text, entities } = parseMessageInput(htmlRef.current!); const { text, entities } = parseMessageInput(getHtml());
if (currentAttachments.length) { if (currentAttachments.length) {
sendAttachments({ sendAttachments({
@ -827,7 +848,7 @@ const Composer: FC<OwnProps & StateProps> = ({
resetComposer(); resetComposer();
}); });
}, [ }, [
connectionState, attachments, activeVoiceRecording, htmlRef, isForwarding, validateTextLength, clearDraft, connectionState, attachments, activeVoiceRecording, getHtml, isForwarding, validateTextLength, clearDraft,
chatId, stopRecordingVoice, sendAttachments, checkSlowMode, sendMessage, forwardMessages, resetComposer, chatId, stopRecordingVoice, sendAttachments, checkSlowMode, sendMessage, forwardMessages, resetComposer,
]); ]);
@ -1205,9 +1226,13 @@ const Composer: FC<OwnProps & StateProps> = ({
: mainButtonState === MainButtonState.Schedule ? handleSendScheduled : mainButtonState === MainButtonState.Schedule ? handleSendScheduled
: handleSend; : handleSend;
const isBotMenuButtonCommands = botMenuButton && botMenuButton?.type === 'commands'; const withBotMenuButton = isChatWithBot && botMenuButton?.type === 'webApp' && !editingMessage;
const shouldDisplayBotCommands = isChatWithBot && isBotMenuButtonCommands && botCommands !== false const isBotMenuButtonOpen = useDerivedState(() => {
&& !activeVoiceRecording && !editingMessage; return withBotMenuButton && !getHtml() && !activeVoiceRecording;
}, [withBotMenuButton, getHtml, activeVoiceRecording]);
const withBotCommands = isChatWithBot && botMenuButton?.type === 'commands' && !editingMessage
&& botCommands !== false && !activeVoiceRecording;
return ( return (
<div className={className}> <div className={className}>
@ -1224,9 +1249,10 @@ const Composer: FC<OwnProps & StateProps> = ({
threadId={threadId} threadId={threadId}
canShowCustomSendMenu={canShowCustomSendMenu} canShowCustomSendMenu={canShowCustomSendMenu}
attachments={attachments} attachments={attachments}
caption={attachments.length ? html : ''} getHtml={getHtml}
isReady={isReady} isReady={isReady}
shouldSuggestCompression={shouldSuggestCompression} shouldSuggestCompression={shouldSuggestCompression}
isForCurrentMessageList={isForCurrentMessageList}
onCaptionUpdate={onCaptionUpdate} onCaptionUpdate={onCaptionUpdate}
onSendSilent={handleSendSilentAttachments} onSendSilent={handleSendSilentAttachments}
onSend={handleSendAttachments} onSend={handleSendAttachments}
@ -1260,9 +1286,9 @@ const Composer: FC<OwnProps & StateProps> = ({
/> />
<MentionTooltip <MentionTooltip
isOpen={isMentionTooltipOpen} isOpen={isMentionTooltipOpen}
onClose={closeMentionTooltip}
onInsertUserName={insertMention}
filteredUsers={mentionFilteredUsers} filteredUsers={mentionFilteredUsers}
onInsertUserName={insertMention}
onClose={closeMentionTooltip}
/> />
<InlineBotTooltip <InlineBotTooltip
isOpen={isInlineBotTooltipOpen} isOpen={isInlineBotTooltipOpen}
@ -1270,12 +1296,12 @@ const Composer: FC<OwnProps & StateProps> = ({
isGallery={isInlineBotTooltipGallery} isGallery={isInlineBotTooltipGallery}
inlineBotResults={inlineBotResults} inlineBotResults={inlineBotResults}
switchPm={inlineBotSwitchPm} switchPm={inlineBotSwitchPm}
onSelectResult={handleInlineBotSelect}
loadMore={loadMoreForInlineBot} loadMore={loadMoreForInlineBot}
onClose={closeInlineBotTooltip}
isSavedMessages={isChatWithSelf} isSavedMessages={isChatWithSelf}
canSendGifs={canSendGifs} canSendGifs={canSendGifs}
isCurrentUserPremium={isCurrentUserPremium} isCurrentUserPremium={isCurrentUserPremium}
onSelectResult={handleInlineBotSelect}
onClose={closeInlineBotTooltip}
/> />
<BotCommandTooltip <BotCommandTooltip
isOpen={isBotCommandTooltipOpen} isOpen={isBotCommandTooltipOpen}
@ -1293,20 +1319,19 @@ const Composer: FC<OwnProps & StateProps> = ({
<WebPagePreview <WebPagePreview
chatId={chatId} chatId={chatId}
threadId={threadId} threadId={threadId}
messageText={!attachments.length ? html : ''} getHtml={getHtml}
disabled={!canAttachEmbedLinks} isDisabled={!canAttachEmbedLinks || hasAttachments}
/> />
<div className="message-input-wrapper"> <div className="message-input-wrapper">
{isChatWithBot && botMenuButton && botMenuButton.type === 'webApp' && !editingMessage {withBotMenuButton && (
&& ( <BotMenuButton
<BotMenuButton isOpen={isBotMenuButtonOpen}
isOpen={!html && !activeVoiceRecording} text={botMenuButton.text}
onClick={handleClickBotMenu} isDisabled={Boolean(activeVoiceRecording)}
text={botMenuButton.text} onClick={handleClickBotMenu}
isDisabled={Boolean(activeVoiceRecording)} />
/> )}
)} {withBotCommands && (
{shouldDisplayBotCommands && (
<ResponsiveHoverButton <ResponsiveHoverButton
className={buildClassName('bot-commands', isBotCommandMenuOpen && 'activated')} className={buildClassName('bot-commands', isBotCommandMenuOpen && 'activated')}
round round
@ -1357,19 +1382,21 @@ const Composer: FC<OwnProps & StateProps> = ({
</ResponsiveHoverButton> </ResponsiveHoverButton>
)} )}
<MessageInput <MessageInput
ref={inputRef}
id="message-input-text" id="message-input-text"
editableInputId={EDITABLE_INPUT_ID} editableInputId={EDITABLE_INPUT_ID}
chatId={chatId} chatId={chatId}
threadId={threadId} threadId={threadId}
html={!attachments.length ? html : ''} isActive={!hasAttachments}
getHtml={getHtml}
placeholder={ placeholder={
activeVoiceRecording && windowWidth <= SCREEN_WIDTH_TO_HIDE_PLACEHOLDER activeVoiceRecording && windowWidth <= SCREEN_WIDTH_TO_HIDE_PLACEHOLDER
? '' ? ''
: botKeyboardPlaceholder || lang('Message') : botKeyboardPlaceholder || lang('Message')
} }
forcedPlaceholder={inlineBotHelp} forcedPlaceholder={inlineBotHelp}
canAutoFocus={isReady && !attachments.length} canAutoFocus={isReady && isForCurrentMessageList && !hasAttachments}
noFocusInterception={attachments.length > 0} noFocusInterception={hasAttachments}
shouldSuppressFocus={isMobile && isSymbolMenuOpen} shouldSuppressFocus={isMobile && isSymbolMenuOpen}
shouldSuppressTextFormatter={isEmojiTooltipOpen || isMentionTooltipOpen || isInlineBotTooltipOpen} shouldSuppressTextFormatter={isEmojiTooltipOpen || isMentionTooltipOpen || isInlineBotTooltipOpen}
onUpdate={setHtml} onUpdate={setHtml}
@ -1439,22 +1466,24 @@ const Composer: FC<OwnProps & StateProps> = ({
isOpen={isCustomEmojiTooltipOpen} isOpen={isCustomEmojiTooltipOpen}
onCustomEmojiSelect={insertCustomEmoji} onCustomEmojiSelect={insertCustomEmoji}
addRecentCustomEmoji={addRecentCustomEmoji} addRecentCustomEmoji={addRecentCustomEmoji}
onClose={closeCustomEmojiTooltip}
/> />
<StickerTooltip <StickerTooltip
chatId={chatId} chatId={chatId}
threadId={threadId} threadId={threadId}
isOpen={isStickerTooltipOpen} isOpen={isStickerTooltipOpen}
onStickerSelect={handleStickerSelect} onStickerSelect={handleStickerSelect}
onClose={closeStickerTooltip}
/> />
<EmojiTooltip <EmojiTooltip
isOpen={isEmojiTooltipOpen} isOpen={isEmojiTooltipOpen}
emojis={filteredEmojis} emojis={filteredEmojis}
customEmojis={filteredCustomEmojis} customEmojis={filteredCustomEmojis}
onClose={closeEmojiTooltip}
onEmojiSelect={insertEmoji}
addRecentEmoji={addRecentEmoji} addRecentEmoji={addRecentEmoji}
onCustomEmojiSelect={insertCustomEmojiFromEmojiTooltip}
addRecentCustomEmoji={addRecentCustomEmoji} addRecentCustomEmoji={addRecentCustomEmoji}
onEmojiSelect={insertEmoji}
onCustomEmojiSelect={insertEmoji}
onClose={closeEmojiTooltip}
/> />
<SymbolMenu <SymbolMenu
chatId={chatId} chatId={chatId}
@ -1538,9 +1567,11 @@ export default memo(withGlobal<OwnProps>(
const keyboardMessage = botKeyboardMessageId ? selectChatMessage(global, chatId, botKeyboardMessageId) : undefined; const keyboardMessage = botKeyboardMessageId ? selectChatMessage(global, chatId, botKeyboardMessageId) : undefined;
const { currentUserId } = global; const { currentUserId } = global;
const defaultSendAsId = chat?.fullInfo ? chat?.fullInfo?.sendAsId || currentUserId : undefined; const defaultSendAsId = chat?.fullInfo ? chat?.fullInfo?.sendAsId || currentUserId : undefined;
const sendAsId = chat?.sendAsPeerIds && defaultSendAsId const sendAsId = chat?.sendAsPeerIds && defaultSendAsId && (
&& chat.sendAsPeerIds.some((peer) => peer.id === defaultSendAsId) ? defaultSendAsId chat.sendAsPeerIds.some((peer) => peer.id === defaultSendAsId)
: (chat?.adminRights?.anonymous ? chat?.id : undefined); ? defaultSendAsId
: (chat?.adminRights?.anonymous ? chat?.id : undefined)
);
const sendAsUser = sendAsId ? selectUser(global, sendAsId) : undefined; const sendAsUser = sendAsId ? selectUser(global, sendAsId) : undefined;
const sendAsChat = !sendAsUser && sendAsId ? selectChat(global, sendAsId) : undefined; const sendAsChat = !sendAsUser && sendAsId ? selectChat(global, sendAsId) : undefined;
const requestedDraftText = selectRequestedDraftText(global, chatId); const requestedDraftText = selectRequestedDraftText(global, chatId);

View File

@ -25,8 +25,9 @@ import styles from './CustomEmojiTooltip.module.scss';
export type OwnProps = { export type OwnProps = {
chatId: string; chatId: string;
isOpen: boolean; isOpen: boolean;
onCustomEmojiSelect: (customEmoji: ApiSticker) => void;
addRecentCustomEmoji: GlobalActions['addRecentCustomEmoji']; addRecentCustomEmoji: GlobalActions['addRecentCustomEmoji'];
onCustomEmojiSelect: (customEmoji: ApiSticker) => void;
onClose: NoneToVoidFunction;
}; };
type StateProps = { type StateProps = {
@ -39,11 +40,12 @@ const INTERSECTION_THROTTLE = 200;
const CustomEmojiTooltip: FC<OwnProps & StateProps> = ({ const CustomEmojiTooltip: FC<OwnProps & StateProps> = ({
isOpen, isOpen,
addRecentCustomEmoji,
onCustomEmojiSelect,
onClose,
customEmoji, customEmoji,
isSavedMessages, isSavedMessages,
isCurrentUserPremium, isCurrentUserPremium,
onCustomEmojiSelect,
addRecentCustomEmoji,
}) => { }) => {
const { clearCustomEmojiForEmoji } = getActions(); const { clearCustomEmojiForEmoji } = getActions();
@ -59,9 +61,7 @@ const CustomEmojiTooltip: FC<OwnProps & StateProps> = ({
observe: observeIntersection, observe: observeIntersection,
} = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE }); } = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE });
useEffect(() => ( useEffect(() => (isOpen ? captureEscKeyListener(onClose) : undefined), [isOpen, onClose]);
isOpen ? captureEscKeyListener(clearCustomEmojiForEmoji) : undefined
), [isOpen, clearCustomEmojiForEmoji]);
const handleCustomEmojiSelect = useCallback((ce: ApiSticker) => { const handleCustomEmojiSelect = useCallback((ce: ApiSticker) => {
if (!isOpen) return; if (!isOpen) return;
@ -111,6 +111,7 @@ export default memo(withGlobal<OwnProps>(
const { stickers: customEmoji } = global.customEmojis.forEmoji; const { stickers: customEmoji } = global.customEmojis.forEmoji;
const isSavedMessages = selectIsChatWithSelf(global, chatId); const isSavedMessages = selectIsChatWithSelf(global, chatId);
const isCurrentUserPremium = selectIsCurrentUserPremium(global); const isCurrentUserPremium = selectIsCurrentUserPremium(global);
return { customEmoji, isSavedMessages, isCurrentUserPremium }; return { customEmoji, isSavedMessages, isCurrentUserPremium };
}, },
)(CustomEmojiTooltip)); )(CustomEmojiTooltip));

View File

@ -1,11 +1,12 @@
import type { ChangeEvent } from 'react'; import type { RefObject, ChangeEvent } from 'react';
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import React, { import React, {
useEffect, useRef, memo, useState, useCallback, useEffect, useRef, memo, useState, useCallback, useLayoutEffect,
} from '../../../lib/teact/teact'; } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { IAnchorPosition, ISettings } from '../../../types'; import type { IAnchorPosition, ISettings } from '../../../types';
import type { Signal } from '../../../util/signals';
import { EDITABLE_INPUT_ID } from '../../../config'; import { EDITABLE_INPUT_ID } from '../../../config';
import { import {
@ -21,12 +22,12 @@ import parseEmojiOnlyString from '../../../util/parseEmojiOnlyString';
import { isSelectionInsideInput } from './helpers/selection'; import { isSelectionInsideInput } from './helpers/selection';
import renderText from '../../common/helpers/renderText'; import renderText from '../../common/helpers/renderText';
import useLayoutEffectWithPrevDeps from '../../../hooks/useLayoutEffectWithPrevDeps';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import { isHeavyAnimating } from '../../../hooks/useHeavyAnimationCheck'; import { isHeavyAnimating } from '../../../hooks/useHeavyAnimationCheck';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
import useInputCustomEmojis from './hooks/useInputCustomEmojis'; import useInputCustomEmojis from './hooks/useInputCustomEmojis';
import useAppLayout from '../../../hooks/useAppLayout'; import useAppLayout from '../../../hooks/useAppLayout';
import useDerivedState from '../../../hooks/useDerivedState';
import TextFormatter from './TextFormatter'; import TextFormatter from './TextFormatter';
@ -39,12 +40,14 @@ const SCROLLER_CLASS = 'input-scroller';
const INPUT_WRAPPER_CLASS = 'message-input-wrapper'; const INPUT_WRAPPER_CLASS = 'message-input-wrapper';
type OwnProps = { type OwnProps = {
ref?: RefObject<HTMLDivElement>;
id: string; id: string;
chatId: string; chatId: string;
threadId: number; threadId: number;
isAttachmentModalInput?: boolean; isAttachmentModalInput?: boolean;
editableInputId?: string; editableInputId?: string;
html: string; isActive: boolean;
getHtml: Signal<string>;
placeholder: string; placeholder: string;
forcedPlaceholder?: string; forcedPlaceholder?: string;
noFocusInterception?: boolean; noFocusInterception?: boolean;
@ -89,12 +92,14 @@ function clearSelection() {
} }
const MessageInput: FC<OwnProps & StateProps> = ({ const MessageInput: FC<OwnProps & StateProps> = ({
ref,
id, id,
chatId, chatId,
captionLimit, captionLimit,
isAttachmentModalInput, isAttachmentModalInput,
editableInputId, editableInputId,
html, isActive,
getHtml,
placeholder, placeholder,
forcedPlaceholder, forcedPlaceholder,
canAutoFocus, canAutoFocus,
@ -115,7 +120,11 @@ const MessageInput: FC<OwnProps & StateProps> = ({
} = getActions(); } = getActions();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const inputRef = useRef<HTMLDivElement>(null); let inputRef = useRef<HTMLDivElement>(null);
if (ref) {
inputRef = ref;
}
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const selectionTimeoutRef = useRef<number>(null); const selectionTimeoutRef = useRef<number>(null);
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
@ -137,7 +146,7 @@ const MessageInput: FC<OwnProps & StateProps> = ({
const [isTextFormatterDisabled, setIsTextFormatterDisabled] = useState<boolean>(false); const [isTextFormatterDisabled, setIsTextFormatterDisabled] = useState<boolean>(false);
const { isMobile } = useAppLayout(); const { isMobile } = useAppLayout();
useInputCustomEmojis(html, inputRef, sharedCanvasRef, sharedCanvasHqRef, absoluteContainerRef); useInputCustomEmojis(getHtml, inputRef, sharedCanvasRef, sharedCanvasHqRef, absoluteContainerRef);
const maxInputHeight = isMobile ? 256 : 416; const maxInputHeight = isMobile ? 256 : 416;
const updateInputHeight = useCallback((willSend = false) => { const updateInputHeight = useCallback((willSend = false) => {
@ -173,7 +182,10 @@ const MessageInput: FC<OwnProps & StateProps> = ({
updateInputHeight(false); updateInputHeight(false);
}, [isAttachmentModalInput, updateInputHeight]); }, [isAttachmentModalInput, updateInputHeight]);
useLayoutEffectWithPrevDeps(([prevHtml]) => { const htmlRef = useRef(getHtml());
useLayoutEffect(() => {
const html = isActive ? getHtml() : '';
if (html !== inputRef.current!.innerHTML) { if (html !== inputRef.current!.innerHTML) {
inputRef.current!.innerHTML = html; inputRef.current!.innerHTML = html;
} }
@ -182,10 +194,12 @@ const MessageInput: FC<OwnProps & StateProps> = ({
cloneRef.current!.innerHTML = html; cloneRef.current!.innerHTML = html;
} }
if (prevHtml !== undefined && prevHtml !== html) { if (html !== htmlRef.current) {
updateInputHeight(!html.length); htmlRef.current = html;
updateInputHeight(!html);
} }
}, [html, updateInputHeight]); }, [getHtml, isActive, updateInputHeight]);
const chatIdRef = useRef(chatId); const chatIdRef = useRef(chatId);
chatIdRef.current = chatId; chatIdRef.current = chatId;
@ -311,7 +325,9 @@ const MessageInput: FC<OwnProps & StateProps> = ({
// https://levelup.gitconnected.com/javascript-events-handlers-keyboard-and-load-events-1b3e46a6b0c3#1960 // https://levelup.gitconnected.com/javascript-events-handlers-keyboard-and-load-events-1b3e46a6b0c3#1960
const { isComposing } = e; 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; const targetIndexDelta = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : undefined;
if (targetIndexDelta) { if (targetIndexDelta) {
e.preventDefault(); e.preventDefault();
@ -334,7 +350,7 @@ const MessageInput: FC<OwnProps & StateProps> = ({
closeTextFormatter(); closeTextFormatter();
onSend(); 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(); e.preventDefault();
editLastMessage(); editLastMessage();
} else { } else {
@ -472,9 +488,11 @@ const MessageInput: FC<OwnProps & StateProps> = ({
}; };
}, [shouldSuppressFocus]); }, [shouldSuppressFocus]);
const isTouched = useDerivedState(() => Boolean(isActive && getHtml()), [isActive, getHtml]);
const className = buildClassName( const className = buildClassName(
'form-control', 'form-control',
html.length > 0 && 'touched', isTouched && 'touched',
shouldSuppressFocus && 'focus-disabled', shouldSuppressFocus && 'focus-disabled',
); );

View File

@ -1,6 +1,6 @@
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import React, { memo, useEffect, useRef } 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'; import type { ApiSticker } from '../../../api/types';
@ -23,6 +23,7 @@ export type OwnProps = {
threadId?: number; threadId?: number;
isOpen: boolean; isOpen: boolean;
onStickerSelect: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void; onStickerSelect: (sticker: ApiSticker, isSilent?: boolean, shouldSchedule?: boolean) => void;
onClose: NoneToVoidFunction;
}; };
type StateProps = { type StateProps = {
@ -37,13 +38,12 @@ const StickerTooltip: FC<OwnProps & StateProps> = ({
chatId, chatId,
threadId, threadId,
isOpen, isOpen,
onStickerSelect,
onClose,
stickers, stickers,
isSavedMessages, isSavedMessages,
onStickerSelect,
isCurrentUserPremium, isCurrentUserPremium,
}) => { }) => {
const { clearStickersForEmoji } = getActions();
// eslint-disable-next-line no-null/no-null // eslint-disable-next-line no-null/no-null
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const { shouldRender, transitionClassNames } = useShowTransition(isOpen, undefined, undefined, false); const { shouldRender, transitionClassNames } = useShowTransition(isOpen, undefined, undefined, false);
@ -55,7 +55,7 @@ const StickerTooltip: FC<OwnProps & StateProps> = ({
observe: observeIntersection, observe: observeIntersection,
} = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE }); } = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE });
useEffect(() => (isOpen ? captureEscKeyListener(clearStickersForEmoji) : undefined), [isOpen, clearStickersForEmoji]); useEffect(() => (isOpen ? captureEscKeyListener(onClose) : undefined), [isOpen, onClose]);
const handleMouseMove = () => { const handleMouseMove = () => {
sendMessageAction({ type: 'chooseSticker' }); sendMessageAction({ type: 'chooseSticker' });

View File

@ -1,3 +1,4 @@
import type { Signal } from '../../../util/signals';
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '../../../lib/teact/teact';
import React, { memo, useCallback, useEffect } from '../../../lib/teact/teact'; import React, { memo, useCallback, useEffect } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
@ -8,12 +9,14 @@ import type { ISettings } from '../../../types';
import { RE_LINK_TEMPLATE } from '../../../config'; import { RE_LINK_TEMPLATE } from '../../../config';
import { selectTabState, selectNoWebPage, selectTheme } from '../../../global/selectors'; import { selectTabState, selectNoWebPage, selectTheme } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName';
import parseMessageInput from '../../../util/parseMessageInput'; import parseMessageInput from '../../../util/parseMessageInput';
import useSyncEffect from '../../../hooks/useSyncEffect'; import useSyncEffect from '../../../hooks/useSyncEffect';
import useShowTransition from '../../../hooks/useShowTransition'; import useShowTransition from '../../../hooks/useShowTransition';
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
import useDebouncedMemo from '../../../hooks/useDebouncedMemo'; import useDerivedState from '../../../hooks/useDerivedState';
import buildClassName from '../../../util/buildClassName'; import useDerivedSignal from '../../../hooks/useDerivedSignal';
import { useDebouncedResolver } from '../../../hooks/useAsyncResolvers';
import WebPage from '../message/WebPage'; import WebPage from '../message/WebPage';
import Button from '../../ui/Button'; import Button from '../../ui/Button';
@ -23,8 +26,8 @@ import './WebPagePreview.scss';
type OwnProps = { type OwnProps = {
chatId: string; chatId: string;
threadId: number; threadId: number;
messageText: string; getHtml: Signal<string>;
disabled?: boolean; isDisabled?: boolean;
}; };
type StateProps = { type StateProps = {
@ -39,8 +42,8 @@ const RE_LINK = new RegExp(RE_LINK_TEMPLATE, 'i');
const WebPagePreview: FC<OwnProps & StateProps> = ({ const WebPagePreview: FC<OwnProps & StateProps> = ({
chatId, chatId,
threadId, threadId,
messageText, getHtml,
disabled, isDisabled,
webPagePreview, webPagePreview,
noWebPage, noWebPage,
theme, theme,
@ -51,39 +54,36 @@ const WebPagePreview: FC<OwnProps & StateProps> = ({
toggleMessageWebPage, toggleMessageWebPage,
} = getActions(); } = getActions();
const link = useDebouncedMemo(() => { const detectLinkDebounced = useDebouncedResolver(() => {
const { text, entities } = parseMessageInput(messageText); const { text, entities } = parseMessageInput(getHtml());
const linkEntity = entities?.find((entity): entity is ApiMessageEntityTextUrl => ( const linkEntity = entities?.find((entity): entity is ApiMessageEntityTextUrl => (
entity.type === ApiMessageEntityTypes.TextUrl entity.type === ApiMessageEntityTypes.TextUrl
)); ));
if (linkEntity) {
return linkEntity.url;
}
const textMatch = text.match(RE_LINK); return linkEntity?.url || text.match(RE_LINK)?.[0];
if (textMatch) { }, [getHtml], DEBOUNCE_MS, true);
return textMatch[0];
}
return undefined; const getLink = useDerivedSignal(detectLinkDebounced, [detectLinkDebounced, getHtml], true);
}, DEBOUNCE_MS, [messageText]);
useEffect(() => { useEffect(() => {
const link = getLink();
if (link) { if (link) {
loadWebPagePreview({ text: link }); loadWebPagePreview({ text: link });
} else { } else {
clearWebPagePreview(); clearWebPagePreview();
toggleMessageWebPage({ chatId, threadId }); toggleMessageWebPage({ chatId, threadId });
} }
}, [chatId, toggleMessageWebPage, clearWebPagePreview, link, loadWebPagePreview, threadId]); }, [getLink, chatId, threadId, clearWebPagePreview, loadWebPagePreview, toggleMessageWebPage]);
useSyncEffect(() => { useSyncEffect(() => {
clearWebPagePreview(); clearWebPagePreview();
toggleMessageWebPage({ chatId, threadId }); toggleMessageWebPage({ chatId, threadId });
}, [chatId, clearWebPagePreview, threadId, toggleMessageWebPage]); }, [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 { shouldRender, transitionClassNames } = useShowTransition(isShown);
const renderingWebPage = useCurrentOrPrev(webPagePreview, true); const renderingWebPage = useCurrentOrPrev(webPagePreview, true);

View File

@ -1,68 +1,56 @@
import { import { useEffect, useState } from '../../../../lib/teact/teact';
useCallback, useEffect, useState,
} from '../../../../lib/teact/teact';
import type { ApiBotCommand } from '../../../../api/types'; import type { ApiBotCommand } from '../../../../api/types';
import type { Signal } from '../../../../util/signals';
import { prepareForRegExp } from '../helpers/prepareForRegExp'; import { prepareForRegExp } from '../helpers/prepareForRegExp';
import { throttle } from '../../../../util/schedulers';
import useFlag from '../../../../hooks/useFlag'; 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( export default function useBotCommandTooltip(
isAllowed: boolean, isEnabled: boolean,
html: string, getHtml: Signal<string>,
botCommands?: ApiBotCommand[] | false, botCommands?: ApiBotCommand[] | false,
chatBotCommands?: ApiBotCommand[], chatBotCommands?: ApiBotCommand[],
) { ) {
const [isOpen, markIsOpen, unmarkIsOpen] = useFlag();
const [filteredBotCommands, setFilteredBotCommands] = useState<ApiBotCommand[] | undefined>(); const [filteredBotCommands, setFilteredBotCommands] = useState<ApiBotCommand[] | undefined>();
const [isManuallyClosed, markManuallyClosed, unmarkManuallyClosed] = useFlag(false);
const getFilteredCommands = useCallback((filter) => { const detectCommandThrottled = useThrottledResolver(() => {
if (!botCommands && !chatBotCommands) { const html = getHtml();
setFilteredBotCommands(undefined); return isEnabled && html.startsWith('/') ? prepareForRegExp(html).match(RE_COMMAND)?.[0].trim() : undefined;
}, [getHtml, isEnabled], THROTTLE);
return; const getCommand = useDerivedSignal(
} detectCommandThrottled, [detectCommandThrottled, getHtml], true,
);
runThrottled(() => {
const nextFilteredBotCommands = (botCommands || chatBotCommands || [])
.filter(({ command }) => !filter || command.includes(filter));
setFilteredBotCommands(
nextFilteredBotCommands && nextFilteredBotCommands.length ? nextFilteredBotCommands : undefined,
);
});
}, [botCommands, chatBotCommands]);
useEffect(() => { useEffect(() => {
if (!isAllowed || !html.length) { const command = getCommand();
const commands = botCommands || chatBotCommands;
if (!command || !commands) {
setFilteredBotCommands(undefined); setFilteredBotCommands(undefined);
return; return;
} }
const shouldShowCommands = html.startsWith('/'); const filter = command.substring(1);
const nextFilteredBotCommands = commands.filter((c) => !filter || c.command.includes(filter));
if (shouldShowCommands) { setFilteredBotCommands(
const filter = prepareForRegExp(html.substr(1)).match(RE_COMMAND); nextFilteredBotCommands?.length ? nextFilteredBotCommands : undefined,
getFilteredCommands(filter ? filter[0] : ''); );
} else { }, [getCommand, botCommands, chatBotCommands]);
setFilteredBotCommands(undefined);
}
}, [getFilteredCommands, html, isAllowed, unmarkIsOpen]);
useEffect(() => { useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]);
if (filteredBotCommands && filteredBotCommands.length && html.length > 0) {
markIsOpen();
} else {
unmarkIsOpen();
}
}, [filteredBotCommands, html.length, markIsOpen, unmarkIsOpen]);
return { return {
isOpen, isOpen: Boolean(filteredBotCommands?.length && !isManuallyClosed),
close: unmarkIsOpen, close: markManuallyClosed,
filteredBotCommands, filteredBotCommands,
}; };
} }

View File

@ -1,90 +1,100 @@
import { useCallback, useEffect, useState } from '../../../../lib/teact/teact'; import type { RefObject } from 'react';
import { getActions } from '../../../../global'; import { useCallback, useEffect } from '../../../../lib/teact/teact';
import twemojiRegex from '../../../../lib/twemojiRegex';
import type { ApiSticker } from '../../../../api/types'; import type { ApiSticker } from '../../../../api/types';
import type { Signal } from '../../../../util/signals';
import { getActions } from '../../../../global';
import { EMOJI_IMG_REGEX } from '../../../../config'; import { EMOJI_IMG_REGEX } from '../../../../config';
import { IS_EMOJI_SUPPORTED } from '../../../../util/environment'; import { IS_EMOJI_SUPPORTED } from '../../../../util/environment';
import { getHtmlBeforeSelection } from '../../../../util/selection'; import { getHtmlBeforeSelection } from '../../../../util/selection';
import focusEditableElement from '../../../../util/focusEditableElement'; import focusEditableElement from '../../../../util/focusEditableElement';
import twemojiRegex from '../../../../lib/twemojiRegex';
import { buildCustomEmojiHtml } from '../helpers/customEmoji'; import { buildCustomEmojiHtml } from '../helpers/customEmoji';
import useOnSelectionChange from '../../../../hooks/useOnSelectionChange'; import useDerivedState from '../../../../hooks/useDerivedState';
import useCacheBuster from '../../../../hooks/useCacheBuster'; 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 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( export default function useCustomEmojiTooltip(
isAllowed: boolean, isEnabled: boolean,
inputSelector: string, getHtml: Signal<string>,
html: string, setHtml: (html: string) => void,
onUpdateHtml: (html: string) => void, getSelectionRange: Signal<Range | undefined>,
stickers?: ApiSticker[], inputRef: RefObject<HTMLDivElement>,
isDisabled = false, customEmojis?: ApiSticker[],
) { ) {
const { loadCustomEmojiForEmoji, clearCustomEmojiForEmoji } = getActions(); 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) => { const hasEmoji = html.match(IS_EMOJI_SUPPORTED ? twemojiRegex : EMOJI_IMG_REGEX);
if (range.collapsed) { if (!hasEmoji) return undefined;
updateCacheBuster(); // Update tooltip on cursor move
}
}, [updateCacheBuster]);
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(() => { useEffect(() => {
if (!html) { if (!isEnabled) return;
setHtmlBeforeSelection('');
return;
}
setHtmlBeforeSelection(getHtmlBeforeSelection(document.querySelector<HTMLDivElement>(inputSelector)!));
}, [html, inputSelector, cacheBuster]);
const lastEmojiText = htmlBeforeSelection.match(IS_EMOJI_SUPPORTED ? RE_ENDS_ON_EMOJI : ENDS_ON_EMOJI_IMG_REGEX)?.[0]; const lastEmoji = getLastEmoji();
const hasStickers = Boolean(stickers?.length && lastEmojiText); if (lastEmoji) {
if (!hasCustomEmojis) {
useEffect(() => { loadCustomEmojiForEmoji({
if (isDisabled) return; emoji: IS_EMOJI_SUPPORTED ? lastEmoji : lastEmoji.match(/.+alt="(.+)"/)?.[1]!,
});
if (isAllowed && lastEmojiText) { }
loadCustomEmojiForEmoji({ } else {
emoji: IS_EMOJI_SUPPORTED ? lastEmojiText : lastEmojiText.match(/.+alt="(.+)"/)?.[1]!,
});
} else if (hasStickers || !lastEmojiText) {
clearCustomEmojiForEmoji(); clearCustomEmojiForEmoji();
} }
// We omit `hasStickers` here to prevent re-fetching after manually closing tooltip (via <Esc>). }, [isEnabled, getLastEmoji, hasCustomEmojis, clearCustomEmojiForEmoji, loadCustomEmojiForEmoji]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [lastEmojiText, clearCustomEmojiForEmoji, loadCustomEmojiForEmoji, isAllowed, isDisabled]);
const insertCustomEmoji = useCallback((emoji: ApiSticker) => { const insertCustomEmoji = useCallback((emoji: ApiSticker) => {
if (!lastEmojiText) return; const lastEmoji = getLastEmoji();
const containerEl = document.querySelector<HTMLDivElement>(inputSelector)!; if (!isEnabled || !lastEmoji) return;
const regexText = IS_EMOJI_SUPPORTED ? lastEmojiText
const inputEl = inputRef.current!;
const htmlBeforeSelection = getHtmlBeforeSelection(inputEl);
const regexText = IS_EMOJI_SUPPORTED
? lastEmoji
// Escape regexp special chars // Escape regexp special chars
: lastEmojiText.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&'); : lastEmoji.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
const regex = new RegExp(`(${regexText})\\1*$`, ''); const regex = new RegExp(`(${regexText})\\1*$`, '');
const matched = htmlBeforeSelection.match(regex)![0]; 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 newHtml = htmlBeforeSelection.replace(regex, buildCustomEmojiHtml(emoji).repeat(count));
const htmlAfterSelection = containerEl.innerHTML.substring(htmlBeforeSelection.length); const htmlAfterSelection = inputEl.innerHTML.substring(htmlBeforeSelection.length);
onUpdateHtml(`${newHtml}${htmlAfterSelection}`);
setHtml(`${newHtml}${htmlAfterSelection}`);
requestAnimationFrame(() => { requestAnimationFrame(() => {
focusEditableElement(containerEl, true, true); focusEditableElement(inputEl, true, true);
}); });
}, [htmlBeforeSelection, inputSelector, lastEmojiText, onUpdateHtml]); }, [getLastEmoji, isEnabled, inputRef, setHtml]);
useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]);
return { return {
isCustomEmojiTooltipOpen: hasStickers, isCustomEmojiTooltipOpen: Boolean(isActive && hasCustomEmojis && !isManuallyClosed),
closeCustomEmojiTooltip: clearCustomEmojiForEmoji, closeCustomEmojiTooltip: markManuallyClosed,
insertCustomEmoji, insertCustomEmoji,
}; };
} }

View File

@ -1,70 +1,72 @@
import { useCallback, useEffect, useMemo } from '../../../../lib/teact/teact'; import { useCallback, useEffect } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global'; import { getActions } from '../../../../global';
import type { ApiFormattedText, ApiMessage } from '../../../../api/types'; 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 { DRAFT_DEBOUNCE, EDITABLE_INPUT_CSS_SELECTOR } from '../../../../config';
import usePrevious from '../../../../hooks/usePrevious'; import { IS_TOUCH_ENV } from '../../../../util/environment';
import { debounce } from '../../../../util/schedulers';
import focusEditableElement from '../../../../util/focusEditableElement'; import focusEditableElement from '../../../../util/focusEditableElement';
import parseMessageInput from '../../../../util/parseMessageInput'; import parseMessageInput from '../../../../util/parseMessageInput';
import { getTextWithEntitiesAsHtml } from '../../../common/helpers/renderTextWithEntities';
import useBackgroundMode from '../../../../hooks/useBackgroundMode'; import useBackgroundMode from '../../../../hooks/useBackgroundMode';
import useBeforeUnload from '../../../../hooks/useBeforeUnload'; import useBeforeUnload from '../../../../hooks/useBeforeUnload';
import { IS_TOUCH_ENV } from '../../../../util/environment'; import { useStateRef } from '../../../../hooks/useStateRef';
import { getTextWithEntitiesAsHtml } from '../../../common/helpers/renderTextWithEntities'; import useEffectWithPrevDeps from '../../../../hooks/useEffectWithPrevDeps';
import useRunDebounced from '../../../../hooks/useRunDebounced';
// Used to avoid running debounced callbacks when chat changes. let isFrozen = false;
let currentChatId: string | undefined;
let currentThreadId: number | undefined; function freeze() {
isFrozen = true;
requestAnimationFrame(() => {
isFrozen = false;
});
}
const useDraft = ( const useDraft = (
draft: ApiFormattedText | undefined, draft: ApiFormattedText | undefined,
chatId: string, chatId: string,
threadId: number, threadId: number,
htmlRef: { current: string }, getHtml: Signal<string>,
setHtml: (html: string) => void, setHtml: (html: string) => void,
editedMessage: ApiMessage | undefined, editedMessage: ApiMessage | undefined,
lastSyncTime?: number, lastSyncTime?: number,
) => { ) => {
const { saveDraft, clearDraft, loadCustomEmojis } = getActions(); const { saveDraft, clearDraft, loadCustomEmojis } = getActions();
const prevDraft = usePrevious(draft);
const updateDraft = useCallback((draftChatId: string, draftThreadId: number) => { const isEditing = Boolean(editedMessage);
const currentHtml = htmlRef.current;
if (currentHtml === undefined || editedMessage || !lastSyncTime) return; const updateDraft = useCallback((prevState: { chatId?: string; threadId?: number } = {}) => {
if (currentHtml.length) { if (isEditing || !lastSyncTime) return;
saveDraft({ chatId: draftChatId, threadId: draftThreadId, draft: parseMessageInput(currentHtml!) });
const html = getHtml();
if (html) {
saveDraft({
chatId: prevState.chatId ?? chatId,
threadId: prevState.threadId ?? threadId,
draft: parseMessageInput(html),
});
} else { } 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 updateDraftRef = useStateRef(updateDraft);
const runDebouncedForSaveDraft = useMemo(() => debounce((cb) => cb(), DRAFT_DEBOUNCE, false), [chatId]); const runDebouncedForSaveDraft = useRunDebounced(DRAFT_DEBOUNCE, true, undefined, [chatId, threadId]);
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]);
// Restore draft on chat change // Restore draft on chat change
useEffect(() => { useEffectWithPrevDeps(([prevChatId, prevThreadId, prevDraft]) => {
if (chatId === prevChatId && threadId === prevThreadId) { if (chatId === prevChatId && threadId === prevThreadId) {
if (!draft && prevDraft) { if (!draft && prevDraft) {
setHtml(''); setHtml('');
} }
return; 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; // Save draft on chat change
// Update draft when input changes
const prevHtml = usePrevious(html);
useEffect(() => { 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; return;
} }
if (html.length) { if (!getHtml()) {
runDebouncedForSaveDraft(() => { updateDraftRef.current();
if (currentChatId !== chatId || currentThreadId !== threadId) {
return;
}
updateDraft(chatId, threadId); return;
});
} else {
updateDraft(chatId, threadId);
} }
}, [chatId, html, prevChatId, prevHtml, prevThreadId, runDebouncedForSaveDraft, threadId, updateDraft]);
const handleBlur = useCallback(() => { const scopedShatId = chatIdRef.current;
if (chatId && threadId) { const scopedThreadId = threadIdRef.current;
updateDraft(chatId, threadId);
}
}, [chatId, threadId, updateDraft]);
useBackgroundMode(handleBlur); runDebouncedForSaveDraft(() => {
useBeforeUnload(handleBlur); if (chatIdRef.current === scopedShatId && threadIdRef.current === scopedThreadId) {
updateDraftRef.current();
}
});
}, [chatIdRef, getHtml, runDebouncedForSaveDraft, threadIdRef, updateDraftRef]);
useBackgroundMode(updateDraft);
useBeforeUnload(updateDraft);
}; };
export default useDraft; export default useDraft;

View File

@ -3,6 +3,7 @@ import { getActions } from '../../../../global';
import type { ApiFormattedText, ApiMessage } from '../../../../api/types'; import type { ApiFormattedText, ApiMessage } from '../../../../api/types';
import type { MessageListType } from '../../../../global/types'; import type { MessageListType } from '../../../../global/types';
import type { Signal } from '../../../../util/signals';
import useEffectWithPrevDeps from '../../../../hooks/useEffectWithPrevDeps'; import useEffectWithPrevDeps from '../../../../hooks/useEffectWithPrevDeps';
import { EDITABLE_INPUT_CSS_SELECTOR } from '../../../../config'; import { EDITABLE_INPUT_CSS_SELECTOR } from '../../../../config';
@ -15,7 +16,7 @@ import useBackgroundMode from '../../../../hooks/useBackgroundMode';
import useBeforeUnload from '../../../../hooks/useBeforeUnload'; import useBeforeUnload from '../../../../hooks/useBeforeUnload';
const useEditing = ( const useEditing = (
htmlRef: { current: string }, getHtml: Signal<string>,
setHtml: (html: string) => void, setHtml: (html: string) => void,
editedMessage: ApiMessage | undefined, editedMessage: ApiMessage | undefined,
resetComposer: (shouldPreserveInput?: boolean) => void, resetComposer: (shouldPreserveInput?: boolean) => void,
@ -47,6 +48,7 @@ const useEditing = (
const text = !prevEditedMessage && editingDraft?.text.length ? editingDraft : editedMessage.content.text; const text = !prevEditedMessage && editingDraft?.text.length ? editingDraft : editedMessage.content.text;
const html = getTextWithEntitiesAsHtml(text); const html = getTextWithEntitiesAsHtml(text);
setHtml(html); setHtml(html);
setShouldForceShowEditing(true); setShouldForceShowEditing(true);
// `fastRaf` would execute syncronously in this case // `fastRaf` would execute syncronously in this case
@ -62,14 +64,14 @@ const useEditing = (
useEffect(() => { useEffect(() => {
if (!editedMessage) return undefined; if (!editedMessage) return undefined;
return () => { return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps const edited = parseMessageInput(getHtml());
const edited = parseMessageInput(htmlRef.current!);
const update = edited.text.length ? edited : undefined; const update = edited.text.length ? edited : undefined;
setEditingDraft({ setEditingDraft({
chatId, threadId, type, text: update, chatId, threadId, type, text: update,
}); });
}; };
}, [chatId, editedMessage, htmlRef, setEditingDraft, threadId, type]); }, [chatId, editedMessage, getHtml, setEditingDraft, threadId, type]);
const restoreNewDraftAfterEditing = useCallback(() => { const restoreNewDraftAfterEditing = useCallback(() => {
if (!draft) return; if (!draft) return;
@ -91,7 +93,7 @@ const useEditing = (
}, [resetComposer, restoreNewDraftAfterEditing]); }, [resetComposer, restoreNewDraftAfterEditing]);
const handleEditComplete = useCallback(() => { const handleEditComplete = useCallback(() => {
const { text, entities } = parseMessageInput(htmlRef.current!); const { text, entities } = parseMessageInput(getHtml());
if (!editedMessage) { if (!editedMessage) {
return; return;
@ -109,16 +111,17 @@ const useEditing = (
resetComposer(); resetComposer();
restoreNewDraftAfterEditing(); restoreNewDraftAfterEditing();
}, [editMessage, editedMessage, htmlRef, openDeleteModal, resetComposer, restoreNewDraftAfterEditing]); }, [editMessage, editedMessage, getHtml, openDeleteModal, resetComposer, restoreNewDraftAfterEditing]);
const handleBlur = useCallback(() => { const handleBlur = useCallback(() => {
if (!editedMessage) return; if (!editedMessage) return;
const edited = parseMessageInput(htmlRef.current!); const edited = parseMessageInput(getHtml());
const update = edited.text.length ? edited : undefined; const update = edited.text.length ? edited : undefined;
setEditingDraft({ setEditingDraft({
chatId, threadId, type, text: update, chatId, threadId, type, text: update,
}); });
}, [chatId, editedMessage, htmlRef, setEditingDraft, threadId, type]); }, [chatId, editedMessage, getHtml, setEditingDraft, threadId, type]);
useBackgroundMode(handleBlur); useBackgroundMode(handleBlur);
useBeforeUnload(handleBlur); useBeforeUnload(handleBlur);

View File

@ -1,10 +1,10 @@
import { import { useCallback, useEffect, useState } from '../../../../lib/teact/teact';
useCallback, useEffect, useState,
} from '../../../../lib/teact/teact';
import { getGlobal } from '../../../../global'; import { getGlobal } from '../../../../global';
import type { ApiSticker } from '../../../../api/types'; import type { ApiSticker } from '../../../../api/types';
import type { EmojiData, EmojiModule, EmojiRawData } from '../../../../util/emoji'; 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 { EDITABLE_INPUT_CSS_SELECTOR, EDITABLE_INPUT_ID } from '../../../../config';
import { import {
@ -12,7 +12,6 @@ import {
} from '../../../../util/iteratees'; } from '../../../../util/iteratees';
import { MEMO_EMPTY_ARRAY } from '../../../../util/memo'; import { MEMO_EMPTY_ARRAY } from '../../../../util/memo';
import { prepareForRegExp } from '../helpers/prepareForRegExp'; import { prepareForRegExp } from '../helpers/prepareForRegExp';
import { uncompressEmoji } from '../../../../util/emoji';
import focusEditableElement from '../../../../util/focusEditableElement'; import focusEditableElement from '../../../../util/focusEditableElement';
import memoized from '../../../../util/memoized'; import memoized from '../../../../util/memoized';
import renderText from '../../../common/helpers/renderText'; import renderText from '../../../common/helpers/renderText';
@ -20,7 +19,8 @@ import { selectCustomEmojiForEmojis } from '../../../../global/selectors';
import { buildCustomEmojiHtml } from '../helpers/customEmoji'; import { buildCustomEmojiHtml } from '../helpers/customEmoji';
import useFlag from '../../../../hooks/useFlag'; import useFlag from '../../../../hooks/useFlag';
import useDebouncedCallback from '../../../../hooks/useDebouncedCallback'; import useDerivedSignal from '../../../../hooks/useDerivedSignal';
import { useThrottledResolver } from '../../../../hooks/useAsyncResolvers';
interface Library { interface Library {
keywords: string[]; keywords: string[];
@ -37,7 +37,7 @@ let RE_EMOJI_SEARCH: RegExp;
const EMOJIS_LIMIT = 36; const EMOJIS_LIMIT = 36;
const FILTER_MIN_LENGTH = 2; const FILTER_MIN_LENGTH = 2;
const DEBOUNCE = 300; const THROTTLE = 300;
const prepareRecentEmojisMemo = memoized(prepareRecentEmojis); const prepareRecentEmojisMemo = memoized(prepareRecentEmojis);
const prepareLibraryMemo = memoized(prepareLibrary); const prepareLibraryMemo = memoized(prepareLibrary);
@ -51,69 +51,94 @@ try {
} }
export default function useEmojiTooltip( export default function useEmojiTooltip(
isAllowed: boolean, isEnabled: boolean,
htmlRef: { current: string }, getHtml: Signal<string>,
recentEmojiIds: string[], setHtml: (html: string) => void,
inputId = EDITABLE_INPUT_ID, inputId = EDITABLE_INPUT_ID,
onUpdateHtml: (html: string) => void, recentEmojiIds: string[],
baseEmojiKeywords?: Record<string, string[]>, baseEmojiKeywords?: Record<string, string[]>,
emojiKeywords?: Record<string, string[]>, emojiKeywords?: Record<string, string[]>,
isDisabled = false,
) { ) {
const [isOpen, markIsOpen, unmarkIsOpen] = useFlag(); const [isManuallyClosed, markManuallyClosed, unmarkManuallyClosed] = useFlag(false);
const [byId, setById] = useState<Record<string, Emoji> | undefined>(); const [byId, setById] = useState<Record<string, Emoji> | undefined>();
const [shouldForceInsertEmoji, setShouldForceInsertEmoji] = useState(false); const [filteredEmojis, setFilteredEmojis] = useState<Emoji[]>(MEMO_EMPTY_ARRAY);
const [filteredEmojis, setFilteredEmojisInner] = useState<Emoji[]>(MEMO_EMPTY_ARRAY);
const [filteredCustomEmojis, setFilteredCustomEmojis] = useState<ApiSticker[]>(MEMO_EMPTY_ARRAY); const [filteredCustomEmojis, setFilteredCustomEmojis] = useState<ApiSticker[]>(MEMO_EMPTY_ARRAY);
const setFilteredEmojis = useDebouncedCallback((emojis: Emoji[]) => { // Initialize data on first render
setFilteredEmojisInner(emojis);
}, [], DEBOUNCE);
// Initialize data on first render.
useEffect(() => { useEffect(() => {
if (isDisabled) return; if (!isEnabled) return;
const exec = () => {
function exec() {
setById(emojiData.emojis); setById(emojiData.emojis);
}; }
if (emojiData) { if (emojiData) {
exec(); exec();
} else { } else {
ensureEmojiData() ensureEmojiData().then(exec);
.then(exec);
} }
}, [isDisabled]); }, [isEnabled]);
const html = htmlRef.current; const detectEmojiCodeThrottled = useThrottledResolver(() => {
useEffect(() => { const html = getHtml();
if (isDisabled) return; 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( const customEmojis = uniqueByField(
selectCustomEmojiForEmojis(getGlobal(), filteredEmojis.map((emoji) => emoji.native)), selectCustomEmojiForEmojis(getGlobal(), nativeEmojis),
'id', 'id',
); );
setFilteredCustomEmojis(customEmojis); 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<HTMLDivElement>(EDITABLE_INPUT_CSS_SELECTOR)!
: document.getElementById(inputId) as HTMLDivElement;
requestAnimationFrame(() => {
focusEditableElement(messageInput, true, true);
});
}
updateFiltered(MEMO_EMPTY_ARRAY);
}, [getHtml, setHtml, inputId, updateFiltered]);
useEffect(() => { useEffect(() => {
if (!isAllowed || !html || !byId || isDisabled) { const emojiCode = getEmojiCode();
unmarkIsOpen(); if (!emojiCode || !byId) {
updateFiltered(MEMO_EMPTY_ARRAY);
return; return;
} }
const code = html.includes(':') && getEmojiCode(html); const newShouldAutoInsert = emojiCode.length > 2 && emojiCode.endsWith(':');
if (!code) {
setFilteredEmojis(MEMO_EMPTY_ARRAY);
unmarkIsOpen();
return;
}
const forceSend = code.length > 2 && code.endsWith(':'); const filter = emojiCode.substring(1, newShouldAutoInsert ? 1 + emojiCode.length - 2 : undefined);
const filter = code.substr(1, forceSend ? code.length - 2 : undefined);
let matched: Emoji[] = MEMO_EMPTY_ARRAY; let matched: Emoji[] = MEMO_EMPTY_ARRAY;
setShouldForceInsertEmoji(forceSend);
if (!filter) { if (!filter) {
matched = prepareRecentEmojisMemo(byId, recentEmojiIds, EMOJIS_LIMIT); matched = prepareRecentEmojisMemo(byId, recentEmojiIds, EMOJIS_LIMIT);
} else if (filter.length >= FILTER_MIN_LENGTH) { } else if (filter.length >= FILTER_MIN_LENGTH) {
@ -121,79 +146,30 @@ export default function useEmojiTooltip(
matched = searchInLibraryMemo(library, filter, EMOJIS_LIMIT); matched = searchInLibraryMemo(library, filter, EMOJIS_LIMIT);
} }
if (matched.length) { if (!matched.length) {
if (!forceSend) { return;
markIsOpen(); }
}
setFilteredEmojis(matched); if (newShouldAutoInsert) {
insertEmoji(matched[0].native, true);
} else { } else {
unmarkIsOpen(); updateFiltered(matched);
} }
}, [ }, [
byId, html, isAllowed, markIsOpen, recentEmojiIds, unmarkIsOpen, setShouldForceInsertEmoji, baseEmojiKeywords, byId, getEmojiCode, emojiKeywords, insertEmoji, recentEmojiIds, updateFiltered,
isDisabled, baseEmojiKeywords, emojiKeywords, setFilteredEmojis,
]); ]);
const insertEmoji = useCallback((textEmoji: string, isForce?: boolean) => { useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]);
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<HTMLDivElement>(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<HTMLDivElement>(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]);
return { return {
isEmojiTooltipOpen: isOpen, isEmojiTooltipOpen: Boolean(filteredEmojis.length || filteredCustomEmojis.length) && !isManuallyClosed,
closeEmojiTooltip: unmarkIsOpen, closeEmojiTooltip: markManuallyClosed,
filteredEmojis, filteredEmojis,
filteredCustomEmojis, filteredCustomEmojis,
insertEmoji, insertEmoji,
insertCustomEmoji,
}; };
} }
function getEmojiCode(html: string) {
const emojis = prepareForRegExp(html).match(RE_EMOJI_SEARCH);
return emojis ? emojis[0].trim() : undefined;
}
async function ensureEmojiData() { async function ensureEmojiData() {
if (!emojiDataPromise) { if (!emojiDataPromise) {
emojiDataPromise = import('emoji-data-ios/emoji-data.json') as unknown as Promise<EmojiModule>; emojiDataPromise = import('emoji-data-ios/emoji-data.json') as unknown as Promise<EmojiModule>;

View File

@ -1,11 +1,17 @@
import { useCallback, useEffect } from '../../../../lib/teact/teact'; 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 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 HAS_NEW_LINE = /^@([a-z0-9_]{1,32})[\u00A0\u0020]+\n{2,}/i;
const MEMO_NO_RESULT = { const MEMO_NO_RESULT = {
@ -18,20 +24,40 @@ const MEMO_NO_RESULT = {
const tempEl = document.createElement('div'); const tempEl = document.createElement('div');
export default function useInlineBotTooltip( export default function useInlineBotTooltip(
isAllowed: boolean, isEnabled: boolean,
chatId: string, chatId: string,
html: string, getHtml: Signal<string>,
inlineBots?: Record<string, false | InlineBotSettings>, inlineBots?: Record<string, false | InlineBotSettings>,
) { ) {
const { queryInlineBot, resetInlineBot, resetAllInlineBots } = getActions(); 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 { const {
username, query, canShowHelp, usernameLowered, username, query, canShowHelp, usernameLowered,
} = useDebouncedMemo(() => parseBotQuery(html), DEBOUNCE_MS, [html]) || {}; } = useDerivedState(extractBotQueryThrottled, [extractBotQueryThrottled, getHtml], true);
const prevQuery = usePrevious(query);
const prevUsername = usePrevious(username); useSyncEffect(([prevUsername]) => {
const inlineBotData = usernameLowered ? inlineBots?.[usernameLowered] : undefined; 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 { const {
id: botId, id: botId,
switchPm, switchPm,
@ -39,13 +65,9 @@ export default function useInlineBotTooltip(
results, results,
isGallery, isGallery,
help, help,
} = inlineBotData || {}; } = (usernameLowered && inlineBots?.[usernameLowered]) || {};
useEffect(() => { const isOpen = Boolean((results?.length || switchPm) && !isManuallyClosed);
if (prevQuery !== query) {
unmarkIsOpen();
}
}, [prevQuery, query, unmarkIsOpen]);
useEffect(() => { useEffect(() => {
if (!isOpen && !username) { if (!isOpen && !username) {
@ -53,44 +75,33 @@ export default function useInlineBotTooltip(
} }
}, [isOpen, resetAllInlineBots, username]); }, [isOpen, resetAllInlineBots, username]);
useEffect(() => {
if (isAllowed && usernameLowered && chatId) {
queryInlineBot({ chatId, username: usernameLowered, query: query! });
}
}, [query, isAllowed, queryInlineBot, chatId, usernameLowered]);
const loadMore = useCallback(() => { const loadMore = useCallback(() => {
if (isAllowed && usernameLowered && chatId) { if (!usernameLowered) return;
queryInlineBot({
chatId, username: usernameLowered, query: query!, offset,
});
}
}, [isAllowed, usernameLowered, chatId, queryInlineBot, query, offset]);
useEffect(() => { queryInlineBot({
if (isAllowed && botId && (switchPm || (results?.length))) { chatId, username: usernameLowered, query, offset,
markIsOpen(); });
} else { }, [chatId, offset, query, queryInlineBot, usernameLowered]);
unmarkIsOpen();
}
}, [botId, isAllowed, markIsOpen, results, switchPm, unmarkIsOpen]);
if (prevUsername !== username) {
resetInlineBot({ username: prevUsername! });
}
return { return {
isOpen, isOpen,
id: botId, botId,
isGallery, isGallery,
switchPm, switchPm,
results, results,
closeTooltip: unmarkIsOpen, closeTooltip: markManuallyClosed,
help: canShowHelp && help ? `@${username} ${help}` : undefined, help: canShowHelp && help ? `@${username} ${help}` : undefined,
loadMore, loadMore,
}; };
} }
const buildQueryStateMemo = memoized((username: string, query: string, canShowHelp: boolean) => ({
username,
query,
canShowHelp,
usernameLowered: username.toLowerCase(),
}));
function parseBotQuery(html: string) { function parseBotQuery(html: string) {
if (!html.startsWith('@')) { if (!html.startsWith('@')) {
return MEMO_NO_RESULT; return MEMO_NO_RESULT;
@ -102,12 +113,7 @@ function parseBotQuery(html: string) {
return MEMO_NO_RESULT; return MEMO_NO_RESULT;
} }
return { return buildQueryStateMemo(result[1], result[2], result[2] === '' && !text.match(HAS_NEW_LINE));
username: result[1],
query: result[2],
canShowHelp: result[2] === '' && !text.match(HAS_NEW_LINE),
usernameLowered: result[1].toLowerCase(),
};
} }
function getPlainText(html: string) { function getPlainText(html: string) {

View File

@ -4,6 +4,7 @@ import {
import RLottie from '../../../../lib/rlottie/RLottie'; import RLottie from '../../../../lib/rlottie/RLottie';
import type { ApiSticker } from '../../../../api/types'; import type { ApiSticker } from '../../../../api/types';
import type { Signal } from '../../../../util/signals';
import { getGlobal } from '../../../../global'; import { getGlobal } from '../../../../global';
import { selectIsAlwaysHighPriorityEmoji } from '../../../../global/selectors'; import { selectIsAlwaysHighPriorityEmoji } from '../../../../global/selectors';
@ -31,7 +32,7 @@ type CustomEmojiPlayer = {
}; };
export default function useInputCustomEmojis( export default function useInputCustomEmojis(
html: string, getHtml: Signal<string>,
inputRef: React.RefObject<HTMLDivElement>, inputRef: React.RefObject<HTMLDivElement>,
sharedCanvasRef: React.RefObject<HTMLCanvasElement>, sharedCanvasRef: React.RefObject<HTMLCanvasElement>,
sharedCanvasHqRef: React.RefObject<HTMLCanvasElement>, sharedCanvasHqRef: React.RefObject<HTMLCanvasElement>,
@ -115,13 +116,13 @@ export default function useInputCustomEmojis(
}, [synchronizeElements]); }, [synchronizeElements]);
useEffect(() => { useEffect(() => {
if (!html || !inputRef.current || !sharedCanvasRef.current) { if (!getHtml() || !inputRef.current || !sharedCanvasRef.current) {
removeContainers(Array.from(mapRef.current.keys())); removeContainers(Array.from(mapRef.current.keys()));
return; return;
} }
synchronizeElements(); synchronizeElements();
}, [html, inputRef, removeContainers, sharedCanvasRef, synchronizeElements]); }, [getHtml, synchronizeElements, inputRef, removeContainers, sharedCanvasRef]);
useResizeObserver(sharedCanvasRef, synchronizeElements, true); useResizeObserver(sharedCanvasRef, synchronizeElements, true);
@ -157,7 +158,7 @@ function createPlayer({
position, position,
isHq, isHq,
isMobile, isMobile,
} : { }: {
customEmoji: ApiSticker; customEmoji: ApiSticker;
sharedCanvasRef: React.RefObject<HTMLCanvasElement>; sharedCanvasRef: React.RefObject<HTMLCanvasElement>;
sharedCanvasHqRef: React.RefObject<HTMLCanvasElement>; sharedCanvasHqRef: React.RefObject<HTMLCanvasElement>;

View File

@ -1,109 +1,94 @@
import type { RefObject } from 'react';
import { import {
useCallback, useEffect, useState, useCallback, useEffect, useState,
} from '../../../../lib/teact/teact'; } from '../../../../lib/teact/teact';
import { getGlobal } from '../../../../global'; import { getGlobal } from '../../../../global';
import type { ApiChatMember, ApiUser } from '../../../../api/types'; 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 { filterUsersByName, getMainUsername, getUserFirstOrLastName } from '../../../../global/helpers';
import { prepareForRegExp } from '../helpers/prepareForRegExp'; import { prepareForRegExp } from '../helpers/prepareForRegExp';
import focusEditableElement from '../../../../util/focusEditableElement'; import focusEditableElement from '../../../../util/focusEditableElement';
import { pickTruthy, unique } from '../../../../util/iteratees'; import { pickTruthy, unique } from '../../../../util/iteratees';
import { throttle } from '../../../../util/schedulers';
import { getHtmlBeforeSelection } from '../../../../util/selection'; import { getHtmlBeforeSelection } from '../../../../util/selection';
import useFlag from '../../../../hooks/useFlag'; import useFlag from '../../../../hooks/useFlag';
import useCacheBuster from '../../../../hooks/useCacheBuster'; import useDerivedSignal from '../../../../hooks/useDerivedSignal';
import useOnSelectionChange from '../../../../hooks/useOnSelectionChange'; import { useThrottledResolver } from '../../../../hooks/useAsyncResolvers';
const THROTTLE = 300;
const runThrottled = throttle((cb) => cb(), 500, true);
let RE_USERNAME_SEARCH: RegExp; let RE_USERNAME_SEARCH: RegExp;
try { try {
RE_USERNAME_SEARCH = /(^|\s)@[-_\p{L}\p{M}\p{N}]*$/gui; RE_USERNAME_SEARCH = /(^|\s)@[-_\p{L}\p{M}\p{N}]*$/gui;
} catch (e) { } catch (e) {
// Support for older versions of firefox // Support for older versions of Firefox
RE_USERNAME_SEARCH = /(^|\s)@[-_\d\wа-яё]*$/gi; RE_USERNAME_SEARCH = /(^|\s)@[-_\d\wа-яё]*$/gi;
} }
export default function useMentionTooltip( export default function useMentionTooltip(
canSuggestMembers: boolean | undefined, isEnabled: boolean,
inputSelector: string, getHtml: Signal<string>,
onUpdateHtml: (html: string) => void, setHtml: (html: string) => void,
getSelectionRange: Signal<Range | undefined>,
inputRef: RefObject<HTMLDivElement>,
groupChatMembers?: ApiChatMember[], groupChatMembers?: ApiChatMember[],
topInlineBotIds?: string[], topInlineBotIds?: string[],
currentUserId?: string, currentUserId?: string,
) { ) {
const [isOpen, markIsOpen, unmarkIsOpen] = useFlag(); const [filteredUsers, setFilteredUsers] = useState<ApiUser[] | undefined>();
const [htmlBeforeSelection, setHtmlBeforeSelection] = useState(''); const [isManuallyClosed, markManuallyClosed, unmarkManuallyClosed] = useFlag(false);
const [usersToMention, setUsersToMention] = useState<ApiUser[] | undefined>();
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 // No need for expensive global updates on users, so we avoid them
const usersById = getGlobal().users.byId; const usersById = getGlobal().users.byId;
if (!usersById) {
if (!(groupChatMembers || topInlineBotIds) || !usersById) { setFilteredUsers(undefined);
setUsersToMention(undefined);
return; return;
} }
runThrottled(() => { const memberIds = groupChatMembers?.reduce((acc: string[], member) => {
const memberIds = groupChatMembers?.reduce((acc: string[], member) => { if (member.userId !== currentUserId) {
if (member.userId !== currentUserId) { acc.push(member.userId);
acc.push(member.userId); }
}
return acc; return acc;
}, []); }, []);
const filteredIds = filterUsersByName(unique([ const filter = usernameTag.substring(1);
...((withInlineBots && topInlineBotIds) || []), const filteredIds = filterUsersByName(unique([
...(memberIds || []), ...((getWithInlineBots() && topInlineBotIds) || []),
]), usersById, filter); ...(memberIds || []),
]), usersById, filter);
setUsersToMention(Object.values(pickTruthy(usersById, filteredIds))); setFilteredUsers(Object.values(pickTruthy(usersById, filteredIds)));
}); }, [currentUserId, groupChatMembers, topInlineBotIds, getUsernameTag, getWithInlineBots]);
}, [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<HTMLDivElement>(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]);
const insertMention = useCallback((user: ApiUser, forceFocus = false) => { const insertMention = useCallback((user: ApiUser, forceFocus = false) => {
if (!user.usernames && !getUserFirstOrLastName(user)) { if (!user.usernames && !getUserFirstOrLastName(user)) {
@ -111,7 +96,7 @@ export default function useMentionTooltip(
} }
const mainUsername = getMainUsername(user); const mainUsername = getMainUsername(user);
const insertedHtml = mainUsername const htmlToInsert = mainUsername
? `@${mainUsername}` ? `@${mainUsername}`
: `<a : `<a
class="text-entity-link" class="text-entity-link"
@ -121,43 +106,37 @@ export default function useMentionTooltip(
dir="auto" dir="auto"
>${getUserFirstOrLastName(user)}</a>`; >${getUserFirstOrLastName(user)}</a>`;
const containerEl = document.querySelector<HTMLDivElement>(inputSelector)!; const inputEl = inputRef.current!;
const htmlBeforeSelection = getHtmlBeforeSelection(inputEl);
const fixedHtmlBeforeSelection = cleanWebkitNewLines(htmlBeforeSelection); const fixedHtmlBeforeSelection = cleanWebkitNewLines(htmlBeforeSelection);
const atIndex = fixedHtmlBeforeSelection.lastIndexOf('@'); const atIndex = fixedHtmlBeforeSelection.lastIndexOf('@');
if (atIndex !== -1) { if (atIndex !== -1) {
const newHtml = `${fixedHtmlBeforeSelection.substr(0, atIndex)}${insertedHtml}&nbsp;`; const newHtml = `${fixedHtmlBeforeSelection.substr(0, atIndex)}${htmlToInsert}&nbsp;`;
const htmlAfterSelection = cleanWebkitNewLines(containerEl.innerHTML).substring(fixedHtmlBeforeSelection.length); const htmlAfterSelection = cleanWebkitNewLines(inputEl.innerHTML).substring(fixedHtmlBeforeSelection.length);
onUpdateHtml(`${newHtml}${htmlAfterSelection}`);
setHtml(`${newHtml}${htmlAfterSelection}`);
requestAnimationFrame(() => { requestAnimationFrame(() => {
focusEditableElement(containerEl, forceFocus); focusEditableElement(inputEl, forceFocus);
}); });
} }
unmarkIsOpen(); setFilteredUsers(undefined);
}, [htmlBeforeSelection, inputSelector, onUpdateHtml, unmarkIsOpen]); }, [inputRef, setHtml]);
useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]);
return { return {
isMentionTooltipOpen: isOpen, isMentionTooltipOpen: Boolean(filteredUsers?.length && !isManuallyClosed),
closeMentionTooltip: unmarkIsOpen, closeMentionTooltip: markManuallyClosed,
insertMention, 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 `<div><br /></div>` or `<div></div>` code. // Webkit replaces the line break with the `<div><br /></div>` or `<div></div>` code.
// It is necessary to clean the html to a single form before processing. // It is necessary to clean the html to a single form before processing.
function cleanWebkitNewLines(html: string) { function cleanWebkitNewLines(html: string) {
return html.replace(/<div>(<br>|<br\s?\/>)?<\/div>/gi, '<br>'); return html.replace(/<div>(<br>|<br\s?\/>)?<\/div>/gi, '<br>');
} }
function canSuggestInlineBots(html: string) {
return html.startsWith('@');
}

View File

@ -1,45 +1,69 @@
import { useEffect, useMemo } from '../../../../lib/teact/teact'; import { useEffect } from '../../../../lib/teact/teact';
import { getActions } from '../../../../global';
import type { ApiSticker } from '../../../../api/types'; import type { ApiSticker } from '../../../../api/types';
import type { Signal } from '../../../../util/signals';
import { getActions } from '../../../../global';
import { EMOJI_IMG_REGEX } from '../../../../config'; import { EMOJI_IMG_REGEX } from '../../../../config';
import { IS_EMOJI_SUPPORTED } from '../../../../util/environment'; import { IS_EMOJI_SUPPORTED } from '../../../../util/environment';
import parseEmojiOnlyString from '../../../../util/parseEmojiOnlyString'; import parseEmojiOnlyString from '../../../../util/parseEmojiOnlyString';
import twemojiRegex from '../../../../lib/twemojiRegex';
import { prepareForRegExp } from '../helpers/prepareForRegExp'; 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'); const STARTS_ENDS_ON_EMOJI_IMG_REGEX = new RegExp(`^${EMOJI_IMG_REGEX.source}$`, 'g');
export default function useStickerTooltip( export default function useStickerTooltip(
isAllowed: boolean, isEnabled: boolean,
html: string, getHtml: Signal<string>,
stickers?: ApiSticker[], stickers?: ApiSticker[],
isDisabled = false,
) { ) {
const cleanHtml = useMemo(() => prepareForRegExp(html).trim(), [html]);
const { loadStickersForEmoji, clearStickersForEmoji } = getActions(); const { loadStickersForEmoji, clearStickersForEmoji } = getActions();
const isSingleEmoji = (
(IS_EMOJI_SUPPORTED && parseEmojiOnlyString(cleanHtml) === 1) const [isManuallyClosed, markManuallyClosed, unmarkManuallyClosed] = useFlag(false);
|| (!IS_EMOJI_SUPPORTED && Boolean(html.match(STARTS_ENDS_ON_EMOJI_IMG_REGEX)))
); const getSingleEmoji = useDerivedSignal(() => {
const hasStickers = Boolean(stickers?.length) && isSingleEmoji; 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(() => { useEffect(() => {
if (isDisabled) return; if (!isEnabled) return;
if (isAllowed && isSingleEmoji) { const singleEmoji = getSingleEmoji();
loadStickersForEmoji({ if (singleEmoji) {
emoji: IS_EMOJI_SUPPORTED ? cleanHtml : cleanHtml.match(/alt="(.+)"/)?.[1]!, if (!hasStickers) {
}); loadStickersForEmoji({ emoji: singleEmoji });
} else if (hasStickers || !isSingleEmoji) { }
} else {
clearStickersForEmoji(); clearStickersForEmoji();
} }
// We omit `hasStickers` here to prevent re-fetching after manually closing tooltip (via <Esc>). }, [isEnabled, getSingleEmoji, hasStickers, loadStickersForEmoji, clearStickersForEmoji]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [html, isSingleEmoji, clearStickersForEmoji, loadStickersForEmoji, isAllowed, isDisabled]); useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]);
return { return {
isStickerTooltipOpen: hasStickers, isStickerTooltipOpen: Boolean(isActive && hasStickers && !isManuallyClosed),
closeStickerTooltip: clearStickersForEmoji, closeStickerTooltip: markManuallyClosed,
}; };
} }

View File

@ -0,0 +1,16 @@
import useThrottledCallback from './useThrottledCallback';
import useDebouncedCallback from './useDebouncedCallback';
export function useThrottledResolver<T>(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<T>(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);
}

View File

@ -6,8 +6,8 @@ export default function useDebouncedCallback<T extends AnyToVoidFunction>(
fn: T, fn: T,
deps: any[], deps: any[],
ms: number, ms: number,
noFirst?: boolean, noFirst = false,
noLast?: boolean, noLast = false,
) { ) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
const fnMemo = useCallback(fn, deps); const fnMemo = useCallback(fn, deps);

View File

@ -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> = () => T;
type AsyncResolver<T> = (setter: (newValue: T) => void) => void;
type Resolver<T> =
SyncResolver<T>
| AsyncResolver<T>;
function useDerivedSignal<T>(resolver: SyncResolver<T>, dependencies: readonly any[]): Signal<T>;
function useDerivedSignal<T>(resolver: AsyncResolver<T>, dependencies: readonly any[], isAsync: true): Signal<T>;
function useDerivedSignal<T>(dependency: T): Signal<T>;
function useDerivedSignal<T>(resolverOrDependency: Resolver<T> | T, dependencies?: readonly any[], isAsync = false) {
const resolver = dependencies ? resolverOrDependency as Resolver<T> : () => (resolverOrDependency as T);
dependencies ??= [resolverOrDependency];
const [getValue, setValue] = useSignal<T>();
const resolverRef = useStateRef(resolver);
function runCurrentResolver() {
const currentResolver = resolverRef.current;
if (isAsync) {
(currentResolver as AsyncResolver<T>)(setValue);
} else {
setValue((currentResolver as SyncResolver<T>)());
}
}
// 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<T>;
}
export default useDerivedSignal;

View File

@ -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> = () => T;
type AsyncResolver<T> = (setter: (newValue: T) => void) => void;
type Resolver<T> =
SyncResolver<T>
| AsyncResolver<T>;
function useDerivedState<T>(resolver: SyncResolver<T>, dependencies: readonly any[]): T;
function useDerivedState<T>(resolver: AsyncResolver<T>, dependencies: readonly any[], isAsync: true): T;
function useDerivedState<T>(signal: Signal<T>): T;
function useDerivedState<T>(resolverOrSignal: Resolver<T> | T, dependencies?: readonly any[], isAsync = false) {
const resolver = dependencies ? resolverOrSignal as Resolver<T> : () => ((resolverOrSignal as Signal<T>)());
dependencies ??= [resolverOrSignal];
const valueRef = useRef<T>();
const forceUpdate = useForceUpdate();
const resolverRef = useStateRef(resolver);
function runCurrentResolver(isSync = false) {
const currentResolver = resolverRef.current;
if (isAsync) {
(currentResolver as AsyncResolver<T>)((newValue) => {
if (valueRef.current !== newValue) {
valueRef.current = newValue;
forceUpdate();
}
});
} else {
const newValue = (currentResolver as SyncResolver<T>)();
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;

View File

@ -0,0 +1,38 @@
import { useEffect } from '../lib/teact/teact';
import useSignal from './useSignal';
export default function useGetSelectionRange(inputSelector: string) {
const [getRange, setRange] = useSignal<Range | undefined>();
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;
}

View File

@ -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]);
}

View File

@ -1,7 +1,8 @@
import useDebouncedCallback from './useDebouncedCallback'; 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) => { return useDebouncedCallback((cb: NoneToVoidFunction) => {
cb(); cb();
}, [], ms, noFirst, noLast); // eslint-disable-next-line react-hooks/exhaustive-deps
}, deps, ms, noFirst, noLast);
} }

View File

@ -1,7 +1,8 @@
import useThrottledCallback from './useThrottledCallback'; 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) => { return useThrottledCallback((cb: NoneToVoidFunction) => {
cb(); cb();
}, [], ms, noFirst); // eslint-disable-next-line react-hooks/exhaustive-deps
}, deps, ms, noFirst);
} }

8
src/hooks/useSignal.ts Normal file
View File

@ -0,0 +1,8 @@
import { useRef } from '../lib/teact/teact';
import { createSignal } from '../util/signals';
export default function useSignal<T>(initial?: T) {
const signalRef = useRef<ReturnType<typeof createSignal<T>>>();
signalRef.current ??= createSignal<T>(initial);
return signalRef.current;
}

View File

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

View File

@ -3,7 +3,7 @@ import { useRef } from '../lib/teact/teact';
import useSyncEffect from './useSyncEffect'; import useSyncEffect from './useSyncEffect';
// Allows to use state value as "silent" dependency in hooks (not causing updates). // 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<T>(value: T) { export function useStateRef<T>(value: T) {
const ref = useRef<T>(value); const ref = useRef<T>(value);

View File

@ -1,17 +1,22 @@
import { useCallback, useMemo } from '../lib/teact/teact'; 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<T extends AnyToVoidFunction>( export default function useThrottledCallback<T extends AnyToVoidFunction>(
fn: T, fn: T,
deps: any[], deps: any[],
ms: number, msOrRaf: number | typeof fastRaf,
noFirst?: boolean, noFirst = false,
) { ) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
const fnMemo = useCallback(fn, deps); const fnMemo = useCallback(fn, deps);
return useMemo(() => { return useMemo(() => {
return throttle(fnMemo, ms, !noFirst); if (typeof msOrRaf === 'number') {
}, [fnMemo, ms, noFirst]); return throttle(fnMemo, msOrRaf, !noFirst);
} else {
return throttleWithRaf(fnMemo);
}
}, [fnMemo, msOrRaf, noFirst]);
} }

View File

@ -10,6 +10,7 @@ import { orderBy } from '../../util/iteratees';
import { getUnequalProps } from '../../util/arePropsShallowEqual'; import { getUnequalProps } from '../../util/arePropsShallowEqual';
import { handleError } from '../../util/handleError'; import { handleError } from '../../util/handleError';
import { incrementOverlayCounter } from '../../util/debugOverlay'; import { incrementOverlayCounter } from '../../util/debugOverlay';
import { isSignal } from '../../util/signals';
export type Props = AnyLiteral; export type Props = AnyLiteral;
export type FC<P extends Props = any> = (props: P) => any; export type FC<P extends Props = any> = (props: P) => any;
@ -81,7 +82,9 @@ interface ComponentInstance {
cursor: number; cursor: number;
byCursor: { byCursor: {
dependencies?: readonly any[]; dependencies?: readonly any[];
schedule: NoneToVoidFunction;
cleanup?: NoneToVoidFunction; cleanup?: NoneToVoidFunction;
releaseSignals?: NoneToVoidFunction;
}[]; }[];
}; };
memos: { memos: {
@ -436,15 +439,15 @@ export function unmountComponent(componentInstance: ComponentInstance) {
idsToExcludeFromUpdate.add(componentInstance.id); idsToExcludeFromUpdate.add(componentInstance.id);
componentInstance.hooks.effects.byCursor.forEach((effect) => { componentInstance.hooks.effects.byCursor.forEach((effect) => {
if (effect.cleanup) { try {
try { effect.cleanup?.();
effect.cleanup(); } catch (err: any) {
} catch (err: any) { handleError(err);
handleError(err); } finally {
} finally { effect.cleanup = undefined;
effect.cleanup = undefined;
}
} }
effect.releaseSignals?.();
}); });
componentInstance.isMounted = false; 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 // We need to remove all references to DOM objects. We also clean all other references, just in case
function helpGc(componentInstance: ComponentInstance) { function helpGc(componentInstance: ComponentInstance) {
componentInstance.hooks.effects.byCursor.forEach((hook) => { componentInstance.hooks.effects.byCursor.forEach((hook) => {
hook.schedule = undefined as any;
hook.cleanup = undefined as any; hook.cleanup = undefined as any;
hook.releaseSignals = undefined as any;
hook.dependencies = undefined; hook.dependencies = undefined;
}); });
@ -667,11 +672,32 @@ function useEffectBase(
schedule(); schedule();
} }
const isFirstRun = !byCursor[cursor];
byCursor[cursor] = { byCursor[cursor] = {
...byCursor[cursor], ...byCursor[cursor],
dependencies, 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++; renderingInstance.hooks.effects.cursor++;
} }

View File

@ -1,4 +1,4 @@
const fragmentEl = document.createElement('div'); const extractorEl = document.createElement('div');
export function insertHtmlInSelection(html: string) { export function insertHtmlInSelection(html: string) {
const selection = window.getSelection(); const selection = window.getSelection();
@ -42,20 +42,9 @@ export function getHtmlBeforeSelection(container?: HTMLElement, useCommonAncesto
range.collapse(true); range.collapse(true);
range.setStart(container, 0); range.setStart(container, 0);
replaceChildren(fragmentEl, range.cloneContents());
return fragmentEl.innerHTML; extractorEl.innerHTML = '';
} extractorEl.appendChild(range.cloneContents());
function replaceChildren(el: HTMLElement, nodes?: DocumentFragment) { return extractorEl.innerHTML;
if (el.replaceChildren === undefined) {
while (el.lastChild) {
el.removeChild(el.lastChild);
}
if (nodes !== undefined) {
el.append(nodes);
}
} else {
el.replaceChildren(nodes || '');
}
} }

81
src/util/signals.ts Normal file
View File

@ -0,0 +1,81 @@
import type { CallbackManager } from './callbacks';
import { createCallbackManager } from './callbacks';
interface SignalState<T> {
value: T;
effects: CallbackManager;
}
const SIGNAL_MARK = Symbol('SIGNAL_MARK');
export type Signal<T = any> = ((() => 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<NoneToVoidFunction, Set<NoneToVoidFunction>>();
let currentEffect: NoneToVoidFunction | undefined;
export function createSignal<T>(defaultValue?: T) {
const state: SignalState<typeof defaultValue> = {
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<T>, {
[SIGNAL_MARK]: SIGNAL_MARK,
subscribe,
});
return [signal, setter] as const;
}
export function cleanupEffect(effect: NoneToVoidFunction) {
unsubscribesByEffect.get(effect)?.forEach((unsubscribe) => {
unsubscribe();
});
unsubscribesByEffect.delete(effect);
}