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