diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index 8101a1866..685a5ba7b 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -1,7 +1,7 @@ import React, { FC, memo, useCallback, useLayoutEffect, useMemo, useRef, } from '../../../lib/teact/teact'; -import { getDispatch, withGlobal } from '../../../lib/teact/teactn'; +import { getDispatch, getGlobal, withGlobal } from '../../../lib/teact/teactn'; import useLang, { LangFn } from '../../../hooks/useLang'; @@ -68,7 +68,6 @@ type StateProps = { user?: ApiUser; userStatus?: ApiUserStatus; actionTargetUserIds?: string[]; - usersById?: Record; actionTargetMessage?: ApiMessage; actionTargetChatId?: string; lastMessageSender?: ApiUser; @@ -95,7 +94,6 @@ const Chat: FC = ({ user, userStatus, actionTargetUserIds, - usersById, lastMessageSender, lastMessageOutgoingStatus, actionTargetMessage, @@ -132,10 +130,14 @@ const Chat: FC = ({ const isRoundVideo = Boolean(lastMessage && getMessageRoundVideo(lastMessage)); const actionTargetUsers = useMemo(() => { - return actionTargetUserIds - ? actionTargetUserIds.map((userId) => usersById?.[userId]).filter(Boolean as any) - : undefined; - }, [actionTargetUserIds, usersById]); + if (!actionTargetUserIds) { + return undefined; + } + + // No need for expensive global updates on users, so we avoid them + const usersById = getGlobal().users.byId; + return actionTargetUserIds.map((userId) => usersById[userId]).filter(Boolean as any); + }, [actionTargetUserIds]); // Sets animation excess values when `orderDiff` changes and then resets excess values to animate. useLayoutEffect(() => { @@ -360,7 +362,6 @@ export default memo(withGlobal( : undefined; const { targetUserIds: actionTargetUserIds, targetChatId: actionTargetChatId } = lastMessageAction || {}; const privateChatUserId = getPrivateChatUserId(chat); - const { byId: usersById } = global.users; const { chatId: currentChatId, threadId: currentThreadId, @@ -386,7 +387,6 @@ export default memo(withGlobal( user: selectUser(global, privateChatUserId), userStatus: selectUserStatus(global, privateChatUserId), }), - ...(actionTargetUserIds && { usersById }), }; }, )(Chat)); diff --git a/src/components/middle/MiddleHeader.tsx b/src/components/middle/MiddleHeader.tsx index 0381fdcb5..dd84b052c 100644 --- a/src/components/middle/MiddleHeader.tsx +++ b/src/components/middle/MiddleHeader.tsx @@ -85,7 +85,6 @@ type StateProps = { isLeftColumnShown?: boolean; isRightColumnShown?: boolean; audioMessage?: ApiMessage; - chatsById?: Record; messagesCount?: number; isChatWithSelf?: boolean; isChatWithBot?: boolean; @@ -110,7 +109,6 @@ const MiddleHeader: FC = ({ isRightColumnShown, audioMessage, chat, - chatsById, messagesCount, isChatWithSelf, isChatWithBot, @@ -229,12 +227,12 @@ const MiddleHeader: FC = ({ ]); const unreadCount = useMemo(() => { - if (!isLeftColumnHideable || !chatsById) { + if (!isLeftColumnHideable) { return undefined; } return selectCountNotMutedUnread(getGlobal()) || undefined; - }, [isLeftColumnHideable, chatsById]); + }, [isLeftColumnHideable]); const canToolsCollideWithChatInfo = ( windowWidth >= MIN_SCREEN_WIDTH_FOR_STATIC_LEFT_COLUMN @@ -445,7 +443,6 @@ const MiddleHeader: FC = ({ export default memo(withGlobal( (global, { chatId, threadId, messageListType }): StateProps => { const { isLeftColumnShown, lastSyncTime, shouldSkipHistoryAnimations } = global; - const { byId: chatsById } = global.chats; const chat = selectChat(global, chatId); const { typingStatus } = chat || {}; @@ -474,7 +471,6 @@ export default memo(withGlobal( isSelectModeActive: selectIsInSelectMode(global), audioMessage, chat, - chatsById, messagesCount, isChatWithSelf: selectIsChatWithSelf(global, chatId), isChatWithBot: chat && selectIsChatWithBot(global, chat), diff --git a/src/components/middle/composer/AttachMenu.tsx b/src/components/middle/composer/AttachMenu.tsx index eab8bde46..7290a8e91 100644 --- a/src/components/middle/composer/AttachMenu.tsx +++ b/src/components/middle/composer/AttachMenu.tsx @@ -3,7 +3,6 @@ import React, { FC, memo, useCallback } from '../../../lib/teact/teact'; import { CONTENT_TYPES_WITH_PREVIEW } from '../../../config'; import { IS_TOUCH_ENV } from '../../../util/environment'; import { openSystemFilesDialog } from '../../../util/systemFilesDialog'; -import { IAllowedAttachmentOptions } from '../../../modules/helpers'; import useMouseInside from '../../../hooks/useMouseInside'; import useLang from '../../../hooks/useLang'; @@ -14,14 +13,15 @@ import './AttachMenu.scss'; export type OwnProps = { isOpen: boolean; - allowedAttachmentOptions: IAllowedAttachmentOptions; + canAttachMedia: boolean; + canAttachPolls: boolean; onFileSelect: (files: File[], isQuick: boolean) => void; onPollCreate: () => void; onClose: () => void; }; const AttachMenu: FC = ({ - isOpen, allowedAttachmentOptions, onFileSelect, onPollCreate, onClose, + isOpen, canAttachMedia, canAttachPolls, onFileSelect, onPollCreate, onClose, }) => { const [handleMouseEnter, handleMouseLeave] = useMouseInside(isOpen, onClose); @@ -46,8 +46,6 @@ const AttachMenu: FC = ({ const lang = useLang(); - const { canAttachMedia, canAttachPolls } = allowedAttachmentOptions; - return ( ; recentEmojis: string[]; baseEmojiKeywords?: Record; emojiKeywords?: Record; @@ -56,7 +56,6 @@ const AttachmentModal: FC = ({ isReady, currentUserId, groupChatMembers, - usersById, recentEmojis, baseEmojiKeywords, emojiKeywords, @@ -66,8 +65,8 @@ const AttachmentModal: FC = ({ onFileAppend, onClear, }) => { - // eslint-disable-next-line no-null/no-null - const hideTimeoutRef = useRef(null); + const captionRef = useStateRef(caption); + const hideTimeoutRef = useRef(); const prevAttachments = usePrevious(attachments); const renderingAttachments = attachments.length ? attachments : prevAttachments; const isOpen = Boolean(attachments.length); @@ -79,7 +78,7 @@ const AttachmentModal: FC = ({ isMentionTooltipOpen, closeMentionTooltip, insertMention, mentionFilteredUsers, } = useMentionTooltip( isOpen, - caption, + captionRef, onCaptionUpdate, EDITABLE_INPUT_MODAL_ID, groupChatMembers, @@ -90,7 +89,7 @@ const AttachmentModal: FC = ({ isEmojiTooltipOpen, closeEmojiTooltip, filteredEmojis, insertEmoji, } = useEmojiTooltip( isOpen, - caption, + captionRef, recentEmojis, EDITABLE_INPUT_MODAL_ID, onCaptionUpdate, @@ -150,6 +149,7 @@ const AttachmentModal: FC = ({ if (hideTimeoutRef.current) { window.clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = undefined; } } @@ -238,7 +238,6 @@ const AttachmentModal: FC = ({ onClose={closeMentionTooltip} onInsertUserName={insertMention} filteredUsers={mentionFilteredUsers} - usersById={usersById} /> ; recentEmojis: string[]; lastSyncTime?: number; contentToBeScheduled?: GlobalState['messages']['contentToBeScheduled']; @@ -195,7 +196,6 @@ const Composer: FC = ({ groupChatMembers, topInlineBotIds, currentUserId, - usersById, lastSyncTime, contentToBeScheduled, shouldSuggestStickers, @@ -231,6 +231,7 @@ const Composer: FC = ({ // eslint-disable-next-line no-null/no-null const appendixRef = useRef(null); const [html, setHtml] = useState(''); + const htmlRef = useStateRef(html); const lastMessageSendTimeSeconds = useRef(); const prevDropAreaState = usePrevious(dropAreaState); const [isCalendarOpen, openCalendar, closeCalendar] = useFlag(); @@ -241,12 +242,6 @@ const Composer: FC = ({ const sendAsIds = chat?.sendAsIds; const sendMessageAction = useSendMessageAction(chatId, threadId); - // Cache for frequently updated state - const htmlRef = useRef(html); - useEffect(() => { - htmlRef.current = html; - }, [html]); - useEffect(() => { lastMessageSendTimeSeconds.current = undefined; }, [chatId]); @@ -323,7 +318,7 @@ const Composer: FC = ({ isMentionTooltipOpen, closeMentionTooltip, insertMention, mentionFilteredUsers, } = useMentionTooltip( !attachments.length, - html, + htmlRef, setHtml, undefined, groupChatMembers, @@ -365,15 +360,15 @@ const Composer: FC = ({ handleContextMenuHide, } = useContextMenuHandlers(mainButtonRef, !(mainButtonState === MainButtonState.Send && canShowCustomSendMenu)); - const allowedAttachmentOptions = useMemo(() => { - return getAllowedAttachmentOptions(chat, isChatWithBot); - }, [chat, isChatWithBot]); + const { + canSendStickers, canSendGifs, canAttachMedia, canAttachPolls, canAttachEmbedLinks, + } = useMemo(() => getAllowedAttachmentOptions(chat, isChatWithBot), [chat, isChatWithBot]); const isAdmin = chat && isChatAdmin(chat); const slowMode = getChatSlowModeOptions(chat); const { isStickerTooltipOpen, closeStickerTooltip } = useStickerTooltip( - Boolean(shouldSuggestStickers && allowedAttachmentOptions.canSendStickers && !attachments.length), + Boolean(shouldSuggestStickers && canSendStickers && !attachments.length), html, stickersForEmoji, !isReady, @@ -381,8 +376,8 @@ const Composer: FC = ({ const { isEmojiTooltipOpen, closeEmojiTooltip, filteredEmojis, insertEmoji, } = useEmojiTooltip( - Boolean(shouldSuggestStickers && allowedAttachmentOptions.canSendStickers && !attachments.length), - html, + Boolean(shouldSuggestStickers && canSendStickers && !attachments.length), + htmlRef, recentEmojis, undefined, setHtml, @@ -413,7 +408,7 @@ const Composer: FC = ({ requestAnimationFrame(() => { focusEditableElement(messageInput); }); - }, []); + }, [htmlRef]); const removeSymbol = useCallback(() => { const selection = window.getSelection()!; @@ -427,13 +422,13 @@ const Composer: FC = ({ } setHtml(deleteLastCharacterOutsideSelection(htmlRef.current!)); - }, []); + }, [htmlRef]); const resetComposer = useCallback((shouldPreserveInput = false) => { if (!shouldPreserveInput) { setHtml(''); } - setAttachments([]); + setAttachments(MEMO_EMPTY_ARRAY); closeStickerTooltip(); closeCalendar(); setScheduledMessageArgs(undefined); @@ -459,7 +454,7 @@ const Composer: FC = ({ }, [chatId, resetComposer, stopRecordingVoiceRef]); const handleEditComplete = useEditing(htmlRef, setHtml, editingMessage, resetComposer, openDeleteModal); - useDraft(draft, chatId, threadId, html, htmlRef, setHtml, editingMessage); + useDraft(draft, chatId, threadId, htmlRef, setHtml, editingMessage); useClipboardPaste(insertTextAndUpdateCursor, setAttachments, editingMessage); const handleFileSelect = useCallback(async (files: File[], isQuick: boolean) => { @@ -474,7 +469,7 @@ const Composer: FC = ({ }, [attachments]); const handleClearAttachment = useCallback(() => { - setAttachments([]); + setAttachments(MEMO_EMPTY_ARRAY); }, []); const handleSend = useCallback(async (isSilent = false, scheduledAt?: number) => { @@ -580,7 +575,7 @@ const Composer: FC = ({ }); }, [ connectionState, attachments, activeVoiceRecording, isForwarding, clearDraft, chatId, serverTimeOffset, - resetComposer, stopRecordingVoice, showDialog, slowMode, isAdmin, sendMessage, forwardMessages, lang, + resetComposer, stopRecordingVoice, showDialog, slowMode, isAdmin, sendMessage, forwardMessages, lang, htmlRef, ]); const handleActivateBotCommandMenu = useCallback(() => { @@ -791,8 +786,7 @@ const Composer: FC = ({ activeVoiceRecording, openCalendar, pauseRecordingVoice, ]); - const areVoiceMessagesNotAllowed = mainButtonState === MainButtonState.Record - && !allowedAttachmentOptions.canAttachMedia; + const areVoiceMessagesNotAllowed = mainButtonState === MainButtonState.Record && !canAttachMedia; const prevEditedMessage = usePrevious(editingMessage, true); const renderedEditedMessage = editingMessage || prevEditedMessage; @@ -836,15 +830,13 @@ const Composer: FC = ({ return (
- {allowedAttachmentOptions.canAttachMedia && isReady && ( - - - + {canAttachMedia && isReady && ( + )} = ({ caption={attachments.length ? html : ''} groupChatMembers={groupChatMembers} currentUserId={currentUserId} - usersById={usersById} recentEmojis={recentEmojis} isReady={isReady} onCaptionUpdate={setHtml} @@ -889,12 +880,10 @@ const Composer: FC = ({ onClose={closeMentionTooltip} onInsertUserName={insertMention} filteredUsers={mentionFilteredUsers} - usersById={usersById} /> = ({ chatId={chatId} threadId={threadId} messageText={!attachments.length ? html : ''} - disabled={!allowedAttachmentOptions.canAttachEmbedLinks} + disabled={!canAttachEmbedLinks} />
{isChatWithBot && botCommands !== false && !activeVoiceRecording && !editingMessage && ( @@ -1044,7 +1033,8 @@ const Composer: FC = ({ /> = ({ chatId={chatId} threadId={threadId} isOpen={isSymbolMenuOpen} - allowedAttachmentOptions={allowedAttachmentOptions} + canSendGifs={canSendGifs} + canSendStickers={canSendStickers} onLoad={onSymbolMenuLoadingComplete} onClose={closeSymbolMenu} onEmojiSelect={insertTextAndUpdateCursor} @@ -1145,12 +1136,10 @@ export default memo(withGlobal( const emojiKeywords = language !== BASE_EMOJI_KEYWORD_LANG ? global.emojiKeywords[language] : undefined; const botKeyboardMessageId = messageWithActualBotKeyboard ? messageWithActualBotKeyboard.id : undefined; const keyboardMessage = botKeyboardMessageId ? selectChatMessage(global, chatId, botKeyboardMessageId) : undefined; - const usersById = global.users.byId; - const chatsById = global.chats.byId; const { currentUserId } = global; const sendAsId = chat?.fullInfo ? chat?.fullInfo?.sendAsId || currentUserId : undefined; - const sendAsUser = sendAsId ? usersById?.[sendAsId] : undefined; - const sendAsChat = !sendAsUser && sendAsId ? chatsById?.[sendAsId] : undefined; + const sendAsUser = sendAsId ? selectUser(global, sendAsId) : undefined; + const sendAsChat = !sendAsUser && sendAsId ? selectChat(global, sendAsId) : undefined; return { editingMessage: selectEditingMessage(global, chatId, threadId, messageListType), @@ -1179,7 +1168,6 @@ export default memo(withGlobal( groupChatMembers: chat?.fullInfo?.members, topInlineBotIds: global.topInlineBots?.userIds, currentUserId, - usersById, lastSyncTime: global.lastSyncTime, contentToBeScheduled: global.messages.contentToBeScheduled, shouldSuggestStickers, diff --git a/src/components/middle/composer/DropArea.tsx b/src/components/middle/composer/DropArea.tsx index 0bec68415..55c6193be 100644 --- a/src/components/middle/composer/DropArea.tsx +++ b/src/components/middle/composer/DropArea.tsx @@ -8,6 +8,7 @@ import buildClassName from '../../../util/buildClassName'; import captureEscKeyListener from '../../../util/captureEscKeyListener'; import usePrevious from '../../../hooks/usePrevious'; +import Portal from '../../ui/Portal'; import DropTarget from './DropTarget'; import './DropArea.scss'; @@ -84,10 +85,12 @@ const DropArea: FC = ({ ); return ( -
- - {(withQuick || prevWithQuick) && } -
+ +
+ + {(withQuick || prevWithQuick) && } +
+
); }; diff --git a/src/components/middle/composer/InlineBotTooltip.tsx b/src/components/middle/composer/InlineBotTooltip.tsx index 9ceb1e527..0d49621d9 100644 --- a/src/components/middle/composer/InlineBotTooltip.tsx +++ b/src/components/middle/composer/InlineBotTooltip.tsx @@ -3,7 +3,6 @@ import React, { } from '../../../lib/teact/teact'; import { ApiBotInlineMediaResult, ApiBotInlineResult, ApiBotInlineSwitchPm } from '../../../api/types'; -import { IAllowedAttachmentOptions } from '../../../modules/helpers'; import { LoadMoreDirection } from '../../../types'; import { IS_TOUCH_ENV } from '../../../util/environment'; @@ -32,7 +31,6 @@ export type OwnProps = { isOpen: boolean; botId?: string; isGallery?: boolean; - allowedAttachmentOptions: IAllowedAttachmentOptions; inlineBotResults?: (ApiBotInlineResult | ApiBotInlineMediaResult)[]; switchPm?: ApiBotInlineSwitchPm; onSelectResult: (inlineResult: ApiBotInlineMediaResult | ApiBotInlineResult) => void; diff --git a/src/components/middle/composer/MentionTooltip.tsx b/src/components/middle/composer/MentionTooltip.tsx index 973bbc374..856e8d831 100644 --- a/src/components/middle/composer/MentionTooltip.tsx +++ b/src/components/middle/composer/MentionTooltip.tsx @@ -1,13 +1,14 @@ import React, { FC, useCallback, useEffect, useRef, memo, } from '../../../lib/teact/teact'; -import usePrevious from '../../../hooks/usePrevious'; +import { getGlobal } from '../../../lib/teact/teactn'; import { ApiUser } from '../../../api/types'; -import useShowTransition from '../../../hooks/useShowTransition'; import buildClassName from '../../../util/buildClassName'; import setTooltipItemVisible from '../../../util/setTooltipItemVisible'; +import usePrevious from '../../../hooks/usePrevious'; +import useShowTransition from '../../../hooks/useShowTransition'; import { useKeyboardNavigation } from './hooks/useKeyboardNavigation'; import ListItem from '../../ui/ListItem'; @@ -20,14 +21,12 @@ export type OwnProps = { onClose: () => void; onInsertUserName: (user: ApiUser, forceFocus?: boolean) => void; filteredUsers?: ApiUser[]; - usersById?: Record; }; const MentionTooltip: FC = ({ isOpen, onClose, onInsertUserName, - usersById, filteredUsers, }) => { // eslint-disable-next-line no-null/no-null @@ -35,13 +34,15 @@ const MentionTooltip: FC = ({ const { shouldRender, transitionClassNames } = useShowTransition(isOpen, undefined, undefined, false); const handleUserSelect = useCallback((userId: string, forceFocus = false) => { - const user = usersById?.[userId]; + // No need for expensive global updates on users, so we avoid them + const usersById = getGlobal().users.byId; + const user = usersById[userId]; if (!user) { return; } onInsertUserName(user, forceFocus); - }, [usersById, onInsertUserName]); + }, [onInsertUserName]); const handleSelectMention = useCallback((member: ApiUser) => { handleUserSelect(member.id, true); diff --git a/src/components/middle/composer/StickerTooltip.async.tsx b/src/components/middle/composer/StickerTooltip.async.tsx index 3a100816f..10fb478f2 100644 --- a/src/components/middle/composer/StickerTooltip.async.tsx +++ b/src/components/middle/composer/StickerTooltip.async.tsx @@ -1,4 +1,4 @@ -import React, { FC } from '../../../lib/teact/teact'; +import React, { FC, memo } from '../../../lib/teact/teact'; import { OwnProps } from './StickerTooltip'; import { Bundles } from '../../../util/moduleLoader'; @@ -12,4 +12,4 @@ const StickerTooltipAsync: FC = (props) => { return StickerTooltip ? : undefined; }; -export default StickerTooltipAsync; +export default memo(StickerTooltipAsync); diff --git a/src/components/middle/composer/SymbolMenu.tsx b/src/components/middle/composer/SymbolMenu.tsx index 32aea8007..0d017faa6 100644 --- a/src/components/middle/composer/SymbolMenu.tsx +++ b/src/components/middle/composer/SymbolMenu.tsx @@ -5,7 +5,6 @@ import { withGlobal } from '../../../lib/teact/teactn'; import { ApiSticker, ApiVideo } from '../../../api/types'; -import { IAllowedAttachmentOptions } from '../../../modules/helpers'; import { IS_SINGLE_COLUMN_LAYOUT, IS_TOUCH_ENV } from '../../../util/environment'; import { fastRaf } from '../../../util/schedulers'; import buildClassName from '../../../util/buildClassName'; @@ -30,7 +29,8 @@ export type OwnProps = { chatId: string; threadId?: number; isOpen: boolean; - allowedAttachmentOptions: IAllowedAttachmentOptions; + canSendStickers: boolean; + canSendGifs: boolean; onLoad: () => void; onClose: () => void; onEmojiSelect: (emoji: string) => void; @@ -51,7 +51,8 @@ const SymbolMenu: FC = ({ chatId, threadId, isOpen, - allowedAttachmentOptions, + canSendStickers, + canSendGifs, isLeftColumnShown, onLoad, onClose, @@ -131,8 +132,6 @@ const SymbolMenu: FC = ({ const lang = useLang(); - const { canSendStickers, canSendGifs } = allowedAttachmentOptions; - function renderContent(isActive: boolean, isFrom: boolean) { switch (activeTab) { case SymbolMenuTabs.Emoji: diff --git a/src/components/middle/composer/hooks/useDraft.ts b/src/components/middle/composer/hooks/useDraft.ts index 80106632a..49eb0fa93 100644 --- a/src/components/middle/composer/hooks/useDraft.ts +++ b/src/components/middle/composer/hooks/useDraft.ts @@ -21,7 +21,6 @@ export default ( draft: ApiFormattedText | undefined, chatId: string, threadId: number, - html: string, htmlRef: { current: string }, setHtml: (html: string) => void, editedMessage: ApiMessage | undefined, @@ -29,8 +28,9 @@ export default ( const { saveDraft, clearDraft } = getDispatch(); const updateDraft = useCallback((draftChatId: string, draftThreadId: number) => { - if (htmlRef.current.length && !editedMessage) { - saveDraft({ chatId: draftChatId, threadId: draftThreadId, draft: parseMessageInput(htmlRef.current!) }); + const currentHtml = htmlRef.current; + if (currentHtml.length && !editedMessage) { + saveDraft({ chatId: draftChatId, threadId: draftThreadId, draft: parseMessageInput(currentHtml!) }); } else { clearDraft({ chatId: draftChatId, threadId: draftThreadId }); } @@ -75,6 +75,7 @@ export default ( } }, [chatId, threadId, draft, setHtml, updateDraft, prevChatId, prevThreadId]); + const html = htmlRef.current; // Update draft when input changes const prevHtml = usePrevious(html); useEffect(() => { diff --git a/src/components/middle/composer/hooks/useEmojiTooltip.ts b/src/components/middle/composer/hooks/useEmojiTooltip.ts index 31e36fecc..71b77a555 100644 --- a/src/components/middle/composer/hooks/useEmojiTooltip.ts +++ b/src/components/middle/composer/hooks/useEmojiTooltip.ts @@ -43,7 +43,7 @@ try { export default function useEmojiTooltip( isAllowed: boolean, - html: string, + htmlRef: { current: string }, recentEmojiIds: string[], inputId = EDITABLE_INPUT_ID, onUpdateHtml: (html: string) => void, @@ -71,6 +71,7 @@ export default function useEmojiTooltip( } }, [isDisabled]); + const html = htmlRef.current; useEffect(() => { if (!isAllowed || !html || !byId || isDisabled) { unmarkIsOpen(); @@ -111,9 +112,10 @@ export default function useEmojiTooltip( ]); const insertEmoji = useCallback((textEmoji: string, isForce?: boolean) => { - const atIndex = html.lastIndexOf(':', isForce ? html.lastIndexOf(':') - 1 : undefined); + const currentHtml = htmlRef.current; + const atIndex = currentHtml.lastIndexOf(':', isForce ? currentHtml.lastIndexOf(':') - 1 : undefined); if (atIndex !== -1) { - onUpdateHtml(`${html.substr(0, atIndex)}${textEmoji}`); + onUpdateHtml(`${currentHtml.substr(0, atIndex)}${textEmoji}`); const messageInput = document.getElementById(inputId)!; requestAnimationFrame(() => { focusEditableElement(messageInput, true); @@ -121,7 +123,7 @@ export default function useEmojiTooltip( } unmarkIsOpen(); - }, [html, inputId, onUpdateHtml, unmarkIsOpen]); + }, [htmlRef, inputId, onUpdateHtml, unmarkIsOpen]); useEffect(() => { if (isOpen && shouldForceInsertEmoji && filteredEmojis.length) { diff --git a/src/components/middle/composer/hooks/useInlineBotTooltip.ts b/src/components/middle/composer/hooks/useInlineBotTooltip.ts index 48ea4068e..15298e17b 100644 --- a/src/components/middle/composer/hooks/useInlineBotTooltip.ts +++ b/src/components/middle/composer/hooks/useInlineBotTooltip.ts @@ -8,6 +8,12 @@ import useDebouncedMemo from '../../../../hooks/useDebouncedMemo'; const DEBOUNCE_MS = 300; const INLINE_BOT_QUERY_REGEXP = /^@([a-z0-9_]{1,32})[\u00A0\u0020]+(.*)/i; const HAS_NEW_LINE = /^@([a-z0-9_]{1,32})[\u00A0\u0020]+\n{2,}/i; +const MEMO_NO_RESULT = { + username: '', + query: '', + canShowHelp: false, + usernameLowered: '', +}; const tempEl = document.createElement('div'); @@ -81,12 +87,7 @@ function parseBotQuery(html: string) { const text = getPlainText(html); const result = text.match(INLINE_BOT_QUERY_REGEXP); if (!result) { - return { - username: '', - query: '', - canShowHelp: false, - usernameLowered: '', - }; + return MEMO_NO_RESULT; } return { diff --git a/src/components/middle/composer/hooks/useMentionTooltip.ts b/src/components/middle/composer/hooks/useMentionTooltip.ts index 85393fe49..a3c29fd97 100644 --- a/src/components/middle/composer/hooks/useMentionTooltip.ts +++ b/src/components/middle/composer/hooks/useMentionTooltip.ts @@ -24,7 +24,7 @@ try { export default function useMentionTooltip( canSuggestMembers: boolean | undefined, - html: string, + htmlRef: { current: string }, onUpdateHtml: (html: string) => void, inputId: string = EDITABLE_INPUT_ID, groupChatMembers?: ApiChatMember[], @@ -62,6 +62,7 @@ export default function useMentionTooltip( }); }, [currentUserId, groupChatMembers, topInlineBotIds]); + const html = htmlRef.current; useEffect(() => { if (!canSuggestMembers || !html.length) { unmarkIsOpen(); @@ -76,7 +77,7 @@ export default function useMentionTooltip( } else { unmarkIsOpen(); } - }, [canSuggestMembers, html, updateFilteredUsers, markIsOpen, unmarkIsOpen]); + }, [canSuggestMembers, updateFilteredUsers, markIsOpen, unmarkIsOpen, html]); useEffect(() => { if (usersToMention?.length) { @@ -101,9 +102,10 @@ export default function useMentionTooltip( dir="auto" >${getUserFirstOrLastName(user)}`; - const atIndex = html.lastIndexOf('@'); + const currentHtml = htmlRef.current; + const atIndex = currentHtml.lastIndexOf('@'); if (atIndex !== -1) { - onUpdateHtml(`${html.substr(0, atIndex)}${insertedHtml} `); + onUpdateHtml(`${currentHtml.substr(0, atIndex)}${insertedHtml} `); const messageInput = document.getElementById(inputId)!; requestAnimationFrame(() => { focusEditableElement(messageInput, forceFocus); @@ -111,7 +113,7 @@ export default function useMentionTooltip( } unmarkIsOpen(); - }, [html, inputId, onUpdateHtml, unmarkIsOpen]); + }, [htmlRef, inputId, onUpdateHtml, unmarkIsOpen]); return { isMentionTooltipOpen: isOpen, diff --git a/src/components/ui/ListItem.tsx b/src/components/ui/ListItem.tsx index e37ffd6ee..673c5019d 100644 --- a/src/components/ui/ListItem.tsx +++ b/src/components/ui/ListItem.tsx @@ -45,29 +45,27 @@ interface OwnProps { onSecondaryIconClick?: (e: React.MouseEvent) => void; } -const ListItem: FC = (props) => { - const { - ref, - buttonRef, - icon, - secondaryIcon, - className, - style, - children, - disabled, - ripple, - narrow, - inactive, - focus, - destructive, - multiline, - isStatic, - contextActions, - onMouseDown, - onClick, - onSecondaryIconClick, - } = props; - +const ListItem: FC = ({ + ref, + buttonRef, + icon, + secondaryIcon, + className, + style, + children, + disabled, + ripple, + narrow, + inactive, + focus, + destructive, + multiline, + isStatic, + contextActions, + onMouseDown, + onClick, + onSecondaryIconClick, +}) => { // eslint-disable-next-line no-null/no-null let containerRef = useRef(null); if (ref) { diff --git a/src/hooks/useModuleLoader.ts b/src/hooks/useModuleLoader.ts index da6a24410..661fd27ca 100644 --- a/src/hooks/useModuleLoader.ts +++ b/src/hooks/useModuleLoader.ts @@ -12,10 +12,13 @@ export default >( const module = getModuleFromMemory(bundleName, moduleName); const forceUpdate = useForceUpdate(); - if (autoUpdate) { - // Use effect and cleanup for listener removal - addLoadListener(forceUpdate); - } + useEffect(() => { + if (!autoUpdate) { + return undefined; + } + + return addLoadListener(forceUpdate); + }, [autoUpdate, forceUpdate]); useEffect(() => { if (!noLoad && !module) { diff --git a/src/hooks/useStateRef.ts b/src/hooks/useStateRef.ts new file mode 100644 index 000000000..994feab28 --- /dev/null +++ b/src/hooks/useStateRef.ts @@ -0,0 +1,13 @@ +import { useEffect, useRef } from '../lib/teact/teact'; + +// 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). +export function useStateRef(value: T) { + const ref = useRef(value); + + useEffect(() => { + ref.current = value; + }, [value]); + + return ref; +} diff --git a/src/lib/teact/teact.ts b/src/lib/teact/teact.ts index 61e42beae..e058afaf1 100644 --- a/src/lib/teact/teact.ts +++ b/src/lib/teact/teact.ts @@ -100,6 +100,7 @@ const Fragment = Symbol('Fragment'); const DEBUG_RENDER_THRESHOLD = 7; const DEBUG_EFFECT_THRESHOLD = 7; +const DEBUG_SILENT_RENDERS_FOR = new Set(['TeactMemoWrapper', 'TeactNContainer', 'Button', 'ListItem', 'MenuItem']); let renderingInstance: ComponentInstance; @@ -276,7 +277,7 @@ export function renderComponent(componentInstance: ComponentInstance) { } if (DEBUG_MORE) { - if (componentName !== 'TeactMemoWrapper' && componentName !== 'TeactNContainer') { + if (!DEBUG_SILENT_RENDERS_FOR.has(componentName)) { // eslint-disable-next-line no-console console.log(`[Teact] Render ${componentName}`); } diff --git a/src/modules/actions/apiUpdaters/messages.ts b/src/modules/actions/apiUpdaters/messages.ts index 76c23e704..018ab20e1 100644 --- a/src/modules/actions/apiUpdaters/messages.ts +++ b/src/modules/actions/apiUpdaters/messages.ts @@ -5,6 +5,7 @@ import { } from '../../../api/types'; import { unique } from '../../../util/iteratees'; +import { areDeepEqual } from '../../../util/areDeepEqual'; import { updateChat, deleteChatMessages, @@ -468,14 +469,17 @@ addReducer('apiUpdate', (global, actions, update: ApiUpdate) => { } case 'updateMessageReactions': { - setGlobal(updateChatMessage( - global, - update.chatId, - update.id, - { - reactions: update.reactions, - }, - )); + const { chatId, id, reactions } = update; + const message = selectChatMessage(global, chatId, id); + const currentReactions = message?.reactions; + + // `updateMessageReactions` happens with an interval so we try to avoid redundant global state updates + if (currentReactions && areDeepEqual(reactions, currentReactions)) { + return; + } + + setGlobal(updateChatMessage(global, chatId, id, { reactions: update.reactions })); + break; } } diff --git a/src/util/areDeepEqual.ts b/src/util/areDeepEqual.ts new file mode 100644 index 000000000..3bb59556c --- /dev/null +++ b/src/util/areDeepEqual.ts @@ -0,0 +1,35 @@ +export function areDeepEqual(value1: T, value2: T): boolean { + const type1 = typeof value1; + const type2 = typeof value2; + if (type1 !== type2) { + return false; + } + + if (type1 !== 'object') { + return value1 === value2; + } + + const isArray1 = Array.isArray(value1); + const isArray2 = Array.isArray(value2); + + if (isArray1 !== isArray2) { + return false; + } + + if (isArray1) { + const array1 = value1 as any[]; + const array2 = value2 as any[]; + + if (array1.length !== array2.length) { + return false; + } + + return array1.every((member1, i) => areDeepEqual(member1, array2[i])); + } + + const object1 = value1 as AnyLiteral; + const object2 = value1 as AnyLiteral; + const keys1 = Object.keys(object1); + + return keys1.every((key1) => areDeepEqual(object1[key1], object2[key1])); +} diff --git a/src/util/moduleLoader.ts b/src/util/moduleLoader.ts index 0a8135155..869a75c2b 100644 --- a/src/util/moduleLoader.ts +++ b/src/util/moduleLoader.ts @@ -1,4 +1,5 @@ import { DEBUG } from '../config'; +import { createCallbackManager } from './callbacks'; export enum Bundles { Auth, @@ -23,6 +24,8 @@ export type BundleModules = keyof ImportedBundl const LOAD_PROMISES: Partial = {}; const MEMORY_CACHE: Partial = {}; +const { addCallback, runCallbacks } = createCallbackManager(); + export async function loadModule>(bundleName: B, moduleName: M) { if (!LOAD_PROMISES[bundleName]) { switch (bundleName) { @@ -45,7 +48,7 @@ export async function loadModule>( break; } - (LOAD_PROMISES[bundleName] as Promise).then(handleBundleLoad); + (LOAD_PROMISES[bundleName] as Promise).then(runCallbacks); } const bundle = (await LOAD_PROMISES[bundleName]) as unknown as ImportedBundles[B]; @@ -67,16 +70,4 @@ export function getModuleFromMemory { - listener(); - }); -} +export const addLoadListener = addCallback;