import React, { useEffect, useState, memo, useMemo, } from '../../lib/teact/teact'; import { requestMeasure, requestMutation } from '../../lib/fasterdom/fasterdom'; import { getActions, withGlobal } from '../../global'; import type { ApiChat, ApiChatBannedRights } from '../../api/types'; import { MAIN_THREAD_ID } from '../../api/types'; import type { MessageListType, ActiveEmojiInteraction, } from '../../global/types'; import type { ThemeKey } from '../../types'; import { MOBILE_SCREEN_MAX_WIDTH, MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN, SAFE_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN, ANIMATION_END_DELAY, SUPPORTED_IMAGE_CONTENT_TYPES, GENERAL_TOPIC_ID, TMP_CHAT_ID, MAX_SCREEN_WIDTH_FOR_EXPAND_PINNED_MESSAGES, } from '../../config'; import { IS_ANDROID, IS_IOS, IS_TRANSLATION_SUPPORTED, MASK_IMAGE_DISABLED, } from '../../util/windowEnvironment'; import { DropAreaState } from './composer/DropArea'; import { selectCanAnimateInterface, selectChat, selectBot, selectChatFullInfo, selectChatMessage, selectCurrentMessageList, selectCurrentTextSearch, selectIsChatBotNotStarted, selectIsInSelectMode, selectIsRightColumnShown, selectIsUserBlocked, selectPinnedIds, selectReplyingToId, selectTabState, selectTheme, selectThreadInfo, selectThreadTopMessageId, } from '../../global/selectors'; import { getCanPostInChat, getMessageSendingRestrictionReason, getForumComposerPlaceholder, isChatChannel, isChatGroup, isChatSuperGroup, isUserId, isUserRightBanned, getHasAdminRight, } from '../../global/helpers'; import calculateMiddleFooterTransforms from './helpers/calculateMiddleFooterTransforms'; import captureEscKeyListener from '../../util/captureEscKeyListener'; import buildClassName from '../../util/buildClassName'; import buildStyle from '../../util/buildStyle'; import useLastCallback from '../../hooks/useLastCallback'; import useCustomBackground from '../../hooks/useCustomBackground'; import useWindowSize from '../../hooks/useWindowSize'; import usePrevDuringAnimation from '../../hooks/usePrevDuringAnimation'; import useLang from '../../hooks/useLang'; import useHistoryBack from '../../hooks/useHistoryBack'; import usePrevious from '../../hooks/usePrevious'; import useForceUpdate from '../../hooks/useForceUpdate'; import useSyncEffect from '../../hooks/useSyncEffect'; import useAppLayout from '../../hooks/useAppLayout'; import usePinnedMessage from './hooks/usePinnedMessage'; import { useResize } from '../../hooks/useResize'; import Transition from '../ui/Transition'; import MiddleHeader from './MiddleHeader'; import MessageList from './MessageList'; import FloatingActionButtons from './FloatingActionButtons'; import Composer from './composer/Composer'; import Button from '../ui/Button'; import MobileSearch from './MobileSearch.async'; import MessageSelectToolbar from './MessageSelectToolbar.async'; import UnpinAllMessagesModal from '../common/UnpinAllMessagesModal.async'; import SeenByModal from '../common/SeenByModal.async'; import EmojiInteractionAnimation from './EmojiInteractionAnimation.async'; import ReactorListModal from './ReactorListModal.async'; import GiftPremiumModal from '../main/premium/GiftPremiumModal.async'; import MessageLanguageModal from './MessageLanguageModal.async'; import './MiddleColumn.scss'; import styles from './MiddleColumn.module.scss'; interface OwnProps { leftColumnRef: React.RefObject; isMobile?: boolean; } type StateProps = { chatId?: string; threadId?: number; messageListType?: MessageListType; chat?: ApiChat; replyingToId?: number; isPrivate?: boolean; isPinnedMessageList?: boolean; canPost?: boolean; currentUserBannedRights?: ApiChatBannedRights; defaultBannedRights?: ApiChatBannedRights; hasPinned?: boolean; hasAudioPlayer?: boolean; hasButtonInHeader?: boolean; pinnedMessagesCount?: number; theme: ThemeKey; customBackground?: string; backgroundColor?: string; patternColor?: string; isLeftColumnShown?: boolean; isRightColumnShown?: boolean; isBackgroundBlurred?: boolean; leftColumnWidth?: number; hasCurrentTextSearch?: boolean; isSelectModeActive?: boolean; isSeenByModalOpen: boolean; isReactorListModalOpen: boolean; isGiftPremiumModalOpen?: boolean; isMessageLanguageModalOpen?: boolean; withInterfaceAnimations?: boolean; shouldSkipHistoryAnimations?: boolean; currentTransitionKey: number; isChannel?: boolean; areChatSettingsLoaded?: boolean; canSubscribe?: boolean; canStartBot?: boolean; canRestartBot?: boolean; shouldLoadFullChat?: boolean; activeEmojiInteractions?: ActiveEmojiInteraction[]; shouldJoinToSend?: boolean; shouldSendJoinRequest?: boolean; pinnedIds?: number[]; topMessageId?: number; canUnpin?: boolean; }; function isImage(item: DataTransferItem) { return item.kind === 'file' && item.type && SUPPORTED_IMAGE_CONTENT_TYPES.has(item.type); } const LAYER_ANIMATION_DURATION_MS = 450 + ANIMATION_END_DELAY; function MiddleColumn({ leftColumnRef, chatId, threadId, messageListType, isMobile, chat, replyingToId, isPrivate, isPinnedMessageList, canPost, currentUserBannedRights, defaultBannedRights, hasPinned, hasAudioPlayer, hasButtonInHeader, pinnedMessagesCount, customBackground, theme, backgroundColor, patternColor, isLeftColumnShown, isRightColumnShown, isBackgroundBlurred, leftColumnWidth, hasCurrentTextSearch, isSelectModeActive, isSeenByModalOpen, isReactorListModalOpen, isGiftPremiumModalOpen, isMessageLanguageModalOpen, withInterfaceAnimations, shouldSkipHistoryAnimations, currentTransitionKey, isChannel, areChatSettingsLoaded, canSubscribe, canStartBot, canRestartBot, activeEmojiInteractions, shouldJoinToSend, shouldSendJoinRequest, shouldLoadFullChat, pinnedIds, topMessageId, canUnpin, }: OwnProps & StateProps) { const { openChat, openPreviousChat, unpinAllMessages, loadUser, loadChatSettings, closeLocalTextSearch, exitMessageSelectMode, joinChannel, sendBotCommand, restartBot, showNotification, loadFullChat, setLeftColumnWidth, resetLeftColumnWidth, } = getActions(); const { width: windowWidth } = useWindowSize(); const { isTablet, isDesktop } = useAppLayout(); const lang = useLang(); const [dropAreaState, setDropAreaState] = useState(DropAreaState.None); const [isFabShown, setIsFabShown] = useState(); const [isNotchShown, setIsNotchShown] = useState(); const [isUnpinModalOpen, setIsUnpinModalOpen] = useState(false); const { onIntersectionChanged, onFocusPinnedMessage, getCurrentPinnedIndexes, getLoadingPinnedId, getForceNextPinnedInHeader, } = usePinnedMessage(chatId, threadId, pinnedIds, topMessageId); const isMobileSearchActive = isMobile && hasCurrentTextSearch; const closeAnimationDuration = isMobile ? LAYER_ANIMATION_DURATION_MS : undefined; const hasTools = hasPinned && ( windowWidth < MOBILE_SCREEN_MAX_WIDTH || hasAudioPlayer || ( isRightColumnShown && windowWidth > MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN && windowWidth < SAFE_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN ) || (!isMobile && hasButtonInHeader && windowWidth < MAX_SCREEN_WIDTH_FOR_EXPAND_PINNED_MESSAGES) ); const renderingChatId = usePrevDuringAnimation(chatId, closeAnimationDuration); const renderingThreadId = usePrevDuringAnimation(threadId, closeAnimationDuration); const renderingMessageListType = usePrevDuringAnimation(messageListType, closeAnimationDuration); const renderingCanSubscribe = usePrevDuringAnimation(canSubscribe, closeAnimationDuration); const renderingCanStartBot = usePrevDuringAnimation(canStartBot, closeAnimationDuration); const renderingCanRestartBot = usePrevDuringAnimation(canRestartBot, closeAnimationDuration); const renderingCanPost = usePrevDuringAnimation(canPost, closeAnimationDuration) && !renderingCanRestartBot && !renderingCanStartBot && !renderingCanSubscribe && chatId !== TMP_CHAT_ID; const renderingHasTools = usePrevDuringAnimation(hasTools, closeAnimationDuration); const renderingIsFabShown = usePrevDuringAnimation(isFabShown, closeAnimationDuration) && chatId !== TMP_CHAT_ID; const renderingIsChannel = usePrevDuringAnimation(isChannel, closeAnimationDuration); const renderingShouldJoinToSend = usePrevDuringAnimation(shouldJoinToSend, closeAnimationDuration); const renderingShouldSendJoinRequest = usePrevDuringAnimation(shouldSendJoinRequest, closeAnimationDuration); const renderingOnPinnedIntersectionChange = usePrevDuringAnimation( chatId ? onIntersectionChanged : undefined, closeAnimationDuration, ); const prevTransitionKey = usePrevious(currentTransitionKey); const cleanupExceptionKey = ( prevTransitionKey !== undefined && prevTransitionKey < currentTransitionKey ? prevTransitionKey : undefined ); const { isReady, handleCssTransitionEnd, handleSlideTransitionStop } = useIsReady( !shouldSkipHistoryAnimations && withInterfaceAnimations, currentTransitionKey, prevTransitionKey, chatId, isMobile, ); useEffect(() => { return chatId ? captureEscKeyListener(() => { openChat({ id: undefined }); }) : undefined; }, [chatId, openChat]); useSyncEffect(() => { setDropAreaState(DropAreaState.None); setIsNotchShown(undefined); }, [chatId]); // Fix for mobile virtual keyboard useEffect(() => { if (!IS_IOS && !IS_ANDROID) { return undefined; } const { visualViewport } = window; if (!visualViewport) { return undefined; } const handleResize = () => { const isFixNeeded = visualViewport.height !== document.documentElement.clientHeight; requestMutation(() => { document.body.classList.toggle('keyboard-visible', isFixNeeded); requestMeasure(() => { if (!isFixNeeded && visualViewport.offsetTop) { requestMutation(() => { window.scrollTo({ top: 0 }); }); } }); }); }; visualViewport.addEventListener('resize', handleResize); return () => { visualViewport.removeEventListener('resize', handleResize); }; }); useEffect(() => { if (isPrivate) { loadUser({ userId: chatId! }); } }, [chatId, isPrivate, loadUser]); useEffect(() => { if (!areChatSettingsLoaded) { loadChatSettings({ chatId: chatId! }); } }, [chatId, isPrivate, areChatSettingsLoaded]); useEffect(() => { if (chatId && shouldLoadFullChat && isReady) { loadFullChat({ chatId }); } }, [shouldLoadFullChat, chatId, isReady, loadFullChat]); const { initResize, resetResize, handleMouseUp, } = useResize(leftColumnRef, (n) => setLeftColumnWidth({ leftColumnWidth: n, }), resetLeftColumnWidth, leftColumnWidth, '--left-column-width'); const handleDragEnter = useLastCallback((e: React.DragEvent) => { const { items } = e.dataTransfer || {}; const shouldDrawQuick = items && items.length > 0 && Array.from(items) // Filter unnecessary element for drag and drop images in Firefox (https://github.com/Ajaxy/telegram-tt/issues/49) // https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types#image .filter((item) => item.type !== 'text/uri-list') // As of September 2021, native clients suggest "send quick, but compressed" only for images .every(isImage); setDropAreaState(shouldDrawQuick ? DropAreaState.QuickFile : DropAreaState.Document); }); const handleHideDropArea = useLastCallback(() => { setDropAreaState(DropAreaState.None); }); const handleOpenUnpinModal = useLastCallback(() => { setIsUnpinModalOpen(true); }); const closeUnpinModal = useLastCallback(() => { setIsUnpinModalOpen(false); }); const handleUnpinAllMessages = useLastCallback(() => { unpinAllMessages({ chatId: chatId!, threadId: threadId! }); closeUnpinModal(); openPreviousChat(); }); const handleTabletFocus = useLastCallback(() => { openChat({ id: chatId }); }); const handleSubscribeClick = useLastCallback(() => { joinChannel({ chatId: chatId! }); if (renderingShouldSendJoinRequest) { showNotification({ message: isChannel ? lang('RequestToJoinChannelSentDescription') : lang('RequestToJoinGroupSentDescription'), }); } }); const handleStartBot = useLastCallback(() => { sendBotCommand({ command: '/start' }); }); const handleRestartBot = useLastCallback(() => { restartBot({ chatId: chatId! }); }); const customBackgroundValue = useCustomBackground(theme, customBackground); const className = buildClassName( renderingHasTools && 'has-header-tools', MASK_IMAGE_DISABLED ? 'mask-image-disabled' : 'mask-image-enabled', ); const bgClassName = buildClassName( styles.background, styles.withTransition, customBackground && styles.customBgImage, backgroundColor && styles.customBgColor, customBackground && isBackgroundBlurred && styles.blurred, isRightColumnShown && styles.withRightColumn, ); const messagingDisabledClassName = buildClassName( 'messaging-disabled', !isSelectModeActive && 'shown', ); const messageSendingRestrictionReason = getMessageSendingRestrictionReason( lang, currentUserBannedRights, defaultBannedRights, ); const forumComposerPlaceholder = getForumComposerPlaceholder(lang, chat, threadId, Boolean(replyingToId)); const composerRestrictionMessage = messageSendingRestrictionReason || forumComposerPlaceholder; // CSS Variables calculation doesn't work properly with transforms, so we calculate transform values in JS const { composerHiddenScale, toolbarHiddenScale, composerTranslateX, toolbarTranslateX, unpinHiddenScale, toolbarForUnpinHiddenScale, } = useMemo( () => calculateMiddleFooterTransforms(windowWidth, renderingCanPost), [renderingCanPost, windowWidth], ); const footerClassName = buildClassName( 'middle-column-footer', !renderingCanPost && 'no-composer', renderingCanPost && isNotchShown && !isSelectModeActive && 'with-notch', ); useHistoryBack({ isActive: isSelectModeActive, onBack: exitMessageSelectMode, }); useHistoryBack({ isActive: isMobileSearchActive, onBack: closeLocalTextSearch, }); const isMessagingDisabled = Boolean( !isPinnedMessageList && !renderingCanPost && !renderingCanRestartBot && !renderingCanStartBot && !renderingCanSubscribe && composerRestrictionMessage, ); const withMessageListBottomShift = Boolean( renderingCanRestartBot || renderingCanSubscribe || renderingShouldSendJoinRequest || renderingCanStartBot || isPinnedMessageList, ); const withExtraShift = Boolean(isMessagingDisabled || isSelectModeActive || isPinnedMessageList); return (
{isDesktop && (
)}
{Boolean(renderingChatId && renderingThreadId) && ( <>
{renderingCanPost && ( )} {isPinnedMessageList && canUnpin && (
)} {isMessagingDisabled && (
{composerRestrictionMessage}
)} {( isMobile && (renderingCanSubscribe || (renderingShouldJoinToSend && !renderingShouldSendJoinRequest)) ) && (
)} {isMobile && renderingShouldSendJoinRequest && (
)} {isMobile && renderingCanStartBot && (
)} {isMobile && renderingCanRestartBot && (
)} {IS_TRANSLATION_SUPPORTED && }
{isMobile && } )} {chatId && ( )}
{activeEmojiInteractions?.map((activeEmojiInteraction, i) => ( ))}
); } export default memo(withGlobal( (global, { isMobile }): StateProps => { const theme = selectTheme(global); const { isBlurred: isBackgroundBlurred, background: customBackground, backgroundColor, patternColor, } = global.settings.themes[theme] || {}; const { messageLists, isLeftColumnShown, activeEmojiInteractions, seenByModal, giftPremiumModal, reactorModal, audioPlayer, shouldSkipHistoryAnimations, messageLanguageModal, } = selectTabState(global); const currentMessageList = selectCurrentMessageList(global); const { leftColumnWidth } = global; const state: StateProps = { theme, customBackground, backgroundColor, patternColor, isLeftColumnShown, isRightColumnShown: selectIsRightColumnShown(global, isMobile), isBackgroundBlurred, hasCurrentTextSearch: Boolean(selectCurrentTextSearch(global)), isSelectModeActive: selectIsInSelectMode(global), isSeenByModalOpen: Boolean(seenByModal), isReactorListModalOpen: Boolean(reactorModal), isGiftPremiumModalOpen: giftPremiumModal?.isOpen, isMessageLanguageModalOpen: Boolean(messageLanguageModal), withInterfaceAnimations: selectCanAnimateInterface(global), currentTransitionKey: Math.max(0, messageLists.length - 1), activeEmojiInteractions, leftColumnWidth, }; if (!currentMessageList) { return state; } const { chatId, threadId, type: messageListType } = currentMessageList; const isPrivate = isUserId(chatId); const chat = selectChat(global, chatId); const bot = selectBot(global, chatId); const pinnedIds = selectPinnedIds(global, chatId, threadId); const { chatId: audioChatId, messageId: audioMessageId } = audioPlayer; const threadInfo = selectThreadInfo(global, chatId, threadId); const isComments = Boolean(threadInfo?.originChannelId); const canPost = chat && getCanPostInChat(chat, threadId, isComments); const isBotNotStarted = selectIsChatBotNotStarted(global, chatId); const isPinnedMessageList = messageListType === 'pinned'; const isMainThread = messageListType === 'thread' && threadId === MAIN_THREAD_ID; const isChannel = Boolean(chat && isChatChannel(chat)); const canSubscribe = Boolean( chat && isMainThread && (isChannel || isChatSuperGroup(chat)) && chat.isNotJoined && !chat.joinRequests, ); const shouldJoinToSend = Boolean(chat?.isNotJoined && chat.isJoinToSend); const shouldSendJoinRequest = Boolean(chat?.isNotJoined && chat.isJoinRequest); const canRestartBot = Boolean(bot && selectIsUserBlocked(global, bot.id)); const canStartBot = !canRestartBot && isBotNotStarted; const shouldLoadFullChat = Boolean( chat && isChatGroup(chat) && !selectChatFullInfo(global, chat.id), ); const replyingToId = selectReplyingToId(global, chatId, threadId); const shouldBlockSendInForum = chat?.isForum ? threadId === MAIN_THREAD_ID && !replyingToId && (chat.topics?.[GENERAL_TOPIC_ID]?.isClosed) : false; const audioMessage = audioChatId && audioMessageId ? selectChatMessage(global, audioChatId, audioMessageId) : undefined; const isCommentThread = threadId !== MAIN_THREAD_ID && !chat?.isForum; const topMessageId = isCommentThread ? selectThreadTopMessageId(global, chatId, threadId) : undefined; const canUnpin = chat && ( isPrivate || ( chat?.isCreator || (!isChannel && !isUserRightBanned(chat, 'pinMessages')) || getHasAdminRight(chat, 'pinMessages') ) ); return { ...state, chatId, threadId, messageListType, chat, replyingToId, isPrivate, areChatSettingsLoaded: Boolean(chat?.settings), canPost: !isPinnedMessageList && (!chat || canPost) && !isBotNotStarted && !(shouldJoinToSend && chat?.isNotJoined) && !shouldBlockSendInForum, isPinnedMessageList, currentUserBannedRights: chat?.currentUserBannedRights, defaultBannedRights: chat?.defaultBannedRights, hasPinned: isCommentThread || Boolean(!isPinnedMessageList && pinnedIds?.length), hasAudioPlayer: Boolean(audioMessage), hasButtonInHeader: canStartBot || canRestartBot || canSubscribe || shouldSendJoinRequest, pinnedMessagesCount: pinnedIds ? pinnedIds.length : 0, shouldSkipHistoryAnimations, isChannel, canSubscribe, canStartBot, canRestartBot, shouldJoinToSend, shouldSendJoinRequest, shouldLoadFullChat, pinnedIds, topMessageId, canUnpin, }; }, )(MiddleColumn)); function useIsReady( withAnimations?: boolean, currentTransitionKey?: number, prevTransitionKey?: number, chatId?: string, isMobile?: boolean, ) { const [isReady, setIsReady] = useState(!isMobile); const forceUpdate = useForceUpdate(); const willSwitchMessageList = prevTransitionKey !== undefined && prevTransitionKey !== currentTransitionKey; if (willSwitchMessageList) { if (withAnimations) { setIsReady(false); // Make sure to end even if end callback was not called (which was some hardly-reproducible bug) setTimeout(() => { setIsReady(true); }, LAYER_ANIMATION_DURATION_MS); } else { forceUpdate(); } } useSyncEffect(() => { if (!withAnimations) { setIsReady(true); } }, [withAnimations]); function handleCssTransitionEnd(e: React.TransitionEvent) { if (e.propertyName === 'transform' && e.target === e.currentTarget) { setIsReady(Boolean(chatId)); } } function handleSlideTransitionStop() { setIsReady(true); } return { isReady: isReady && !willSwitchMessageList, handleCssTransitionEnd: withAnimations ? handleCssTransitionEnd : undefined, handleSlideTransitionStop: withAnimations ? handleSlideTransitionStop : undefined, }; }