Composer: Introduce Signals (#2378)
This commit is contained in:
parent
ba63b8c2c6
commit
eed6241f42
@ -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",
|
||||
|
||||
@ -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} />;
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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',
|
||||
);
|
||||
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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} `;
|
||||
const htmlAfterSelection = cleanWebkitNewLines(containerEl.innerHTML).substring(fixedHtmlBeforeSelection.length);
|
||||
onUpdateHtml(`${newHtml}${htmlAfterSelection}`);
|
||||
const newHtml = `${fixedHtmlBeforeSelection.substr(0, atIndex)}${htmlToInsert} `;
|
||||
const htmlAfterSelection = cleanWebkitNewLines(inputEl.innerHTML).substring(fixedHtmlBeforeSelection.length);
|
||||
|
||||
setHtml(`${newHtml}${htmlAfterSelection}`);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
focusEditableElement(containerEl, forceFocus);
|
||||
focusEditableElement(inputEl, forceFocus);
|
||||
});
|
||||
}
|
||||
|
||||
unmarkIsOpen();
|
||||
}, [htmlBeforeSelection, inputSelector, onUpdateHtml, unmarkIsOpen]);
|
||||
setFilteredUsers(undefined);
|
||||
}, [inputRef, setHtml]);
|
||||
|
||||
useEffect(unmarkManuallyClosed, [unmarkManuallyClosed, getHtml]);
|
||||
|
||||
return {
|
||||
isMentionTooltipOpen: isOpen,
|
||||
closeMentionTooltip: unmarkIsOpen,
|
||||
isMentionTooltipOpen: Boolean(filteredUsers?.length && !isManuallyClosed),
|
||||
closeMentionTooltip: markManuallyClosed,
|
||||
insertMention,
|
||||
mentionFilteredUsers: usersToMention,
|
||||
mentionFilteredUsers: filteredUsers,
|
||||
};
|
||||
}
|
||||
|
||||
function getUsernameFilter(html: string) {
|
||||
const username = prepareForRegExp(html).match(RE_USERNAME_SEARCH);
|
||||
|
||||
return username ? username[0].trim() : undefined;
|
||||
}
|
||||
|
||||
// Webkit replaces the line break with the `<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('@');
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
16
src/hooks/useAsyncResolvers.ts
Normal file
16
src/hooks/useAsyncResolvers.ts
Normal 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);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
43
src/hooks/useDerivedSignal.ts
Normal file
43
src/hooks/useDerivedSignal.ts
Normal 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;
|
||||
60
src/hooks/useDerivedState.ts
Normal file
60
src/hooks/useDerivedState.ts
Normal 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;
|
||||
38
src/hooks/useGetSelectionRange.ts
Normal file
38
src/hooks/useGetSelectionRange.ts
Normal 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;
|
||||
}
|
||||
@ -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]);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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
8
src/hooks/useSignal.ts
Normal 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;
|
||||
}
|
||||
23
src/hooks/useSignalEffect.ts
Normal file
23
src/hooks/useSignalEffect.ts
Normal 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);
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
|
||||
@ -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++;
|
||||
}
|
||||
|
||||
|
||||
@ -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
81
src/util/signals.ts
Normal 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);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user