import type { ElementRef } from '../../../../lib/teact/teact'; import { useCallback, useEffect, useRef } from '../../../../lib/teact/teact'; import { getActions, getGlobal } from '../../../../global'; import type { WebApp, WebAppInboundEvent, WebAppOutboundEvent } from '../../../../types/webapp'; import { VERIFY_AGE_MIN_DEFAULT } from '../../../../config'; import { getWebAppKey } from '../../../../global/helpers'; import { isMessageFromIframe } from '../../../../util/browser/iframe'; import { isValidProtocol } from '../../../../util/browser/url'; import { extractCurrentThemeParams } from '../../../../util/themeStyle'; import { REM } from '../../../common/helpers/mediaDimensions'; import useLastCallback from '../../../../hooks/useLastCallback'; import useWindowSize from '../../../../hooks/window/useWindowSize'; const SCROLLBAR_STYLE = `* { scrollbar-width: thin; scrollbar-color: %SCROLLBAR_COLOR% transparent; } *::-webkit-scrollbar { width: 6px; height: 6px; background-color: transparent; } *::-webkit-scrollbar-thumb { border-radius: 6px; background-color: %SCROLLBAR_COLOR%; } *::-webkit-scrollbar-corner { background-color: transparent; }`; const RELOAD_TIMEOUT = 500; const FULLSCREEN_BUTTONS_AREA_HEIGHT = 3.675 * REM; const useWebAppFrame = ( ref: ElementRef, isOpen: boolean, isFullscreen: boolean, isSimpleView: boolean, onEvent: (event: WebAppInboundEvent) => void, webApp?: WebApp, onLoad?: () => void, ) => { const { showNotification, setWebAppPaymentSlug, openInvoice, closeWebApp, openSuggestedStatusModal, updateWebApp, updateContentSettings, } = getActions(); const isReloadSupportedRef = useRef(false); const reloadTimeoutRef = useRef>(); const ignoreEventsRef = useRef(false); const lastFrameSizeRef = useRef<{ width: number; height: number; isResizing?: boolean }>(); const windowSize = useWindowSize(); useEffect(() => { if (!ref.current || !isOpen) return undefined; const handleLoad = () => { onLoad?.(); }; const frame = ref.current; frame.addEventListener('load', handleLoad); return () => { frame.removeEventListener('load', handleLoad); }; }, [onLoad, ref, isOpen]); const sendEvent = useCallback((event: WebAppOutboundEvent) => { if (!ref.current?.contentWindow) return; ref.current.contentWindow.postMessage(JSON.stringify(event), '*'); }, [ref]); const sendFullScreenChanged = useCallback((value: boolean) => { sendEvent({ eventType: 'fullscreen_changed', eventData: { is_fullscreen: value, }, }); }, [sendEvent]); const forceReloadFrame = useLastCallback((url: string) => { if (!ref.current) return; const frame = ref.current; frame.src = 'about:blank'; frame.addEventListener('load', () => { frame.src = url; }, { once: true }); }); const reloadFrame = useCallback((url: string) => { if (isReloadSupportedRef.current) { sendEvent({ eventType: 'reload_iframe', }); reloadTimeoutRef.current = setTimeout(() => { forceReloadFrame(url); }, RELOAD_TIMEOUT); return; } forceReloadFrame(url); }, [sendEvent]); const sendViewport = useCallback((isNonStable?: boolean) => { if (!ref.current) { return; } const { width, height } = ref.current.getBoundingClientRect(); sendEvent({ eventType: 'viewport_changed', eventData: { width, height, is_expanded: true, is_state_stable: !isNonStable, }, }); }, [sendEvent, ref]); const sendSafeArea = useCallback(() => { if (!ref.current) { return; } sendEvent({ eventType: 'safe_area_changed', eventData: { left: 0, right: 0, top: 0, bottom: 0, }, }); sendEvent({ eventType: 'content_safe_area_changed', eventData: { left: 0, right: 0, top: isFullscreen ? FULLSCREEN_BUTTONS_AREA_HEIGHT : 0, bottom: 0, }, }); }, [sendEvent, isFullscreen, ref]); const sendTheme = useCallback(() => { sendEvent({ eventType: 'theme_changed', eventData: { theme_params: extractCurrentThemeParams(), }, }); }, [sendEvent]); const sendCustomStyle = useCallback((style: string) => { sendEvent({ eventType: 'set_custom_style', eventData: style, }); }, [sendEvent]); const handleMessage = useCallback((event: MessageEvent) => { if (ignoreEventsRef.current) { return; } if (!isMessageFromIframe(event, ref.current)) { return; } try { const data = JSON.parse(event.data) as WebAppInboundEvent; const { eventType, eventData } = data; // Handle some app requests here to simplify hook usage if (eventType === 'web_app_ready') { onLoad?.(); } if (eventType === 'web_app_close') { if (webApp) { const key = getWebAppKey(webApp); closeWebApp({ key, skipClosingConfirmation: true }); } } if (eventType === 'web_app_request_viewport') { sendViewport(windowSize.isResizing); } if (eventType === 'web_app_request_safe_area') { sendSafeArea(); } if (eventType === 'web_app_request_content_safe_area') { sendSafeArea(); } if (eventType === 'web_app_request_theme') { sendTheme(); } if (eventType === 'iframe_ready') { const scrollbarColor = getComputedStyle(document.body).getPropertyValue('--color-scrollbar'); sendCustomStyle(SCROLLBAR_STYLE.replace(/%SCROLLBAR_COLOR%/g, scrollbarColor)); isReloadSupportedRef.current = Boolean(eventData.reload_supported); } if (eventType === 'iframe_will_reload') { clearTimeout(reloadTimeoutRef.current); } if (eventType === 'web_app_data_send') { if (!isSimpleView) return; // Allowed only in simple view ignoreEventsRef.current = true; } if (eventType === 'web_app_open_scan_qr_popup') { showNotification({ message: 'Scanning QR code is not supported in this client yet', }); } if (eventType === 'web_app_open_invoice') { setWebAppPaymentSlug({ slug: eventData.slug, }); openInvoice({ type: 'slug', slug: eventData.slug, }); } if (eventType === 'web_app_open_link') { if (!isValidProtocol(eventData.url, getGlobal().appConfig.webAppAllowedProtocols)) { return; } window.open(eventData.url, '_blank', 'noopener,noreferrer'); } if (eventType === 'web_app_biometry_get_info') { sendEvent({ eventType: 'biometry_info_received', eventData: { available: false, }, }); } if (eventType === 'web_app_device_storage_clear' || eventType === 'web_app_device_storage_get_key' || eventType === 'web_app_device_storage_save_key') { const { req_id } = eventData; sendEvent({ eventType: 'device_storage_failed', eventData: { req_id, error: 'UNSUPPORTED', }, }); } if (eventType === 'web_app_secure_storage_clear' || eventType === 'web_app_secure_storage_get_key' || eventType === 'web_app_secure_storage_restore_key' || eventType === 'web_app_secure_storage_save_key') { const { req_id } = eventData; sendEvent({ eventType: 'secure_storage_failed', eventData: { req_id, error: 'UNSUPPORTED', }, }); } if (eventType === 'web_app_start_accelerometer') { sendEvent({ eventType: 'accelerometer_failed', eventData: { error: 'UNSUPPORTED', }, }); } if (eventType === 'web_app_start_gyroscope') { sendEvent({ eventType: 'gyroscope_failed', eventData: { error: 'UNSUPPORTED', }, }); } if (eventType === 'web_app_start_device_orientation') { sendEvent({ eventType: 'device_orientation_failed', eventData: { error: 'UNSUPPORTED', }, }); } if (eventType === 'web_app_add_to_home_screen') { sendEvent({ eventType: 'home_screen_failed', eventData: { error: 'UNSUPPORTED', }, }); } if (eventType === 'web_app_check_home_screen') { sendEvent({ eventType: 'home_screen_checked', eventData: { status: 'unsupported', }, }); } if (eventType === 'web_app_set_emoji_status') { const { custom_emoji_id, duration } = eventData; if (!custom_emoji_id || typeof custom_emoji_id !== 'string') { sendEvent({ eventType: 'emoji_status_failed', eventData: { error: 'SUGGESTED_EMOJI_INVALID', }, }); return; } if (duration) { try { BigInt(duration); } catch (e) { sendEvent({ eventType: 'emoji_status_failed', eventData: { error: 'DURATION_INVALID', }, }); return; } } if (!webApp) { sendEvent({ eventType: 'emoji_status_failed', eventData: { error: 'UNKNOWN_ERROR', }, }); return; } openSuggestedStatusModal({ webAppKey: getWebAppKey(webApp), customEmojiId: custom_emoji_id, duration: Number(duration), botId: webApp.botId, }); } if (eventType === 'web_app_verify_age') { const { passed } = eventData; const minAge = getGlobal().appConfig.verifyAgeMin || VERIFY_AGE_MIN_DEFAULT; const ageFromParam = eventData.age || 0; if (passed && ageFromParam >= minAge) { showNotification({ message: { key: 'TitleAgeCheckSuccess', }, }); updateContentSettings({ isSensitiveEnabled: true }); } else { showNotification({ message: { key: 'TitleAgeCheckFailed', }, }); } } onEvent(data); } catch (err) { // Ignore other messages } }, [ isSimpleView, sendEvent, onEvent, sendCustomStyle, webApp, sendTheme, sendViewport, sendSafeArea, onLoad, windowSize.isResizing, ref, ]); useEffect(() => { const { width, height, isResizing } = windowSize; if (lastFrameSizeRef.current && lastFrameSizeRef.current.width === width && lastFrameSizeRef.current.height === height && !lastFrameSizeRef.current.isResizing) return; lastFrameSizeRef.current = { width, height, isResizing }; sendViewport(isResizing); }, [sendViewport, sendSafeArea, windowSize]); useEffect(() => { if (!webApp?.plannedEvents?.length) return; const events = webApp.plannedEvents; events.forEach((event) => { sendEvent(event); }); updateWebApp({ key: getWebAppKey(webApp), update: { plannedEvents: [], }, }); }, [sendEvent, webApp]); useEffect(() => { window.addEventListener('message', handleMessage); return () => window.removeEventListener('message', handleMessage); }, [handleMessage, ref]); useEffect(() => { if (isOpen && ref.current?.contentWindow) { sendViewport(); sendSafeArea(); ignoreEventsRef.current = false; } else { lastFrameSizeRef.current = undefined; } }, [isOpen, isFullscreen, sendViewport, sendSafeArea, ref]); return { sendEvent, sendFullScreenChanged, reloadFrame, sendViewport, sendSafeArea, sendTheme, }; }; export default useWebAppFrame;