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": [
"error",
{
"additionalHooks": "(useSyncEffect|useAsync|useDebouncedCallback|useThrottledCallback|useEffectWithPrevDeps|useLayoutEffectWithPrevDeps)$"
"additionalHooks": "(useSyncEffect|useAsync|useDebouncedCallback|useThrottledCallback|useEffectWithPrevDeps|useLayoutEffectWithPrevDeps|useDerivedState|useDerivedSignal|useThrottledResolver|useDebouncedResolver)$"
}
],
"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';
if (shouldSkipRender) {
return <span ref={ref} />;

View File

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

View File

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

View File

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

View File

@ -25,8 +25,9 @@ import styles from './CustomEmojiTooltip.module.scss';
export type OwnProps = {
chatId: string;
isOpen: boolean;
onCustomEmojiSelect: (customEmoji: ApiSticker) => void;
addRecentCustomEmoji: GlobalActions['addRecentCustomEmoji'];
onCustomEmojiSelect: (customEmoji: ApiSticker) => void;
onClose: NoneToVoidFunction;
};
type StateProps = {
@ -39,11 +40,12 @@ const INTERSECTION_THROTTLE = 200;
const CustomEmojiTooltip: FC<OwnProps & StateProps> = ({
isOpen,
addRecentCustomEmoji,
onCustomEmojiSelect,
onClose,
customEmoji,
isSavedMessages,
isCurrentUserPremium,
onCustomEmojiSelect,
addRecentCustomEmoji,
}) => {
const { clearCustomEmojiForEmoji } = getActions();
@ -59,9 +61,7 @@ const CustomEmojiTooltip: FC<OwnProps & StateProps> = ({
observe: observeIntersection,
} = useIntersectionObserver({ rootRef: containerRef, throttleMs: INTERSECTION_THROTTLE });
useEffect(() => (
isOpen ? captureEscKeyListener(clearCustomEmojiForEmoji) : undefined
), [isOpen, clearCustomEmojiForEmoji]);
useEffect(() => (isOpen ? captureEscKeyListener(onClose) : undefined), [isOpen, onClose]);
const handleCustomEmojiSelect = useCallback((ce: ApiSticker) => {
if (!isOpen) return;
@ -111,6 +111,7 @@ export default memo(withGlobal<OwnProps>(
const { stickers: customEmoji } = global.customEmojis.forEmoji;
const isSavedMessages = selectIsChatWithSelf(global, chatId);
const isCurrentUserPremium = selectIsCurrentUserPremium(global);
return { customEmoji, isSavedMessages, isCurrentUserPremium };
},
)(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 React, {
useEffect, useRef, memo, useState, useCallback,
useEffect, useRef, memo, useState, useCallback, useLayoutEffect,
} from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global';
import type { IAnchorPosition, ISettings } from '../../../types';
import type { Signal } from '../../../util/signals';
import { EDITABLE_INPUT_ID } from '../../../config';
import {
@ -21,12 +22,12 @@ import parseEmojiOnlyString from '../../../util/parseEmojiOnlyString';
import { isSelectionInsideInput } from './helpers/selection';
import renderText from '../../common/helpers/renderText';
import useLayoutEffectWithPrevDeps from '../../../hooks/useLayoutEffectWithPrevDeps';
import useFlag from '../../../hooks/useFlag';
import { isHeavyAnimating } from '../../../hooks/useHeavyAnimationCheck';
import useLang from '../../../hooks/useLang';
import useInputCustomEmojis from './hooks/useInputCustomEmojis';
import useAppLayout from '../../../hooks/useAppLayout';
import useDerivedState from '../../../hooks/useDerivedState';
import TextFormatter from './TextFormatter';
@ -39,12 +40,14 @@ const SCROLLER_CLASS = 'input-scroller';
const INPUT_WRAPPER_CLASS = 'message-input-wrapper';
type OwnProps = {
ref?: RefObject<HTMLDivElement>;
id: string;
chatId: string;
threadId: number;
isAttachmentModalInput?: boolean;
editableInputId?: string;
html: string;
isActive: boolean;
getHtml: Signal<string>;
placeholder: string;
forcedPlaceholder?: string;
noFocusInterception?: boolean;
@ -89,12 +92,14 @@ function clearSelection() {
}
const MessageInput: FC<OwnProps & StateProps> = ({
ref,
id,
chatId,
captionLimit,
isAttachmentModalInput,
editableInputId,
html,
isActive,
getHtml,
placeholder,
forcedPlaceholder,
canAutoFocus,
@ -115,7 +120,11 @@ const MessageInput: FC<OwnProps & StateProps> = ({
} = getActions();
// 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
const selectionTimeoutRef = useRef<number>(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 { isMobile } = useAppLayout();
useInputCustomEmojis(html, inputRef, sharedCanvasRef, sharedCanvasHqRef, absoluteContainerRef);
useInputCustomEmojis(getHtml, inputRef, sharedCanvasRef, sharedCanvasHqRef, absoluteContainerRef);
const maxInputHeight = isMobile ? 256 : 416;
const updateInputHeight = useCallback((willSend = false) => {
@ -173,7 +182,10 @@ const MessageInput: FC<OwnProps & StateProps> = ({
updateInputHeight(false);
}, [isAttachmentModalInput, updateInputHeight]);
useLayoutEffectWithPrevDeps(([prevHtml]) => {
const htmlRef = useRef(getHtml());
useLayoutEffect(() => {
const html = isActive ? getHtml() : '';
if (html !== inputRef.current!.innerHTML) {
inputRef.current!.innerHTML = html;
}
@ -182,10 +194,12 @@ const MessageInput: FC<OwnProps & StateProps> = ({
cloneRef.current!.innerHTML = html;
}
if (prevHtml !== undefined && prevHtml !== html) {
updateInputHeight(!html.length);
if (html !== htmlRef.current) {
htmlRef.current = html;
updateInputHeight(!html);
}
}, [html, updateInputHeight]);
}, [getHtml, isActive, updateInputHeight]);
const chatIdRef = useRef(chatId);
chatIdRef.current = chatId;
@ -311,7 +325,9 @@ const MessageInput: FC<OwnProps & StateProps> = ({
// https://levelup.gitconnected.com/javascript-events-handlers-keyboard-and-load-events-1b3e46a6b0c3#1960
const { isComposing } = e;
if (!isComposing && !html.length && (e.metaKey || e.ctrlKey)) {
const html = getHtml();
if (!isComposing && !html && (e.metaKey || e.ctrlKey)) {
const targetIndexDelta = e.key === 'ArrowDown' ? 1 : e.key === 'ArrowUp' ? -1 : undefined;
if (targetIndexDelta) {
e.preventDefault();
@ -334,7 +350,7 @@ const MessageInput: FC<OwnProps & StateProps> = ({
closeTextFormatter();
onSend();
}
} else if (!isComposing && e.key === 'ArrowUp' && !html.length && !e.metaKey && !e.ctrlKey && !e.altKey) {
} else if (!isComposing && e.key === 'ArrowUp' && !html && !e.metaKey && !e.ctrlKey && !e.altKey) {
e.preventDefault();
editLastMessage();
} else {
@ -472,9 +488,11 @@ const MessageInput: FC<OwnProps & StateProps> = ({
};
}, [shouldSuppressFocus]);
const isTouched = useDerivedState(() => Boolean(isActive && getHtml()), [isActive, getHtml]);
const className = buildClassName(
'form-control',
html.length > 0 && 'touched',
isTouched && 'touched',
shouldSuppressFocus && 'focus-disabled',
);

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,109 +1,94 @@
import type { RefObject } from 'react';
import {
useCallback, useEffect, useState,
} from '../../../../lib/teact/teact';
import { getGlobal } from '../../../../global';
import type { ApiChatMember, ApiUser } from '../../../../api/types';
import { ApiMessageEntityTypes } from '../../../../api/types';
import type { Signal } from '../../../../util/signals';
import { ApiMessageEntityTypes } from '../../../../api/types';
import { filterUsersByName, getMainUsername, getUserFirstOrLastName } from '../../../../global/helpers';
import { prepareForRegExp } from '../helpers/prepareForRegExp';
import focusEditableElement from '../../../../util/focusEditableElement';
import { pickTruthy, unique } from '../../../../util/iteratees';
import { throttle } from '../../../../util/schedulers';
import { getHtmlBeforeSelection } from '../../../../util/selection';
import useFlag from '../../../../hooks/useFlag';
import useCacheBuster from '../../../../hooks/useCacheBuster';
import useOnSelectionChange from '../../../../hooks/useOnSelectionChange';
import useDerivedSignal from '../../../../hooks/useDerivedSignal';
import { useThrottledResolver } from '../../../../hooks/useAsyncResolvers';
const THROTTLE = 300;
const runThrottled = throttle((cb) => cb(), 500, true);
let RE_USERNAME_SEARCH: RegExp;
try {
RE_USERNAME_SEARCH = /(^|\s)@[-_\p{L}\p{M}\p{N}]*$/gui;
} catch (e) {
// Support for older versions of firefox
// Support for older versions of Firefox
RE_USERNAME_SEARCH = /(^|\s)@[-_\d\wа-яё]*$/gi;
}
export default function useMentionTooltip(
canSuggestMembers: boolean | undefined,
inputSelector: string,
onUpdateHtml: (html: string) => void,
isEnabled: boolean,
getHtml: Signal<string>,
setHtml: (html: string) => void,
getSelectionRange: Signal<Range | undefined>,
inputRef: RefObject<HTMLDivElement>,
groupChatMembers?: ApiChatMember[],
topInlineBotIds?: string[],
currentUserId?: string,
) {
const [isOpen, markIsOpen, unmarkIsOpen] = useFlag();
const [htmlBeforeSelection, setHtmlBeforeSelection] = useState('');
const [usersToMention, setUsersToMention] = useState<ApiUser[] | undefined>();
const [filteredUsers, setFilteredUsers] = useState<ApiUser[] | undefined>();
const [isManuallyClosed, markManuallyClosed, unmarkManuallyClosed] = useFlag(false);
const extractUsernameTagThrottled = useThrottledResolver(() => {
const html = getHtml();
if (!isEnabled || !getSelectionRange()?.collapsed || !html.includes('@')) return undefined;
const htmlBeforeSelection = getHtmlBeforeSelection(inputRef.current!);
return prepareForRegExp(htmlBeforeSelection).match(RE_USERNAME_SEARCH)?.[0].trim();
}, [isEnabled, getHtml, getSelectionRange, inputRef], THROTTLE);
const getUsernameTag = useDerivedSignal(
extractUsernameTagThrottled, [extractUsernameTagThrottled, getHtml, getSelectionRange], true,
);
const getWithInlineBots = useDerivedSignal(() => {
return isEnabled && getHtml().startsWith('@');
}, [getHtml, isEnabled]);
useEffect(() => {
const usernameTag = getUsernameTag();
if (!usernameTag || !(groupChatMembers || topInlineBotIds)) {
setFilteredUsers(undefined);
return;
}
const updateFilteredUsers = useCallback((filter, withInlineBots: boolean) => {
// No need for expensive global updates on users, so we avoid them
const usersById = getGlobal().users.byId;
if (!(groupChatMembers || topInlineBotIds) || !usersById) {
setUsersToMention(undefined);
if (!usersById) {
setFilteredUsers(undefined);
return;
}
runThrottled(() => {
const memberIds = groupChatMembers?.reduce((acc: string[], member) => {
if (member.userId !== currentUserId) {
acc.push(member.userId);
}
const memberIds = groupChatMembers?.reduce((acc: string[], member) => {
if (member.userId !== currentUserId) {
acc.push(member.userId);
}
return acc;
}, []);
return acc;
}, []);
const filteredIds = filterUsersByName(unique([
...((withInlineBots && topInlineBotIds) || []),
...(memberIds || []),
]), usersById, filter);
const filter = usernameTag.substring(1);
const filteredIds = filterUsersByName(unique([
...((getWithInlineBots() && topInlineBotIds) || []),
...(memberIds || []),
]), usersById, filter);
setUsersToMention(Object.values(pickTruthy(usersById, filteredIds)));
});
}, [currentUserId, groupChatMembers, topInlineBotIds]);
const [cacheBuster, updateCacheBuster] = useCacheBuster();
const handleSelectionChange = useCallback((range: Range) => {
if (range.collapsed) {
updateCacheBuster(); // Update tooltip on cursor move
}
}, [updateCacheBuster]);
useOnSelectionChange(inputSelector, handleSelectionChange);
useEffect(() => {
setHtmlBeforeSelection(getHtmlBeforeSelection(document.querySelector<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]);
setFilteredUsers(Object.values(pickTruthy(usersById, filteredIds)));
}, [currentUserId, groupChatMembers, topInlineBotIds, getUsernameTag, getWithInlineBots]);
const insertMention = useCallback((user: ApiUser, forceFocus = false) => {
if (!user.usernames && !getUserFirstOrLastName(user)) {
@ -111,7 +96,7 @@ export default function useMentionTooltip(
}
const mainUsername = getMainUsername(user);
const insertedHtml = mainUsername
const htmlToInsert = mainUsername
? `@${mainUsername}`
: `<a
class="text-entity-link"
@ -121,43 +106,37 @@ export default function useMentionTooltip(
dir="auto"
>${getUserFirstOrLastName(user)}</a>`;
const containerEl = document.querySelector<HTMLDivElement>(inputSelector)!;
const inputEl = inputRef.current!;
const htmlBeforeSelection = getHtmlBeforeSelection(inputEl);
const fixedHtmlBeforeSelection = cleanWebkitNewLines(htmlBeforeSelection);
const atIndex = fixedHtmlBeforeSelection.lastIndexOf('@');
if (atIndex !== -1) {
const newHtml = `${fixedHtmlBeforeSelection.substr(0, atIndex)}${insertedHtml}&nbsp;`;
const htmlAfterSelection = cleanWebkitNewLines(containerEl.innerHTML).substring(fixedHtmlBeforeSelection.length);
onUpdateHtml(`${newHtml}${htmlAfterSelection}`);
const newHtml = `${fixedHtmlBeforeSelection.substr(0, atIndex)}${htmlToInsert}&nbsp;`;
const htmlAfterSelection = cleanWebkitNewLines(inputEl.innerHTML).substring(fixedHtmlBeforeSelection.length);
setHtml(`${newHtml}${htmlAfterSelection}`);
requestAnimationFrame(() => {
focusEditableElement(containerEl, forceFocus);
focusEditableElement(inputEl, forceFocus);
});
}
unmarkIsOpen();
}, [htmlBeforeSelection, inputSelector, onUpdateHtml, unmarkIsOpen]);
setFilteredUsers(undefined);
}, [inputRef, setHtml]);
useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]);
return {
isMentionTooltipOpen: isOpen,
closeMentionTooltip: unmarkIsOpen,
isMentionTooltipOpen: Boolean(filteredUsers?.length && !isManuallyClosed),
closeMentionTooltip: markManuallyClosed,
insertMention,
mentionFilteredUsers: usersToMention,
mentionFilteredUsers: filteredUsers,
};
}
function getUsernameFilter(html: string) {
const username = prepareForRegExp(html).match(RE_USERNAME_SEARCH);
return username ? username[0].trim() : undefined;
}
// Webkit replaces the line break with the `<div><br /></div>` or `<div></div>` code.
// It is necessary to clean the html to a single form before processing.
function cleanWebkitNewLines(html: string) {
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 { getActions } from '../../../../global';
import { useEffect } from '../../../../lib/teact/teact';
import type { ApiSticker } from '../../../../api/types';
import type { Signal } from '../../../../util/signals';
import { getActions } from '../../../../global';
import { EMOJI_IMG_REGEX } from '../../../../config';
import { IS_EMOJI_SUPPORTED } from '../../../../util/environment';
import parseEmojiOnlyString from '../../../../util/parseEmojiOnlyString';
import twemojiRegex from '../../../../lib/twemojiRegex';
import { prepareForRegExp } from '../helpers/prepareForRegExp';
import useDerivedState from '../../../../hooks/useDerivedState';
import useFlag from '../../../../hooks/useFlag';
import useDerivedSignal from '../../../../hooks/useDerivedSignal';
const MAX_LENGTH = 8;
const STARTS_ENDS_ON_EMOJI_IMG_REGEX = new RegExp(`^${EMOJI_IMG_REGEX.source}$`, 'g');
export default function useStickerTooltip(
isAllowed: boolean,
html: string,
isEnabled: boolean,
getHtml: Signal<string>,
stickers?: ApiSticker[],
isDisabled = false,
) {
const cleanHtml = useMemo(() => prepareForRegExp(html).trim(), [html]);
const { loadStickersForEmoji, clearStickersForEmoji } = getActions();
const isSingleEmoji = (
(IS_EMOJI_SUPPORTED && parseEmojiOnlyString(cleanHtml) === 1)
|| (!IS_EMOJI_SUPPORTED && Boolean(html.match(STARTS_ENDS_ON_EMOJI_IMG_REGEX)))
);
const hasStickers = Boolean(stickers?.length) && isSingleEmoji;
const [isManuallyClosed, markManuallyClosed, unmarkManuallyClosed] = useFlag(false);
const getSingleEmoji = useDerivedSignal(() => {
const html = getHtml();
if (!isEnabled || !html || (IS_EMOJI_SUPPORTED && html.length > MAX_LENGTH)) return undefined;
const hasEmoji = html.match(IS_EMOJI_SUPPORTED ? twemojiRegex : EMOJI_IMG_REGEX);
if (!hasEmoji) return undefined;
const cleanHtml = prepareForRegExp(html);
const isSingleEmoji = cleanHtml && (
(IS_EMOJI_SUPPORTED && parseEmojiOnlyString(cleanHtml) === 1)
|| (!IS_EMOJI_SUPPORTED && Boolean(html.match(STARTS_ENDS_ON_EMOJI_IMG_REGEX)))
);
return isSingleEmoji
? (IS_EMOJI_SUPPORTED ? cleanHtml : cleanHtml.match(/alt="(.+)"/)?.[1]!)
: undefined;
}, [getHtml, isEnabled]);
const isActive = useDerivedState(() => Boolean(getSingleEmoji()), [getSingleEmoji]);
const hasStickers = Boolean(stickers?.length);
useEffect(() => {
if (isDisabled) return;
if (!isEnabled) return;
if (isAllowed && isSingleEmoji) {
loadStickersForEmoji({
emoji: IS_EMOJI_SUPPORTED ? cleanHtml : cleanHtml.match(/alt="(.+)"/)?.[1]!,
});
} else if (hasStickers || !isSingleEmoji) {
const singleEmoji = getSingleEmoji();
if (singleEmoji) {
if (!hasStickers) {
loadStickersForEmoji({ emoji: singleEmoji });
}
} else {
clearStickersForEmoji();
}
// We omit `hasStickers` here to prevent re-fetching after manually closing tooltip (via <Esc>).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [html, isSingleEmoji, clearStickersForEmoji, loadStickersForEmoji, isAllowed, isDisabled]);
}, [isEnabled, getSingleEmoji, hasStickers, loadStickersForEmoji, clearStickersForEmoji]);
useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]);
return {
isStickerTooltipOpen: hasStickers,
closeStickerTooltip: clearStickersForEmoji,
isStickerTooltipOpen: Boolean(isActive && hasStickers && !isManuallyClosed),
closeStickerTooltip: markManuallyClosed,
};
}

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,
deps: any[],
ms: number,
noFirst?: boolean,
noLast?: boolean,
noFirst = false,
noLast = false,
) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const fnMemo = useCallback(fn, deps);

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';
export default function useRunDebounced(ms: number, noFirst?: boolean, noLast?: boolean) {
export default function useRunDebounced(ms: number, noFirst?: boolean, noLast?: boolean, deps: any = []) {
return useDebouncedCallback((cb: NoneToVoidFunction) => {
cb();
}, [], ms, noFirst, noLast);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps, ms, noFirst, noLast);
}

View File

@ -1,7 +1,8 @@
import useThrottledCallback from './useThrottledCallback';
export default function useRunThrottled(ms: number, noFirst?: boolean) {
export default function useRunThrottled(ms: number, noFirst?: boolean, deps: any = []) {
return useThrottledCallback((cb: NoneToVoidFunction) => {
cb();
}, [], ms, noFirst);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps, ms, noFirst);
}

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';
// 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) {
const ref = useRef<T>(value);

View File

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

View File

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

View File

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

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