import type { FC } from '../../../lib/teact/teact'; import { memo, useEffect, useMemo, useRef, useState } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { ApiAttachBot, ApiBotAppSettings, ApiUser } from '../../../api/types'; import type { TabState } from '../../../global/types'; import type { BotAppPermissions, ThemeKey } from '../../../types'; import type { PopupOptions, WebApp, WebAppInboundEvent, WebAppModalStateType, WebAppOutboundEvent, } from '../../../types/webapp'; import { TME_LINK_PREFIX, VERIFY_AGE_MIN_DEFAULT } from '../../../config'; import { convertToApiChatType, getUserFullName } from '../../../global/helpers'; import { getWebAppKey } from '../../../global/helpers/bots'; import { selectBotAppPermissions, selectTabState, selectTheme, selectUser, selectUserFullInfo, selectWebApp, } from '../../../global/selectors'; import { getGeolocationStatus, IS_GEOLOCATION_SUPPORTED } from '../../../util/browser/windowEnvironment'; import buildClassName from '../../../util/buildClassName'; import buildStyle from '../../../util/buildStyle.ts'; import download from '../../../util/download'; import { extractCurrentThemeParams, validateHexColor } from '../../../util/themeStyle'; import { callApi } from '../../../api/gramjs'; import { REM } from '../../common/helpers/mediaDimensions'; import renderText from '../../common/helpers/renderText'; import { getIsWebAppsFullscreenSupported } from '../../../hooks/useAppLayout'; import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; import useEffectWithPrevDeps from '../../../hooks/useEffectWithPrevDeps'; import useFlag from '../../../hooks/useFlag'; import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; import useOldLang from '../../../hooks/useOldLang'; import useSyncEffect from '../../../hooks/useSyncEffect'; import useFullscreen, { checkIfFullscreen } from '../../../hooks/window/useFullscreen'; import usePopupLimit from './hooks/usePopupLimit'; import useWebAppFrame from './hooks/useWebAppFrame'; import CustomEmoji from '../../common/CustomEmoji'; import Icon from '../../common/icons/Icon'; import Button from '../../ui/Button'; import ConfirmDialog from '../../ui/ConfirmDialog'; import Modal from '../../ui/Modal'; import Spinner from '../../ui/Spinner'; import Transition from '../../ui/Transition'; import styles from './WebAppModalTabContent.module.scss'; type WebAppButton = { isVisible: boolean; isActive: boolean; text: string; color: string; textColor: string; isProgressVisible: boolean; iconCustomEmojiId?: string; hasShineEffect?: boolean; position?: 'left' | 'right' | 'top' | 'bottom'; }; export type OwnProps = { modal?: TabState['webApps']; webApp?: WebApp; registerSendEventCallback: (callback: (event: WebAppOutboundEvent) => void) => void; registerReloadFrameCallback: (callback: (url: string) => void) => void; onContextMenuButtonClick: (e: React.MouseEvent) => void; isTransforming?: boolean; isMultiTabSupported?: boolean; modalHeight: number; }; type StateProps = { bot?: ApiUser; currentUser?: ApiUser; botAppSettings?: ApiBotAppSettings; attachBot?: ApiAttachBot; theme?: ThemeKey; isPaymentModalOpen?: boolean; paymentStatus?: TabState['payment']['status']; modalState?: WebAppModalStateType; botAppPermissions?: BotAppPermissions; verifyAgeMin?: number; verifyAgeBotUsername?: string; }; const MAIN_BUTTON_ANIMATION_TIME = 250; const ANIMATION_WAIT = 400; const COLLAPSING_WAIT = 350; const POPUP_SEQUENTIAL_LIMIT = 3; const POPUP_RESET_DELAY = 2000; // 2s const APP_NAME_DISPLAY_DURATION = 3800; const SANDBOX_ATTRIBUTES = [ 'allow-scripts', 'allow-popups', 'allow-forms', 'allow-modals', 'allow-same-origin', 'allow-storage-access-by-user-activation', ].join(' '); const DEFAULT_BUTTON_TEXT: Record = { ok: 'OK', cancel: 'Cancel', close: 'Close', }; const NBSP = '\u00A0'; const WebAppModalTabContent: FC = ({ modal, webApp, bot, theme, isPaymentModalOpen, paymentStatus, isTransforming, modalState, isMultiTabSupported, botAppPermissions, botAppSettings, modalHeight, verifyAgeMin = VERIFY_AGE_MIN_DEFAULT, verifyAgeBotUsername, registerSendEventCallback, registerReloadFrameCallback, onContextMenuButtonClick, }) => { const { closeActiveWebApp, sendWebViewData, toggleAttachBot, openTelegramLink, setWebAppPaymentSlug, switchBotInline, sharePhoneWithBot, updateWebApp, resetPaymentStatus, openChatWithInfo, showNotification, openEmojiStatusAccessModal, openLocationAccessModal, changeWebAppModalState, closeWebAppModal, openPreparedInlineMessageModal, updateContentSettings, } = getActions(); const [mainButton, setMainButton] = useState(); const [secondaryButton, setSecondaryButton] = useState(); const [isLoaded, markLoaded, markUnloaded] = useFlag(false); const [popupParameters, setPopupParameters] = useState(); const renderingPopupParameters = useCurrentOrPrev(popupParameters); const [isRequestingPhone, setIsRequestingPhone] = useState(false); const [isRequestingWriteAccess, setIsRequestingWriteAccess] = useState(false); const [clipboardRequestId, setClipboardRequestId] = useState(); const [requestedFileDownload, setRequestedFileDownload] = useState<{ url: string; fileName: string }>(); const [bottomBarColor, setBottomBarColor] = useState(); const { unlockPopupsAt, handlePopupOpened, handlePopupClosed, } = usePopupLimit(POPUP_SEQUENTIAL_LIMIT, POPUP_RESET_DELAY); const containerRef = useRef(); const headerButtonRef = useRef(); const headerButtonCaptionRef = useRef(); const isFullscreen = modalState === 'fullScreen'; const isMinimizedState = modalState === 'minimized'; const exitFullScreenCallback = useLastCallback(() => { setTimeout(() => { changeWebAppModalState({ state: 'maximized' }); }, COLLAPSING_WAIT); }); const fullscreenElementRef = useRef(); useEffect(() => { fullscreenElementRef.current = document.querySelector('#portals') as HTMLElement; }, []); const [, setFullscreen, exitFullscreen] = useFullscreen(fullscreenElementRef, exitFullScreenCallback); const activeWebApp = modal?.activeWebAppKey ? modal.openedWebApps[modal.activeWebAppKey] : undefined; const { appName: activeWebAppName, backgroundColor } = activeWebApp || {}; const { url, buttonText, isBackButtonVisible, } = webApp || {}; const { placeholderPath, } = botAppSettings || {}; const isCloseModalOpen = Boolean(webApp?.isCloseModalOpen); const isRemoveModalOpen = Boolean(webApp?.isRemoveModalOpen); const webAppKey = webApp && getWebAppKey(webApp); const activeWebAppKey = activeWebApp && getWebAppKey(activeWebApp); const isActive = (activeWebApp && webApp) && activeWebAppKey === webAppKey; const isAvailable = IS_GEOLOCATION_SUPPORTED; const isAccessRequested = botAppPermissions?.geolocation !== undefined; const isAccessGranted = Boolean(botAppPermissions?.geolocation); const updateCurrentWebApp = useLastCallback((updatedPartialWebApp: Partial) => { if (!webAppKey) return; updateWebApp({ key: webAppKey, update: updatedPartialWebApp }); }); const themeParams = useMemo(() => { return extractCurrentThemeParams(); // eslint-disable-next-line react-hooks-static-deps/exhaustive-deps }, [theme]); useEffect(() => { setBottomBarColor(themeParams.secondary_bg_color); }, [themeParams]); const themeBackgroundColor = themeParams.bg_color; const [backgroundColorFromEvent, setBackgroundColorFromEvent] = useState(); const backgroundColorFromSettings = theme === 'light' ? botAppSettings?.backgroundColor : botAppSettings?.backgroundDarkColor; useEffect(() => { const color = backgroundColorFromEvent || backgroundColorFromSettings || themeBackgroundColor; updateCurrentWebApp({ backgroundColor: color }); }, [themeBackgroundColor, backgroundColorFromEvent, backgroundColorFromSettings]); const themeHeaderColor = themeParams.bg_color; const [headerColorFromEvent, setHeaderColorFromEvent] = useState(); const headerColorFromSettings = theme === 'light' ? botAppSettings?.headerColor : botAppSettings?.headerDarkColor; useEffect(() => { const color = headerColorFromEvent || headerColorFromSettings || themeHeaderColor; updateCurrentWebApp({ headerColor: color }); }, [themeHeaderColor, headerColorFromEvent, headerColorFromSettings]); const frameRef = useRef(); const oldLang = useOldLang(); const lang = useLang(); const isOpen = modal?.isModalOpen || false; const isSimple = Boolean(buttonText); const { reloadFrame, sendEvent, sendFullScreenChanged, sendViewport, sendSafeArea, sendTheme, } = useWebAppFrame(frameRef, isOpen, isFullscreen, isSimple, handleEvent, webApp, markLoaded); useEffect(() => { if (isActive) registerSendEventCallback(sendEvent); }, [sendEvent, registerSendEventCallback, isActive]); useEffect(() => { if (isActive) registerReloadFrameCallback(reloadFrame); }, [reloadFrame, registerReloadFrameCallback, isActive]); function hasBottomButtonContent(text: string | undefined, iconCustomEmojiId?: string) { return Boolean(text?.trim().length || iconCustomEmojiId); } const isMainButtonVisible = isLoaded && mainButton?.isVisible && hasBottomButtonContent( mainButton.text, mainButton.iconCustomEmojiId, ); const isSecondaryButtonVisible = isLoaded && secondaryButton?.isVisible && hasBottomButtonContent( secondaryButton.text, secondaryButton.iconCustomEmojiId, ); const handleHideCloseModal = useLastCallback(() => { updateCurrentWebApp({ isCloseModalOpen: false }); }); const handleConfirmCloseModal = useLastCallback(() => { updateCurrentWebApp({ shouldConfirmClosing: false, isCloseModalOpen: false }); setTimeout(() => { closeActiveWebApp(); }, ANIMATION_WAIT); }); const handleHideRemoveModal = useLastCallback(() => { updateCurrentWebApp({ isRemoveModalOpen: false }); }); const handleMainButtonClick = useLastCallback(() => { sendEvent({ eventType: 'main_button_pressed', }); }); const handleSecondaryButtonClick = useLastCallback(() => { sendEvent({ eventType: 'secondary_button_pressed', }); }); const handleAppPopupClose = useLastCallback((buttonId?: string) => { setPopupParameters(undefined); handlePopupClosed(); sendEvent({ eventType: 'popup_closed', eventData: { button_id: buttonId, }, }); }); const handleAppPopupModalClose = useLastCallback(() => { handleAppPopupClose(); }); const sendThemeCallback = useLastCallback(() => { sendTheme(); }); // Notify view that theme changed useSyncEffect(() => { setTimeout(() => { sendThemeCallback(); }, ANIMATION_WAIT); }, [theme]); const setFullscreenCallback = useLastCallback(() => { if (!checkIfFullscreen() && isActive) { setFullscreen?.(); } }); const exitIfFullscreenCallback = useLastCallback(() => { if (checkIfFullscreen() && isActive) { exitFullscreen?.(); } }); const sendFullScreenChangedCallback = useLastCallback( (value: boolean) => { if (isActive) sendFullScreenChanged(value); }, ); useEffect(() => { if (isFullscreen) { setFullscreenCallback(); sendFullScreenChangedCallback(true); } else { exitIfFullscreenCallback(); sendFullScreenChangedCallback(false); } }, [isFullscreen]); const visibilityChangedCallBack = useLastCallback((visibility: boolean) => { sendEvent({ eventType: 'visibility_changed', eventData: { is_visible: visibility, }, }); }); useEffect(() => { if (isLoaded) { visibilityChangedCallBack(Boolean(isActive)); } }, [isActive, isLoaded]); useEffectWithPrevDeps(([prevModalState]) => { if (modalState === 'minimized') { visibilityChangedCallBack(false); } if (modalState && prevModalState === 'minimized') { visibilityChangedCallBack(true); } }, [modalState]); useSyncEffect(([prevIsPaymentModalOpen]) => { if (isPaymentModalOpen === prevIsPaymentModalOpen) return; if (webApp?.slug && !isPaymentModalOpen && paymentStatus) { sendEvent({ eventType: 'invoice_closed', eventData: { slug: webApp.slug, status: paymentStatus, }, }); setWebAppPaymentSlug({ slug: undefined, }); resetPaymentStatus(); } }, [isPaymentModalOpen, paymentStatus, sendEvent, webApp?.slug]); const handleRemoveAttachBot = useLastCallback(() => { toggleAttachBot({ botId: bot!.id, isEnabled: false, }); closeActiveWebApp(); }); const handleRejectPhone = useLastCallback(() => { setIsRequestingPhone(false); handlePopupClosed(); sendEvent({ eventType: 'phone_requested', eventData: { status: 'cancelled', }, }); }); const handleAcceptPhone = useLastCallback(() => { sharePhoneWithBot({ botId: bot!.id }); setIsRequestingPhone(false); handlePopupClosed(); sendEvent({ eventType: 'phone_requested', eventData: { status: 'sent', }, }); }); const handleRejectFileDownload = useLastCallback((shouldCloseActive?: boolean) => { if (shouldCloseActive) { setRequestedFileDownload(undefined); handlePopupClosed(); } sendEvent({ eventType: 'file_download_requested', eventData: { status: 'cancelled', }, }); }); const handleRejectWriteAccess = useLastCallback(() => { sendEvent({ eventType: 'write_access_requested', eventData: { status: 'cancelled', }, }); setIsRequestingWriteAccess(false); handlePopupClosed(); }); const handleAcceptWriteAccess = useLastCallback(async () => { if (!bot) return; const result = await callApi('allowBotSendMessages', { bot }); if (!result) { handleRejectWriteAccess(); return; } sendEvent({ eventType: 'write_access_requested', eventData: { status: 'allowed', }, }); setIsRequestingWriteAccess(false); handlePopupClosed(); }); async function handleRequestWriteAccess() { if (!bot) return; const canWrite = await callApi('fetchBotCanSendMessage', { bot, }); if (canWrite) { sendEvent({ eventType: 'write_access_requested', eventData: { status: 'allowed', }, }); } setIsRequestingWriteAccess(!canWrite); } const handleRejectClipboardText = useLastCallback(() => { if (!clipboardRequestId) return; setClipboardRequestId(undefined); sendEvent({ eventType: 'clipboard_text_received', eventData: { req_id: clipboardRequestId, // eslint-disable-next-line no-null/no-null data: null, }, }); }); const handleConfirmClipboardText = useLastCallback(() => { const reqId = clipboardRequestId; if (!reqId) return; setClipboardRequestId(undefined); window.navigator.clipboard.readText().then((clipboardText) => { sendEvent({ eventType: 'clipboard_text_received', eventData: { req_id: reqId, data: clipboardText, }, }); }).catch(() => { sendEvent({ eventType: 'clipboard_text_received', eventData: { req_id: reqId, // eslint-disable-next-line no-null/no-null data: null, }, }); }); }); async function handleCheckDownloadFile(fileUrl: string, fileName: string) { const canDownload = await callApi('checkBotDownloadFileParams', { bot: bot!, url: fileUrl, fileName, }); if (!canDownload) { sendEvent({ eventType: 'file_download_requested', eventData: { status: 'cancelled', }, }); return; } setRequestedFileDownload({ url: fileUrl, fileName }); handlePopupOpened(); } const handleDownloadFile = useLastCallback(() => { if (!requestedFileDownload) return; setRequestedFileDownload(undefined); handlePopupClosed(); download(requestedFileDownload.url, requestedFileDownload.fileName); sendEvent({ eventType: 'file_download_requested', eventData: { status: 'downloading', }, }); }); async function handleInvokeCustomMethod(requestId: string, method: string, parameters: string) { const result = await callApi('invokeWebViewCustomMethod', { bot: bot!, customMethod: method, parameters, }); sendEvent({ eventType: 'custom_method_invoked', eventData: { req_id: requestId, ...result, }, }); } useEffect(() => { if (!isOpen) { setPopupParameters(undefined); setIsRequestingPhone(false); setIsRequestingWriteAccess(false); setMainButton(undefined); setSecondaryButton(undefined); setRequestedFileDownload(undefined); setClipboardRequestId(undefined); updateCurrentWebApp({ isSettingsButtonVisible: false, shouldConfirmClosing: false, isBackButtonVisible: false, isCloseModalOpen: false, isRemoveModalOpen: false, }); markUnloaded(); } }, [isOpen]); const handleOpenChat = useLastCallback(() => { openChatWithInfo({ id: bot!.id }); }); function handleEvent(event: WebAppInboundEvent) { const { eventType, eventData } = event; if (eventType === 'web_app_request_fullscreen') { if (getIsWebAppsFullscreenSupported()) { changeWebAppModalState({ state: 'fullScreen' }); } else { sendEvent({ eventType: 'fullscreen_failed', eventData: { error: 'UNSUPPORTED', }, }); } } if (eventType === 'web_app_exit_fullscreen') { exitIfFullscreenCallback(); } if (eventType === 'web_app_open_tg_link') { changeWebAppModalState({ state: 'minimized' }); const linkUrl = TME_LINK_PREFIX + eventData.path_full; openTelegramLink({ url: linkUrl, shouldIgnoreCache: eventData.force_request }); } if (eventType === 'web_app_setup_back_button') { updateCurrentWebApp({ isBackButtonVisible: eventData.is_visible }); } if (eventType === 'web_app_setup_settings_button') { updateCurrentWebApp({ isSettingsButtonVisible: eventData.is_visible }); } if (eventType === 'web_app_set_background_color') { setBackgroundColorFromEvent(validateHexColor(eventData.color) ? eventData.color : undefined); } if (eventType === 'web_app_set_header_color') { const key = eventData.color_key; setHeaderColorFromEvent(eventData.color || (key ? themeParams[key] : undefined)); } if (eventType === 'web_app_set_bottom_bar_color') { setBottomBarColor(eventData.color); } if (eventType === 'web_app_data_send') { closeActiveWebApp(); sendWebViewData({ bot: bot!, buttonText: buttonText!, data: eventData.data, }); } if (eventType === 'web_app_setup_main_button') { const color = eventData.color; const textColor = eventData.text_color; setMainButton({ isVisible: eventData.is_visible && hasBottomButtonContent(eventData.text, eventData.icon_custom_emoji_id), isActive: eventData.is_active, text: eventData.text, color, textColor, isProgressVisible: eventData.is_progress_visible, iconCustomEmojiId: eventData.icon_custom_emoji_id, hasShineEffect: eventData.has_shine_effect, }); } if (eventType === 'web_app_setup_secondary_button') { const color = eventData.color; const textColor = eventData.text_color; setSecondaryButton({ isVisible: eventData.is_visible && hasBottomButtonContent(eventData.text, eventData.icon_custom_emoji_id), isActive: eventData.is_active, text: eventData.text, color, textColor, isProgressVisible: eventData.is_progress_visible, iconCustomEmojiId: eventData.icon_custom_emoji_id, hasShineEffect: eventData.has_shine_effect, position: eventData.position, }); } if (eventType === 'web_app_setup_closing_behavior') { updateCurrentWebApp({ shouldConfirmClosing: eventData.need_confirmation }); } if (eventType === 'web_app_open_popup') { if (popupParameters || !eventData.message.trim().length || !eventData.buttons?.length || eventData.buttons.length > 3 || isRequestingPhone || isRequestingWriteAccess || unlockPopupsAt > Date.now()) { handleAppPopupClose(undefined); return; } setPopupParameters(eventData); handlePopupOpened(); } if (eventType === 'web_app_switch_inline_query') { const filter = eventData.chat_types?.map(convertToApiChatType).filter(Boolean); const isSamePeer = !filter?.length; switchBotInline({ botId: bot!.id, query: eventData.query, filter, isSamePeer, }); closeActiveWebApp(); } if (eventType === 'web_app_request_phone') { if (popupParameters || isRequestingWriteAccess || unlockPopupsAt > Date.now()) { handleRejectPhone(); return; } setIsRequestingPhone(true); handlePopupOpened(); } if (eventType === 'web_app_request_write_access') { if (popupParameters || isRequestingPhone || unlockPopupsAt > Date.now()) { handleRejectWriteAccess(); return; } handleRequestWriteAccess(); handlePopupOpened(); } if (eventType === 'web_app_invoke_custom_method') { const { method, params, req_id: requestId } = eventData; handleInvokeCustomMethod(requestId, method, JSON.stringify(params)); } if (eventType === 'web_app_request_file_download') { if (requestedFileDownload || unlockPopupsAt > Date.now()) { handleRejectFileDownload(); return; } handleCheckDownloadFile(eventData.url, eventData.file_name); } if (eventType === 'web_app_send_prepared_message') { if (!bot || !webAppKey) return; const { id } = eventData; openPreparedInlineMessageModal({ botId: bot.id, messageId: id, webAppKey }); } if (eventType === 'web_app_request_emoji_status_access') { if (!bot) return; openEmojiStatusAccessModal({ bot, webAppKey }); } if (eventType === 'web_app_check_location') { const handleGeolocationCheck = () => { sendEvent({ eventType: 'location_checked', eventData: { available: isAvailable, access_requested: isAccessRequested, access_granted: isAccessGranted, }, }); }; handleGeolocationCheck(); } if (eventType === 'web_app_request_location') { const handleRequestLocation = async () => { const geolocationData = await getGeolocationStatus(); const { accessRequested, accessGranted, geolocation } = geolocationData; if (!accessGranted || !accessRequested) { sendEvent({ eventType: 'location_requested', eventData: { available: false, }, }); showNotification({ message: oldLang('PermissionNoLocationPosition') }); handleAppPopupClose(undefined); return; } if (isAvailable) { if (isAccessRequested) { sendEvent({ eventType: 'location_requested', eventData: { available: Boolean(botAppPermissions?.geolocation), latitude: geolocation?.latitude, longitude: geolocation?.longitude, altitude: geolocation?.altitude, course: geolocation?.heading, speed: geolocation?.speed, horizontal_accuracy: geolocation?.accuracy, vertical_accuracy: geolocation?.altitudeAccuracy, }, }); } else { openLocationAccessModal({ bot, webAppKey }); } } else { showNotification({ message: oldLang('PermissionNoLocationPosition') }); handleAppPopupClose(undefined); } }; handleRequestLocation(); } if (eventType === 'web_app_open_location_settings') { handleOpenChat(); } if (eventType === 'web_app_read_text_from_clipboard') { setClipboardRequestId(eventData.req_id); } if (eventType === 'web_app_verify_age') { if (!bot?.usernames?.some((username) => username.username === verifyAgeBotUsername)) return; const { passed } = eventData; const minAge = verifyAgeMin; const ageFromParam = eventData.age || 0; if (passed && ageFromParam >= minAge) { showNotification({ message: { key: 'TitleAgeCheckSuccess', }, }); updateContentSettings({ isSensitiveEnabled: true }); } else { showNotification({ message: { key: 'TitleAgeCheckFailed', }, }); } } } const mainButtonCurrentColor = useCurrentOrPrev(mainButton?.color, true); const mainButtonCurrentTextColor = useCurrentOrPrev(mainButton?.textColor, true); const mainButtonCurrentIsActive = useCurrentOrPrev(mainButton && Boolean(mainButton.isActive), true); const mainButtonCurrentText = useCurrentOrPrev(mainButton?.text, true); const secondaryButtonCurrentPosition = useCurrentOrPrev(secondaryButton?.position, true); const secondaryButtonCurrentColor = useCurrentOrPrev(secondaryButton?.color, true); const secondaryButtonCurrentTextColor = useCurrentOrPrev(secondaryButton?.textColor, true); const secondaryButtonCurrentIsActive = useCurrentOrPrev(secondaryButton && Boolean(secondaryButton.isActive), true); const secondaryButtonCurrentText = useCurrentOrPrev(secondaryButton?.text, true); const [shouldDecreaseWebFrameSize, setShouldDecreaseWebFrameSize] = useState(false); const [shouldHideMainButton, setShouldHideMainButton] = useState(true); const [shouldHideSecondaryButton, setShouldHideSecondaryButton] = useState(true); const [shouldShowMainButton, setShouldShowMainButton] = useState(false); const [shouldShowSecondaryButton, setShouldShowSecondaryButton] = useState(false); const [shouldShowAppNameInFullscreen, setShouldShowAppNameInFullscreen] = useState(false); const [backButtonTextWidth, setBackButtonTextWidth] = useState(0); // Notify view that height changed useSyncEffect(() => { setTimeout(() => { sendViewport(); sendSafeArea(); }, isTransforming ? 0 : ANIMATION_WAIT); }, [shouldShowSecondaryButton, shouldHideSecondaryButton, shouldShowMainButton, shouldShowMainButton, secondaryButton?.position, sendViewport, isTransforming, modalHeight, sendSafeArea]); const isVerticalLayout = secondaryButtonCurrentPosition === 'top' || secondaryButtonCurrentPosition === 'bottom'; const isHorizontalLayout = !isVerticalLayout; const rowsCount = (isVerticalLayout && shouldShowMainButton && shouldShowSecondaryButton) ? 2 : shouldShowMainButton || shouldShowSecondaryButton ? 1 : 0; const hideDirection = (isHorizontalLayout && (!shouldHideMainButton && !shouldHideSecondaryButton)) ? 'horizontal' : 'vertical'; const mainButtonChangeTimeoutRef = useRef>(); const mainButtonFastTimeoutRef = useRef>(); const secondaryButtonChangeTimeoutRef = useRef>(); const secondaryButtonFastTimeoutRef = useRef>(); const appNameDisplayTimeoutRef = useRef>(); useEffect(() => { if (isFullscreen && isOpen && Boolean(activeWebAppName)) { setShouldShowAppNameInFullscreen(true); if (appNameDisplayTimeoutRef.current) { clearTimeout(appNameDisplayTimeoutRef.current); } appNameDisplayTimeoutRef.current = setTimeout(() => { setShouldShowAppNameInFullscreen(false); appNameDisplayTimeoutRef.current = undefined; }, APP_NAME_DISPLAY_DURATION); } else { setShouldShowAppNameInFullscreen(false); if (appNameDisplayTimeoutRef.current) { clearTimeout(appNameDisplayTimeoutRef.current); appNameDisplayTimeoutRef.current = undefined; } } return () => { if (appNameDisplayTimeoutRef.current) { clearTimeout(appNameDisplayTimeoutRef.current); } }; }, [isFullscreen, isOpen, activeWebAppName]); useEffect(() => { if (mainButtonChangeTimeoutRef.current) clearTimeout(mainButtonChangeTimeoutRef.current); if (mainButtonFastTimeoutRef.current) clearTimeout(mainButtonFastTimeoutRef.current); if (isMainButtonVisible) { mainButtonFastTimeoutRef.current = setTimeout(() => { setShouldShowMainButton(true); }, 35); setShouldHideMainButton(false); mainButtonChangeTimeoutRef.current = setTimeout(() => { setShouldDecreaseWebFrameSize(true); }, MAIN_BUTTON_ANIMATION_TIME); } if (!isMainButtonVisible) { setShouldShowMainButton(false); mainButtonChangeTimeoutRef.current = setTimeout(() => { setShouldHideMainButton(true); }, MAIN_BUTTON_ANIMATION_TIME); } }, [isMainButtonVisible]); useEffect(() => { if (secondaryButtonChangeTimeoutRef.current) clearTimeout(secondaryButtonChangeTimeoutRef.current); if (secondaryButtonFastTimeoutRef.current) clearTimeout(secondaryButtonFastTimeoutRef.current); if (isSecondaryButtonVisible) { secondaryButtonFastTimeoutRef.current = setTimeout(() => { setShouldShowSecondaryButton(true); }, 35); setShouldHideSecondaryButton(false); secondaryButtonChangeTimeoutRef.current = setTimeout(() => { setShouldDecreaseWebFrameSize(true); }, MAIN_BUTTON_ANIMATION_TIME); } if (!isSecondaryButtonVisible) { setShouldShowSecondaryButton(false); secondaryButtonChangeTimeoutRef.current = setTimeout(() => { setShouldHideSecondaryButton(true); }, MAIN_BUTTON_ANIMATION_TIME); } }, [isSecondaryButtonVisible]); useEffect(() => { if (!shouldShowSecondaryButton && !shouldShowMainButton) { setShouldDecreaseWebFrameSize(false); } }, [setShouldDecreaseWebFrameSize, shouldShowSecondaryButton, shouldShowMainButton]); const frameStyle = buildStyle( `background-color: ${backgroundColor || 'var(--color-background)'}`, isTransforming && 'pointer-events: none;', ); const handleBackClick = useLastCallback(() => { if (isBackButtonVisible) { sendEvent({ eventType: 'back_button_pressed', }); } else { exitIfFullscreenCallback(); sendFullScreenChanged(false); changeWebAppModalState({ state: 'maximized' }); closeWebAppModal(); } }); const handleCollapseClick = useLastCallback(() => { exitIfFullscreenCallback(); }); const handleShowContextMenu = useLastCallback((e: React.MouseEvent) => { onContextMenuButtonClick(e); }); const backIconClass = buildClassName( styles.closeIcon, isBackButtonVisible && styles.stateBack, ); const backButtonCaption = shouldShowAppNameInFullscreen ? activeWebAppName : oldLang(isBackButtonVisible ? 'Back' : 'Close'); const hasHeaderElement = headerButtonCaptionRef?.current; useEffect(() => { const width = headerButtonCaptionRef?.current?.clientWidth || 0; setBackButtonTextWidth(width); }, [backButtonCaption, hasHeaderElement]); function getBackButtonActiveKey() { if (shouldShowAppNameInFullscreen) return 0; return isBackButtonVisible ? 1 : 2; } function renderFullscreenBackButtonCaption() { return (
{backButtonCaption}
); } function renderFullscreenHeaderPanel() { return (
{renderFullscreenBackButtonCaption()}
); } function renderDefaultPlaceholder() { const className = buildClassName(styles.loadingPlaceholder, styles.defaultPlaceholderGrid, isLoaded && styles.hide); return (
); } function renderPlaceholder() { if (!placeholderPath) { return renderDefaultPlaceholder(); } return ( ); } function renderBottomButtonContent(text: string | undefined, iconCustomEmojiId?: string) { const hasText = Boolean(text?.trim().length); if (!hasText && !iconCustomEmojiId) return undefined; const textContent = hasText ? renderText(text, ['emoji']) : undefined; if (!iconCustomEmojiId) { return textContent; } return ( <> {hasText && ( {textContent} )} ); } return (
{isFullscreen && getIsWebAppsFullscreenSupported() && renderFullscreenHeaderPanel()} {!isMinimizedState && renderPlaceholder()}