TelegramPWA/src/components/modals/webApp/WebAppModalTabContent.tsx
2026-04-27 14:29:15 +02:00

1362 lines
43 KiB
TypeScript

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<string, string> = {
ok: 'OK',
cancel: 'Cancel',
close: 'Close',
};
const NBSP = '\u00A0';
const WebAppModalTabContent: FC<OwnProps & StateProps> = ({
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<WebAppButton>();
const [secondaryButton, setSecondaryButton] = useState<WebAppButton>();
const [isLoaded, markLoaded, markUnloaded] = useFlag(false);
const [popupParameters, setPopupParameters] = useState<PopupOptions>();
const renderingPopupParameters = useCurrentOrPrev(popupParameters);
const [isRequestingPhone, setIsRequestingPhone] = useState(false);
const [isRequestingWriteAccess, setIsRequestingWriteAccess] = useState(false);
const [clipboardRequestId, setClipboardRequestId] = useState<string>();
const [requestedFileDownload, setRequestedFileDownload] = useState<{ url: string; fileName: string }>();
const [bottomBarColor, setBottomBarColor] = useState<string>();
const {
unlockPopupsAt, handlePopupOpened, handlePopupClosed,
} = usePopupLimit(POPUP_SEQUENTIAL_LIMIT, POPUP_RESET_DELAY);
const containerRef = useRef<HTMLDivElement>();
const headerButtonRef = useRef<HTMLDivElement>();
const headerButtonCaptionRef = useRef<HTMLDivElement>();
const isFullscreen = modalState === 'fullScreen';
const isMinimizedState = modalState === 'minimized';
const exitFullScreenCallback = useLastCallback(() => {
setTimeout(() => {
changeWebAppModalState({ state: 'maximized' });
}, COLLAPSING_WAIT);
});
const fullscreenElementRef = useRef<HTMLElement>();
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<WebApp>) => {
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<string>();
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<string>();
const headerColorFromSettings = theme === 'light' ? botAppSettings?.headerColor
: botAppSettings?.headerDarkColor;
useEffect(() => {
const color = headerColorFromEvent || headerColorFromSettings || themeHeaderColor;
updateCurrentWebApp({ headerColor: color });
}, [themeHeaderColor, headerColorFromEvent, headerColorFromSettings]);
const frameRef = useRef<HTMLIFrameElement>();
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<ReturnType<typeof setTimeout>>();
const mainButtonFastTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const secondaryButtonChangeTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const secondaryButtonFastTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const appNameDisplayTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
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 (
<span
className={styles.buttonCaptionContainer}
style={
`width: ${backButtonTextWidth}px;`
}
>
<Transition
activeKey={getBackButtonActiveKey()}
name="slideFade"
>
<div
ref={headerButtonCaptionRef}
className={styles.backButtonCaption}
>
{backButtonCaption}
</div>
</Transition>
</span>
);
}
function renderFullscreenHeaderPanel() {
return (
<div className={styles.headerPanel}>
<div ref={headerButtonRef} className={styles.headerButton} onClick={handleBackClick}>
<div className={styles.backIconContainer}>
<div className={backIconClass} />
</div>
{renderFullscreenBackButtonCaption()}
</div>
<div className={styles.headerSplitButton}>
<div
className={buildClassName(
styles.headerButton,
styles.left,
)}
tabIndex={0}
role="button"
aria-label={lang('WebAppCollapse')}
onClick={handleCollapseClick}
>
<Icon
name="down"
className={styles.icon}
/>
</div>
<div
className={buildClassName(
styles.headerButton,
styles.right,
)}
tabIndex={0}
role="button"
aria-haspopup="menu"
aria-label={lang('AriaMoreButton')}
onClick={handleShowContextMenu}
>
<Icon
name="more"
className={buildClassName(
styles.icon,
styles.moreIcon,
)}
/>
</div>
</div>
</div>
);
}
function renderDefaultPlaceholder() {
const className = buildClassName(styles.loadingPlaceholder, styles.defaultPlaceholderGrid, isLoaded && styles.hide);
return (
<div className={className}>
<div className={styles.placeholderSquare} />
<div className={styles.placeholderSquare} />
<div className={styles.placeholderSquare} />
<div className={styles.placeholderSquare} />
</div>
);
}
function renderPlaceholder() {
if (!placeholderPath) {
return renderDefaultPlaceholder();
}
return (
<svg
className={buildClassName(styles.loadingPlaceholder, isLoaded && styles.hide)}
viewBox="0 0 512 512"
>
<path className={styles.placeholderPath} d={placeholderPath} />
</svg>
);
}
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 (
<>
<CustomEmoji
className={buildClassName(styles.buttonEmoji, hasText && styles.buttonEmojiWithLabel)}
documentId={iconCustomEmojiId}
size={1.25 * REM}
forceAlways
/>
{hasText && (
<span className={styles.buttonLabel}>
{textContent}
</span>
)}
</>
);
}
return (
<div
ref={containerRef}
className={buildClassName(
styles.root,
!isActive && styles.hidden,
isMultiTabSupported && styles.multiTab,
)}
>
{isFullscreen && getIsWebAppsFullscreenSupported() && renderFullscreenHeaderPanel()}
{!isMinimizedState && renderPlaceholder()}
<iframe
className={buildClassName(
styles.frame,
shouldDecreaseWebFrameSize && styles.withButton,
!isLoaded && styles.hide,
)}
style={frameStyle}
src={url}
title={lang('AriaMiniApp', { bot: bot?.firstName })}
sandbox={SANDBOX_ATTRIBUTES}
allow="camera; microphone; geolocation; clipboard-write;"
allowFullScreen
ref={frameRef}
/>
{!isMinimizedState && (
<div
style={`background-color: ${bottomBarColor};`}
className={buildClassName(
styles.buttonsContainer,
secondaryButtonCurrentPosition === 'left' && styles.leftToRight,
secondaryButtonCurrentPosition === 'right' && styles.rightToLeft,
secondaryButtonCurrentPosition === 'top' && styles.topToBottom,
secondaryButtonCurrentPosition === 'bottom' && styles.bottomToTop,
hideDirection === 'horizontal' && styles.hideHorizontal,
rowsCount === 1 && styles.oneRow,
rowsCount === 2 && styles.twoRows,
)}
>
<Button
className={buildClassName(
styles.secondaryButton,
shouldShowSecondaryButton && !shouldHideSecondaryButton && styles.visible,
shouldHideSecondaryButton && styles.hidden,
)}
fluid
style={`background-color: ${secondaryButtonCurrentColor}; color: ${secondaryButtonCurrentTextColor}`}
disabled={!secondaryButtonCurrentIsActive && !secondaryButton?.isProgressVisible}
nonInteractive={secondaryButton?.isProgressVisible}
isShiny={secondaryButton?.hasShineEffect && !secondaryButton?.isProgressVisible}
onClick={handleSecondaryButtonClick}
>
{!secondaryButton?.isProgressVisible && renderBottomButtonContent(
secondaryButtonCurrentText,
secondaryButton?.iconCustomEmojiId,
)}
{secondaryButton?.isProgressVisible
&& <Spinner className={styles.mainButtonSpinner} color="blue" />}
</Button>
<Button
className={buildClassName(
styles.mainButton,
shouldShowMainButton && !shouldHideMainButton && styles.visible,
shouldHideMainButton && styles.hidden,
)}
fluid
style={`background-color: ${mainButtonCurrentColor}; color: ${mainButtonCurrentTextColor}`}
disabled={!mainButtonCurrentIsActive && !mainButton?.isProgressVisible}
nonInteractive={mainButton?.isProgressVisible}
isShiny={mainButton?.hasShineEffect && !mainButton?.isProgressVisible}
onClick={handleMainButtonClick}
>
{!mainButton?.isProgressVisible && renderBottomButtonContent(
mainButtonCurrentText,
mainButton?.iconCustomEmojiId,
)}
{mainButton?.isProgressVisible && <Spinner className={styles.mainButtonSpinner} color="white" />}
</Button>
</div>
)}
<Modal
isOpen={Boolean(popupParameters)}
title={renderingPopupParameters?.title || NBSP}
className={
buildClassName(styles.webAppPopup, !renderingPopupParameters?.title?.trim().length && styles.withoutTitle)
}
hasCloseButton
onClose={handleAppPopupModalClose}
>
{renderingPopupParameters?.message}
<div className="dialog-buttons mt-2">
{renderingPopupParameters?.buttons.map((button) => (
<Button
key={button.id || button.type}
className="confirm-dialog-button"
color={button.type === 'destructive' ? 'danger' : 'primary'}
isText
onClick={() => handleAppPopupClose(button.id)}
>
{button.text || oldLang(DEFAULT_BUTTON_TEXT[button.type])}
</Button>
))}
</div>
</Modal>
<ConfirmDialog
isOpen={isRequestingPhone}
onClose={handleRejectPhone}
title={lang('ShareYouPhoneNumberTitle')}
textParts={lang(
'AreYouSureShareMyContactInfoBot',
undefined,
{ withNodes: true, withMarkdown: true, renderTextFilters: ['br'],
})}
confirmHandler={handleAcceptPhone}
confirmLabel={lang('ContactShare')}
/>
<ConfirmDialog
isOpen={isRequestingWriteAccess}
onClose={handleRejectWriteAccess}
title={oldLang('lng_bot_allow_write_title')}
text={oldLang('lng_bot_allow_write')}
confirmHandler={handleAcceptWriteAccess}
confirmLabel={oldLang('lng_bot_allow_write_confirm')}
/>
<ConfirmDialog
isOpen={Boolean(requestedFileDownload)}
title={lang('BotDownloadFileTitle')}
textParts={lang('BotDownloadFileDescription', {
bot: bot?.firstName,
filename: requestedFileDownload?.fileName,
}, {
withNodes: true,
withMarkdown: true,
})}
confirmLabel={lang('BotDownloadFileButton')}
onClose={handleRejectFileDownload}
confirmHandler={handleDownloadFile}
/>
<ConfirmDialog
isOpen={isCloseModalOpen}
onClose={handleHideCloseModal}
title={oldLang('lng_bot_close_warning_title')}
text={oldLang('lng_bot_close_warning')}
confirmHandler={handleConfirmCloseModal}
confirmIsDestructive
confirmLabel={oldLang('lng_bot_close_warning_sure')}
/>
<ConfirmDialog
isOpen={isRemoveModalOpen}
onClose={handleHideRemoveModal}
title={oldLang('BotRemoveFromMenuTitle')}
textParts={renderText(oldLang('BotRemoveFromMenu', bot?.firstName), ['simple_markdown'])}
confirmHandler={handleRemoveAttachBot}
confirmIsDestructive
/>
<ConfirmDialog
isOpen={Boolean(clipboardRequestId)}
title={lang('BotReadTextFromClipboardTitle')}
text={lang('BotReadTextFromClipboardDescription', { bot: getUserFullName(bot) })}
confirmLabel={lang('BotReadTextFromClipboardConfirm')}
onClose={handleRejectClipboardText}
confirmHandler={handleConfirmClipboardText}
/>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(global, { modal }): Complete<StateProps> => {
const activeWebApp = modal?.activeWebAppKey ? selectWebApp(global, modal.activeWebAppKey) : undefined;
const { botId: activeBotId } = activeWebApp || {};
const modalState = modal?.modalState;
const { verifyAgeMin, verifyAgeBotUsername } = global.appConfig;
const attachBot = activeBotId ? global.attachMenu.bots[activeBotId] : undefined;
const bot = activeBotId ? selectUser(global, activeBotId) : undefined;
const userFullInfo = activeBotId ? selectUserFullInfo(global, activeBotId) : undefined;
const botAppSettings = userFullInfo?.botInfo?.appSettings;
const currentUser = global.currentUserId ? selectUser(global, global.currentUserId) : undefined;
const theme = selectTheme(global);
const { isPaymentModalOpen, status: regularPaymentStatus } = selectTabState(global).payment;
const { status: starsPaymentStatus, inputInvoice: starsInputInvoice } = selectTabState(global).starsPayment;
const botAppPermissions = bot ? selectBotAppPermissions(global, bot.id) : undefined;
const paymentStatus = starsPaymentStatus || regularPaymentStatus;
return {
attachBot,
bot,
currentUser,
theme,
isPaymentModalOpen: isPaymentModalOpen || Boolean(starsInputInvoice),
paymentStatus,
modalState,
botAppPermissions,
botAppSettings,
verifyAgeMin,
verifyAgeBotUsername,
};
},
)(WebAppModalTabContent));