import type { FC } from '../../../lib/teact/teact'; import React, { memo, useEffect, useMemo, useRef, useState, } from '../../../lib/teact/teact'; import { getActions, withGlobal } from '../../../global'; import type { ApiAttachBot, ApiChat, ApiUser } from '../../../api/types'; import type { TabState } from '../../../global/types'; import type { ThemeKey } from '../../../types'; import type { PopupOptions, WebAppInboundEvent } from '../../../types/webapp'; import { TME_LINK_PREFIX } from '../../../config'; import { convertToApiChatType } from '../../../global/helpers'; import { selectCurrentChat, selectTabState, selectTheme, selectUser, } from '../../../global/selectors'; import buildClassName from '../../../util/buildClassName'; import buildStyle from '../../../util/buildStyle'; import { getColorLuma } from '../../../util/colors'; import { hexToRgb } from '../../../util/switchTheme'; import { extractCurrentThemeParams, validateHexColor } from '../../../util/themeStyle'; import { callApi } from '../../../api/gramjs'; import useAppLayout from '../../../hooks/useAppLayout'; import useFlag from '../../../hooks/useFlag'; import useInterval from '../../../hooks/useInterval'; import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; import usePrevious from '../../../hooks/usePrevious'; import useSyncEffect from '../../../hooks/useSyncEffect'; import usePopupLimit from './hooks/usePopupLimit'; import useWebAppFrame from './hooks/useWebAppFrame'; import Button from '../../ui/Button'; import ConfirmDialog from '../../ui/ConfirmDialog'; import DropdownMenu from '../../ui/DropdownMenu'; import MenuItem from '../../ui/MenuItem'; import Modal from '../../ui/Modal'; import Spinner from '../../ui/Spinner'; import styles from './WebAppModal.module.scss'; type WebAppButton = { isVisible: boolean; isActive: boolean; text: string; color: string; textColor: string; isProgressVisible: boolean; }; export type OwnProps = { webApp?: TabState['webApp']; }; type StateProps = { chat?: ApiChat; bot?: ApiUser; attachBot?: ApiAttachBot; theme?: ThemeKey; isPaymentModalOpen?: boolean; paymentStatus?: TabState['payment']['status']; }; const NBSP = '\u00A0'; const MAIN_BUTTON_ANIMATION_TIME = 250; const PROLONG_INTERVAL = 45000; // 45s const ANIMATION_WAIT = 400; const POPUP_SEQUENTIAL_LIMIT = 3; const LUMA_THRESHOLD = 128; const POPUP_RESET_DELAY = 2000; // 2s const SANDBOX_ATTRIBUTES = [ 'allow-scripts', 'allow-same-origin', 'allow-popups', 'allow-forms', 'allow-modals', 'allow-storage-access-by-user-activation', ].join(' '); const DEFAULT_BUTTON_TEXT: Record = { ok: 'OK', cancel: 'Cancel', close: 'Close', }; const WebAppModal: FC = ({ webApp, chat, bot, attachBot, theme, isPaymentModalOpen, paymentStatus, }) => { const { closeWebApp, sendWebViewData, prolongWebView, toggleAttachBot, openTelegramLink, openChat, setWebAppPaymentSlug, switchBotInline, sharePhoneWithBot, } = getActions(); const [mainButton, setMainButton] = useState(); const [isBackButtonVisible, setIsBackButtonVisible] = useState(false); const [backgroundColor, setBackgroundColor] = useState(); const [headerColor, setHeaderColor] = useState(); const [shouldConfirmClosing, setShouldConfirmClosing] = useState(false); const [isCloseModalOpen, openCloseModal, hideCloseModal] = useFlag(false); const [isLoaded, markLoaded, markUnloaded] = useFlag(false); const [popupParameters, setPopupParameters] = useState(); const [isRequestingPhone, setIsRequestingPhone] = useState(false); const [isRequesingWriteAccess, setIsRequestingWriteAccess] = useState(false); const { unlockPopupsAt, handlePopupOpened, handlePopupClosed, } = usePopupLimit(POPUP_SEQUENTIAL_LIMIT, POPUP_RESET_DELAY); const { isMobile } = useAppLayout(); useEffect(() => { const themeParams = extractCurrentThemeParams(); setBackgroundColor(themeParams.bg_color); setHeaderColor(themeParams.bg_color); }, []); // eslint-disable-next-line no-null/no-null const frameRef = useRef(null); const lang = useLang(); const { url, buttonText, queryId, replyToMessageId, threadId, } = webApp || {}; const isOpen = Boolean(url); const isSimple = Boolean(buttonText); const { reloadFrame, sendEvent, sendViewport, sendTheme, } = useWebAppFrame(frameRef, isOpen, isSimple, handleEvent, markLoaded); const shouldShowMainButton = mainButton?.isVisible && mainButton.text.trim().length > 0; useInterval(() => { prolongWebView({ botId: bot!.id, queryId: queryId!, peerId: chat!.id, replyToMessageId, threadId, }); }, queryId ? PROLONG_INTERVAL : undefined, true); const handleMainButtonClick = useLastCallback(() => { sendEvent({ eventType: 'main_button_pressed', }); }); const handleSettingsButtonClick = useLastCallback(() => { sendEvent({ eventType: 'settings_button_pressed', }); }); const handleRefreshClick = useLastCallback(() => { reloadFrame(webApp!.url); }); const handleClose = useLastCallback(() => { if (shouldConfirmClosing) { openCloseModal(); } else { closeWebApp(); } }); const handleAppPopupClose = useLastCallback((buttonId?: string) => { setPopupParameters(undefined); handlePopupClosed(); sendEvent({ eventType: 'popup_closed', eventData: { button_id: buttonId, }, }); }); const handleAppPopupModalClose = useLastCallback(() => { handleAppPopupClose(); }); // Notify view that height changed useSyncEffect(() => { setTimeout(() => { sendViewport(); }, ANIMATION_WAIT); }, [mainButton?.isVisible, sendViewport]); // Notify view that theme changed useSyncEffect(() => { setTimeout(() => { sendTheme(); }, ANIMATION_WAIT); }, [theme, sendTheme]); useSyncEffect(([prevIsPaymentModalOpen]) => { if (isPaymentModalOpen === prevIsPaymentModalOpen) return; if (webApp?.slug && !isPaymentModalOpen && paymentStatus) { sendEvent({ eventType: 'invoice_closed', eventData: { slug: webApp.slug, status: paymentStatus, }, }); setWebAppPaymentSlug({ slug: undefined, }); } }, [isPaymentModalOpen, paymentStatus, sendEvent, setWebAppPaymentSlug, webApp]); const handleToggleClick = useLastCallback(() => { toggleAttachBot({ botId: bot!.id, isEnabled: !attachBot, }); }); const handleBackClick = useLastCallback(() => { if (isBackButtonVisible) { sendEvent({ eventType: 'back_button_pressed', }); } else { handleClose(); } }); 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 handleRejectWriteAccess = useLastCallback(() => { sendEvent({ eventType: 'write_access_requested', eventData: { status: 'cancelled', }, }); setIsRequestingWriteAccess(false); handlePopupClosed(); }); const handleAcceptWriteAccess = useLastCallback(async () => { const result = await callApi('allowBotSendMessages', { bot: bot! }); if (!result) { handleRejectWriteAccess(); return; } sendEvent({ eventType: 'write_access_requested', eventData: { status: 'allowed', }, }); setIsRequestingWriteAccess(false); handlePopupClosed(); }); async function handleRequestWriteAccess() { const canWrite = await callApi('fetchBotCanSendMessage', { bot: bot!, }); if (canWrite) { sendEvent({ eventType: 'write_access_requested', eventData: { status: 'allowed', }, }); } setIsRequestingWriteAccess(!canWrite); } 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, }, }); } const openBotChat = useLastCallback(() => { openChat({ id: bot!.id, }); closeWebApp(); }); useEffect(() => { if (!isOpen) { const themeParams = extractCurrentThemeParams(); setShouldConfirmClosing(false); hideCloseModal(); setPopupParameters(undefined); setIsRequestingPhone(false); setIsRequestingWriteAccess(false); setMainButton(undefined); setIsBackButtonVisible(false); setBackgroundColor(themeParams.bg_color); setHeaderColor(themeParams.bg_color); markUnloaded(); } }, [hideCloseModal, isOpen, markUnloaded]); function handleEvent(event: WebAppInboundEvent) { const { eventType, eventData } = event; if (eventType === 'web_app_open_tg_link' && !isPaymentModalOpen) { const linkUrl = TME_LINK_PREFIX + eventData.path_full; openTelegramLink({ url: linkUrl }); closeWebApp(); } if (eventType === 'web_app_setup_back_button') { setIsBackButtonVisible(eventData.is_visible); } if (eventType === 'web_app_set_background_color') { const themeParams = extractCurrentThemeParams(); const color = validateHexColor(eventData.color) ? eventData.color : themeParams.bg_color; setBackgroundColor(color); } if (eventType === 'web_app_set_header_color') { if (eventData.color_key) { const themeParams = extractCurrentThemeParams(); const key = eventData.color_key; const newColor = themeParams[key]; const color = validateHexColor(newColor) ? newColor : headerColor; setHeaderColor(color); } if (eventData.color) { const color = validateHexColor(eventData.color) ? eventData.color : headerColor; setHeaderColor(color); } } if (eventType === 'web_app_data_send') { closeWebApp(); sendWebViewData({ bot: bot!, buttonText: buttonText!, data: eventData.data, }); } if (eventType === 'web_app_setup_main_button') { const themeParams = extractCurrentThemeParams(); const color = validateHexColor(eventData.color) ? eventData.color : themeParams.button_color; const textColor = validateHexColor(eventData.text_color) ? eventData.text_color : themeParams.text_color; setMainButton({ isVisible: eventData.is_visible && Boolean(eventData.text?.trim().length), isActive: eventData.is_active, text: eventData.text || '', color, textColor, isProgressVisible: eventData.is_progress_visible, }); } if (eventType === 'web_app_setup_closing_behavior') { setShouldConfirmClosing(eventData.need_confirmation); } if (eventType === 'web_app_open_popup') { if (popupParameters || !eventData.message.trim().length || !eventData.buttons?.length || eventData.buttons.length > 3 || isRequestingPhone || isRequesingWriteAccess || 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, }); closeWebApp(); } if (eventType === 'web_app_request_phone') { if (popupParameters || isRequesingWriteAccess || 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)); } } const MoreMenuButton: FC<{ onTrigger: () => void; isOpen?: boolean }> = useMemo(() => { return ({ onTrigger, isOpen: isMenuOpen }) => ( ); }, [isMobile]); const backButtonClassName = buildClassName( styles.closeIcon, isBackButtonVisible && styles.stateBack, ); const headerTextVar = useMemo(() => { if (!headerColor) return undefined; const { r, g, b } = hexToRgb(headerColor); const luma = getColorLuma([r, g, b]); const adaptedLuma = theme === 'dark' ? 255 - luma : luma; return adaptedLuma > LUMA_THRESHOLD ? 'color-text' : 'color-background'; }, [headerColor, theme]); function renderHeader() { return (
{bot?.firstName}
{chat && bot && chat.id !== bot.id && ( {lang('BotWebViewOpenBot')} )} {lang('WebApp.ReloadPage')} {attachBot?.hasSettings && ( {lang('Settings')} )} {bot?.isAttachBot && ( {lang(attachBot ? 'WebApp.RemoveBot' : 'WebApp.AddToAttachmentAdd')} )}
); } const prevMainButtonColor = usePrevious(mainButton?.color, true); const prevMainButtonTextColor = usePrevious(mainButton?.textColor, true); const prevMainButtonIsActive = usePrevious(mainButton && Boolean(mainButton.isActive), true); const prevMainButtonText = usePrevious(mainButton?.text, true); const mainButtonCurrentColor = mainButton?.color || prevMainButtonColor; const mainButtonCurrentTextColor = mainButton?.textColor || prevMainButtonTextColor; const mainButtonCurrentIsActive = mainButton?.isActive !== undefined ? mainButton.isActive : prevMainButtonIsActive; const mainButtonCurrentText = mainButton?.text || prevMainButtonText; const [shouldDecreaseWebFrameSize, setShouldDecreaseWebFrameSize] = useState(false); const [shouldHideButton, setShouldHideButton] = useState(true); const buttonChangeTimeout = useRef>(); useEffect(() => { if (buttonChangeTimeout.current) clearTimeout(buttonChangeTimeout.current); if (!shouldShowMainButton) { setShouldDecreaseWebFrameSize(false); buttonChangeTimeout.current = setTimeout(() => { setShouldHideButton(true); }, MAIN_BUTTON_ANIMATION_TIME); } else { setShouldHideButton(false); buttonChangeTimeout.current = setTimeout(() => { setShouldDecreaseWebFrameSize(true); }, MAIN_BUTTON_ANIMATION_TIME); } }, [setShouldDecreaseWebFrameSize, shouldShowMainButton]); return ( {isOpen && ( <>