2026-04-27 14:29:15 +02:00

430 lines
11 KiB
TypeScript

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 { 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<HTMLIFrameElement>,
isOpen: boolean,
isFullscreen: boolean,
isSimpleView: boolean,
onEvent: (event: WebAppInboundEvent) => void,
webApp?: WebApp,
onLoad?: () => void,
) => {
const {
showNotification,
setWebAppPaymentSlug,
openInvoice,
closeWebApp,
openSuggestedStatusModal,
updateWebApp,
} = getActions();
const isReloadSupportedRef = useRef<boolean>(false);
const reloadTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const ignoreEventsRef = useRef<boolean>(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<string>) => {
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,
});
}
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;