diff --git a/src/api/gramjs/methods/bots.ts b/src/api/gramjs/methods/bots.ts index dc5ab535f..0da2cca98 100644 --- a/src/api/gramjs/methods/bots.ts +++ b/src/api/gramjs/methods/bots.ts @@ -194,6 +194,7 @@ export async function requestWebView({ theme, sendAs, isFromBotMenu, + isFullscreen, }: { isSilent?: boolean; peer: ApiPeer; @@ -204,6 +205,7 @@ export async function requestWebView({ theme?: ApiThemeParameters; sendAs?: ApiPeer; isFromBotMenu?: boolean; + isFullscreen?: boolean; }) { const result = await invokeRequest(new GramJs.messages.RequestWebView({ silent: isSilent || undefined, @@ -215,6 +217,7 @@ export async function requestWebView({ fromBotMenu: isFromBotMenu || undefined, platform: WEB_APP_PLATFORM, replyTo: replyInfo && buildInputReplyTo(replyInfo), + fullscreen: isFullscreen ? true : undefined, ...(sendAs && { sendAs: buildInputPeer(sendAs.id, sendAs.accessHash) }), })); @@ -222,6 +225,7 @@ export async function requestWebView({ return { url: result.url, queryId: result.queryId?.toString(), + isFullScreen: Boolean(result.fullscreen), }; } @@ -232,17 +236,20 @@ export async function requestMainWebView({ peer, bot, startParam, + mode, theme, }: { peer: ApiPeer; bot: ApiUser; startParam?: string; + mode?: string; theme?: ApiThemeParameters; }) { const result = await invokeRequest(new GramJs.messages.RequestMainWebView({ peer: buildInputPeer(peer.id, peer.accessHash), bot: buildInputPeer(bot.id, bot.accessHash), startParam, + fullscreen: mode === 'fullscreen' || undefined, themeParams: theme ? buildInputThemeParams(theme) : undefined, platform: WEB_APP_PLATFORM, })); @@ -254,6 +261,7 @@ export async function requestMainWebView({ return { url: result.url, queryId: result.queryId?.toString(), + isFullscreen: Boolean(result.fullscreen), }; } @@ -310,12 +318,14 @@ export async function requestAppWebView({ peer, app, startParam, + mode, theme, isWriteAllowed, }: { peer: ApiPeer; app: ApiBotApp; startParam?: string; + mode?: string; theme?: ApiThemeParameters; isWriteAllowed?: boolean; }) { @@ -326,9 +336,10 @@ export async function requestAppWebView({ themeParams: theme ? buildInputThemeParams(theme) : undefined, platform: WEB_APP_PLATFORM, writeAllowed: isWriteAllowed || undefined, + fullscreen: mode === 'fullscreen' || undefined, })); - return result?.url; + return { url: result?.url, isFullscreen: Boolean(result?.fullscreen) }; } export function prolongWebView({ diff --git a/src/components/modals/webApp/MinimizedWebAppModal.tsx b/src/components/modals/webApp/MinimizedWebAppModal.tsx index 104f5bd95..f7354cc22 100644 --- a/src/components/modals/webApp/MinimizedWebAppModal.tsx +++ b/src/components/modals/webApp/MinimizedWebAppModal.tsx @@ -61,7 +61,7 @@ const MinimizedWebAppModal = ({ }); const handleExpandClick = useLastCallback(() => { - changeWebAppModalState(); + changeWebAppModalState({ state: 'maximized' }); }); if (!isMinimizedState) return undefined; diff --git a/src/components/modals/webApp/WebAppModal.module.scss b/src/components/modals/webApp/WebAppModal.module.scss index 5652b0d12..30fea2142 100644 --- a/src/components/modals/webApp/WebAppModal.module.scss +++ b/src/components/modals/webApp/WebAppModal.module.scss @@ -58,12 +58,21 @@ .multi-tab { :global { .modal-dialog { - height: min(42.5rem, 85vh); + width: 100%; + height: 100%; + max-height: min(42.5rem, 85vh); + max-width: 26.25rem; background-color: var(--color-web-app-browser); backdrop-filter: blur(1.5625rem); - /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ - transition: height var(--state-transition), width var(--state-transition), transform 0.2s ease, opacity 0.2s ease; + + /* stylelint-disable @stylistic/value-list-comma-newline-after */ + /* stylelint-disable plugin/no-low-performance-animation-properties */ + transition: + max-height var(--state-transition), max-width var(--state-transition), + left var(--state-transition), top var(--state-transition), + transform 0.2s ease, opacity 0.2s ease; + box-shadow: var(--modal-shadow); } @@ -105,13 +114,26 @@ :global { .modal-dialog { cursor: grab !important; - width: 300px; - height: 2.5rem; + max-width: 300px; + max-height: 2.5rem; min-width: 0; } } } +.fullScreen { + :global { + .modal-dialog { + max-width: 100%; + max-height: 100%; + border-radius: 0; + } + .modal-content { + border-radius: 0; + } + } +} + .tabs { display: flex; align-items: center; diff --git a/src/components/modals/webApp/WebAppModal.tsx b/src/components/modals/webApp/WebAppModal.tsx index b7b3f5b29..91bfa2ee9 100644 --- a/src/components/modals/webApp/WebAppModal.tsx +++ b/src/components/modals/webApp/WebAppModal.tsx @@ -20,6 +20,7 @@ import buildClassName from '../../../util/buildClassName'; import buildStyle from '../../../util/buildStyle'; import { getColorLuma } from '../../../util/colors'; import { hexToRgb } from '../../../util/switchTheme'; +import windowSize from '../../../util/windowSize'; import useInterval from '../../../hooks/schedulers/useInterval'; import useAppLayout from '../../../hooks/useAppLayout'; @@ -89,12 +90,16 @@ const WebAppModal: FC = ({ const minimizedStateSize = useMemo(() => { return { width: 300, height: 40 }; }, []); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [getFrameSize, setFrameSize] = useSignal( { width: maximizedStateSize.width, height: maximizedStateSize.height - minimizedStateSize.height }, ); function getSize() { - return modal?.modalState === 'maximized' ? maximizedStateSize : minimizedStateSize; + if (modal?.modalState === 'fullScreen') return windowSize.get(); + if (modal?.modalState === 'maximized') return maximizedStateSize; + return minimizedStateSize; } const { @@ -118,6 +123,8 @@ const WebAppModal: FC = ({ const { isMobile } = useAppLayout(); const isOpen = modal?.isModalOpen || false; const isMaximizedState = modal?.modalState === 'maximized'; + const isMinimizedState = modal?.modalState === 'minimized'; + const isFullScreen = modal?.modalState === 'fullScreen'; const supportMultiTabMode = !isMobile; // eslint-disable-next-line no-null/no-null @@ -151,14 +158,24 @@ const WebAppModal: FC = ({ const containerElement = ref.current; useEffect(() => { - setIsDraggingEnabled(Boolean(supportMultiTabMode && headerElement && containerElement)); - }, [supportMultiTabMode, headerElement, containerElement]); + setIsDraggingEnabled(Boolean(supportMultiTabMode && headerElement && containerElement && !isFullScreen)); + }, [supportMultiTabMode, headerElement, containerElement, isFullScreen]); + + useEffect(() => { + changeWebAppModalState({ state: 'maximized' }); + }, [supportMultiTabMode]); const { isDragging, style: draggableStyle, size, - } = useDraggable(ref, headerRef, isDraggingEnabled, getSize()); + } = useDraggable( + ref, + headerRef, + isDraggingEnabled, + getSize(), + isFullScreen, + ); const currentSize = size || getSize(); @@ -170,7 +187,16 @@ const WebAppModal: FC = ({ const height = currentHeight - minimizedStateSize.height; setFrameSize({ width: currentWidth, height }); } - }, [currentWidth, currentHeight, isMaximizedState, minimizedStateSize, setFrameSize]); + if (isFullScreen) { + setFrameSize({ width: window.innerWidth, height: window.innerHeight }); + } + }, [currentWidth, + currentHeight, + isMaximizedState, + minimizedStateSize, + setFrameSize, + isFullScreen, + isMinimizedState]); const oldLang = useOldLang(); const lang = useLang(); @@ -275,7 +301,7 @@ const WebAppModal: FC = ({ }); const handleCollapseClick = useLastCallback(() => { - changeWebAppModalState(); + changeWebAppModalState({ state: 'minimized' }); }); const handleOpenMoreAppsTabClick = useLastCallback(() => { @@ -497,7 +523,7 @@ const WebAppModal: FC = ({ // eslint-disable-next-line no-null/no-null const containerRef = useRef(null); - useHorizontalScroll(containerRef, !isOpen || !isMaximizedState || !(containerRef.current)); + useHorizontalScroll(containerRef, !isOpen || isMinimizedState || !(containerRef.current)); function renderTabs() { return ( @@ -606,7 +632,8 @@ const WebAppModal: FC = ({ className={buildClassName( styles.root, supportMultiTabMode && styles.multiTab, - !isMaximizedState && styles.minimized, + isMinimizedState && styles.minimized, + isFullScreen && styles.fullScreen, )} dialogStyle={supportMultiTabMode ? draggableStyle : undefined} isOpen={isOpen} @@ -617,6 +644,7 @@ const WebAppModal: FC = ({ noBackdrop noBackdropClose > + {isFullScreen && renderMoreMenu()} {openedWebApps && sessionKeys?.map((key) => ( = ({ registerReloadFrameCallback={registerReloadFrameCallback} webApp={openedWebApps[key]} isDragging={isDragging} - frameSize={supportMultiTabMode ? getFrameSize() : undefined} + onContextMenuButtonClick={handleContextMenu} isMultiTabSupported={supportMultiTabMode} /> ))} diff --git a/src/components/modals/webApp/WebAppModalTabContent.module.scss b/src/components/modals/webApp/WebAppModalTabContent.module.scss index 4ee358eeb..3e6588a90 100644 --- a/src/components/modals/webApp/WebAppModalTabContent.module.scss +++ b/src/components/modals/webApp/WebAppModalTabContent.module.scss @@ -1,9 +1,12 @@ +@use "../../../styles/mixins"; + .root { height: 100%; width: 100%; } .multi-tab { + position: relative; display: flex; flex-direction: column; padding: 0; @@ -157,3 +160,124 @@ padding-left: 2rem; } } + +.closeIcon { + --color-header-text: white; + position: relative; + transform: rotate(-45deg); + + &, + &::before, + &::after { + width: 0.875rem; + height: 0.125rem; + border-radius: 0.125rem; + background-color: var(--color-header-text); + transition: var(--slide-transition) transform, var(--color-transition) background-color; + } + + &::before, + &::after { + position: absolute; + left: 0; + top: 0; + content: ""; + } + + &::before { + transform: rotate(90deg); + } + + &.state-back { + transform: rotate(180deg); + + &::before { + transform: rotate(45deg) scaleX(0.75) translate(0, -0.3125rem); + } + + &::after { + transform: rotate(-45deg) scaleX(0.75) translate(0, 0.3125rem); + } + } +} + +.backIconContainer { + padding-right: 0.25rem; +} + +.moreIcon { + transform: rotate(90deg); +} + +.headerPanel { + display: flex; + flex-direction: row; + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 100; + padding: 1rem; + padding-inline-end: calc(1rem + var(--scrollbar-width)); + justify-content: space-between; +} + +.icon { + font-size: 1.25rem; +} + +.headerSplitButton { + display: flex; + flex-direction: row; +} + +.headerButton { + height: 1.75rem; + width: fit-content; + font-size: 0.875rem; + font-weight: 500; + position: relative; + cursor: var(--custom-cursor, pointer); + flex-shrink: 0; + overflow: hidden; + + outline: none !important; + align-items: center; + display: flex; + justify-content: center; + color: white; + border-radius: 1rem; + background-color: rgba(0, 0, 0, 0.2); + backdrop-filter: blur(25px); + + &:hover { + background-color: rgba(0, 0, 0, 0.1); + } + + padding: 0.25rem; + padding-inline: 0.625rem; +} + +.left { + border-bottom-right-radius: 0; + border-top-right-radius: 0; +} + +.right { + border-bottom-left-radius: 0; + border-top-left-radius: 0; +} + +.buttonCaptionContainer { + /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ + transition: width 0.25s ease-in-out; + height: 100%; +} + +.backButtonCaption { + width: fit-content; + max-width: 20rem; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/src/components/modals/webApp/WebAppModalTabContent.tsx b/src/components/modals/webApp/WebAppModalTabContent.tsx index 467095587..c630b0dd9 100644 --- a/src/components/modals/webApp/WebAppModalTabContent.tsx +++ b/src/components/modals/webApp/WebAppModalTabContent.tsx @@ -6,7 +6,7 @@ import React, { import { getActions, withGlobal } from '../../../global'; import type { ApiAttachBot, ApiChat, ApiUser } from '../../../api/types'; -import type { TabState, WebApp } from '../../../global/types'; +import type { TabState, WebApp, WebAppModalStateType } from '../../../global/types'; import type { ThemeKey } from '../../../types'; import type { PopupOptions, WebAppInboundEvent, WebAppOutboundEvent } from '../../../types/webapp'; @@ -23,18 +23,22 @@ 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 useFlag from '../../../hooks/useFlag'; 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 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'; @@ -53,6 +57,7 @@ export type OwnProps = { webApp?: WebApp; registerSendEventCallback: (callback: (event: WebAppOutboundEvent) => void) => void; registerReloadFrameCallback: (callback: (url: string) => void) => void; + onContextMenuButtonClick: (e: React.MouseEvent) => void; isDragging?: boolean; frameSize?: { width: number; height: number }; isMultiTabSupported? : boolean; @@ -65,15 +70,17 @@ type StateProps = { theme?: ThemeKey; isPaymentModalOpen?: boolean; paymentStatus?: TabState['payment']['status']; - isMaximizedState: boolean; + modalState?: WebAppModalStateType; }; const NBSP = '\u00A0'; 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-same-origin', @@ -99,9 +106,10 @@ const WebAppModalTabContent: FC = ({ registerSendEventCallback, registerReloadFrameCallback, isDragging, - isMaximizedState, + modalState, frameSize, isMultiTabSupported, + onContextMenuButtonClick, }) => { const { closeActiveWebApp, @@ -113,6 +121,8 @@ const WebAppModalTabContent: FC = ({ sharePhoneWithBot, updateWebApp, resetPaymentStatus, + changeWebAppModalState, + closeWebAppModal, } = getActions(); const [mainButton, setMainButton] = useState(); const [secondaryButton, setSecondaryButton] = useState(); @@ -127,9 +137,35 @@ const WebAppModalTabContent: FC = ({ unlockPopupsAt, handlePopupOpened, handlePopupClosed, } = usePopupLimit(POPUP_SEQUENTIAL_LIMIT, POPUP_RESET_DELAY); + // eslint-disable-next-line no-null/no-null + const containerRef = useRef(null); + + // eslint-disable-next-line no-null/no-null + const headerButtonRef = useRef(null); + + // eslint-disable-next-line no-null/no-null + const headerButtonCaptionRef = useRef(null); + + const isFullscreen = modalState === 'fullScreen'; + const isMinimizedState = modalState === 'minimized'; + + const exitFullScreenCallback = useLastCallback(() => { + setTimeout(() => { changeWebAppModalState({ state: 'maximized' }); }, COLLAPSING_WAIT); + }); + + // eslint-disable-next-line no-null/no-null + const fullscreenElementRef = useRef(null); + + useEffect(() => { + fullscreenElementRef.current = document.querySelector('#portals') as HTMLElement; + }, []); + + const [, setFullscreen, exitFullscreen] = useFullscreen(fullscreenElementRef, exitFullScreenCallback); + const activeWebApp = modal?.activeWebApp; + const activeWebAppName = activeWebApp?.appName; const { - url, buttonText, headerColor, serverHeaderColorKey, serverHeaderColor, + url, buttonText, headerColor, serverHeaderColorKey, serverHeaderColor, isBackButtonVisible, } = webApp || {}; const isCloseModalOpen = Boolean(webApp?.isCloseModalOpen); const isRemoveModalOpen = Boolean(webApp?.isRemoveModalOpen); @@ -158,8 +194,8 @@ const WebAppModalTabContent: FC = ({ const isSimple = Boolean(buttonText); const { - reloadFrame, sendEvent, sendViewport, sendTheme, - } = useWebAppFrame(frameRef, isOpen, isSimple, handleEvent, webApp, markLoaded); + reloadFrame, sendEvent, sendFullScreenChanged, sendViewport, sendSafeArea, sendTheme, + } = useWebAppFrame(frameRef, isOpen, isFullscreen, isSimple, handleEvent, webApp, markLoaded); useEffect(() => { if (isActive) registerSendEventCallback(sendEvent); @@ -248,6 +284,32 @@ const WebAppModalTabContent: FC = ({ }, 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]); + useSyncEffect(([prevIsPaymentModalOpen]) => { if (isPaymentModalOpen === prevIsPaymentModalOpen) return; if (webApp?.slug && !isPaymentModalOpen && paymentStatus) { @@ -377,6 +439,24 @@ const WebAppModalTabContent: FC = ({ 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') { const linkUrl = TME_LINK_PREFIX + eventData.path_full; openTelegramLink({ url: linkUrl, shouldIgnoreCache: eventData.force_request }); @@ -514,14 +594,19 @@ const WebAppModalTabContent: FC = ({ 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(); }, ANIMATION_WAIT); }, [shouldShowSecondaryButton, shouldHideSecondaryButton, shouldShowMainButton, shouldShowMainButton, - secondaryButton?.position, sendViewport]); + secondaryButton?.position, sendViewport, sendSafeArea]); const isVerticalLayout = secondaryButtonCurrentPosition === 'top' || secondaryButtonCurrentPosition === 'bottom'; const isHorizontalLayout = !isVerticalLayout; @@ -536,6 +621,35 @@ const WebAppModalTabContent: FC = ({ const mainButtonFastTimeout = useRef>(); const secondaryButtonChangeTimeout = useRef>(); const secondaryButtonFastTimeout = useRef>(); + const appNameDisplayTimeout = useRef>(); + + useEffect(() => { + if (isFullscreen && isOpen && Boolean(activeWebAppName)) { + setShouldShowAppNameInFullscreen(true); + + if (appNameDisplayTimeout.current) { + clearTimeout(appNameDisplayTimeout.current); + } + + appNameDisplayTimeout.current = setTimeout(() => { + setShouldShowAppNameInFullscreen(false); + appNameDisplayTimeout.current = undefined; + }, APP_NAME_DISPLAY_DURATION); + } else { + setShouldShowAppNameInFullscreen(false); + + if (appNameDisplayTimeout.current) { + clearTimeout(appNameDisplayTimeout.current); + appNameDisplayTimeout.current = undefined; + } + } + + return () => { + if (appNameDisplayTimeout.current) { + clearTimeout(appNameDisplayTimeout.current); + } + }; + }, [isFullscreen, isOpen, activeWebAppName]); useEffect(() => { if (mainButtonChangeTimeout.current) clearTimeout(mainButtonChangeTimeout.current); @@ -590,30 +704,140 @@ const WebAppModalTabContent: FC = ({ const frameWidth = frameSize?.width || 0; let frameHeight = frameSize?.height || 0; if (shouldDecreaseWebFrameSize) { frameHeight -= 4 * REM; } - const frameStyle = buildStyle( + const frameStyle = frameSize ? buildStyle( `left: ${0}px;`, `top: ${0}px;`, `width: ${frameWidth}px;`, `height: ${frameHeight}px;`, isDragging ? 'pointer-events: none;' : '', + ) : isDragging ? '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 + : lang(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()} +
+
+
+ +
+
+ +
+
+
+ ); + } return (
- {isMaximizedState && } + {isFullscreen && getIsWebAppsFullscreenSupported() && renderFullscreenHeaderPanel()} + {!isMinimizedState && }